feat: add resources page with file management

- Add /resources page with file listing, filtering, and bulk operations
- Add useResources composable for file state management
- Add sidenav header and nav components for resources section
- Add files list API endpoint (GET /api/files)
- Track file size during upload
- Add resources link to home navigation
This commit is contained in:
Zoe
2026-05-11 17:42:28 -05:00
parent 103dedd9cc
commit a0f2e89154
9 changed files with 533 additions and 12 deletions
+1 -8
View File
@@ -90,6 +90,7 @@ const uploadFile = async (file: File) => {
id,
name: fileName,
mimeType: fileType,
size: file.size,
url: assetUrl,
},
})
@@ -146,14 +147,6 @@ watch(files, async (newFiles, oldFiles) => {
}
if (removedFile.url.startsWith('blob:')) {
// we knpw that if the url starts with blob: it was the first time we uploaded it
// so we can just delete it
if (removedFile.status === 'uploaded') {
await $fetch(`/api/file/${removedFile.id}`, {
method: 'DELETE',
})
}
URL.revokeObjectURL(removedFile.url);
}
}
@@ -0,0 +1,22 @@
<script setup lang="ts">
const { isHovered } = useSidenavContext();
</script>
<template>
<header class="flex items-center rounded-lg overflow-hidden flex-1 min-w-0">
<div class="w-10 md:w-8 flex flex-shrink-0 items-center">
<NuxtLink to="/"
class="flex @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg decoration-none transition-inherit text-[var(--text-secondary)] p-1.5">
<span class="i-mynaui-home text-5.5 md:text-4.5"></span>
</NuxtLink>
</div>
<div class="flex items-center gap-1 pr-1">
<span class="i-mynaui-chevron-right inline-block text-5 md:text-4"></span>
<span
class="md:text-sm font-medium text-ellipsis overflow-hidden text-[var(--text-secondary)] whitespace-nowrap">
Resources
</span>
</div>
</header>
</template>
+4 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue';
import type { DialogType } from '~/composables/useDialog';
import { DialogType } from '~/composables/useDialog';
const { openDialog } = useDialog();
const { agents, createAgent, deleteAgent, updateAgent } = await useAgents();
@@ -115,7 +115,8 @@ onMounted(() => {
</span>
</div>
</SidenavItem>
<SidenavItem to="/" name="Home" icon="i-mynaui-home" />
<SidenavItem to="/" name="Home" icon="i-mynaui-home" active />
<SidenavItem to="/resources" name="Resources" icon="i-tabler-books" />
<!-- Header Toggle -->
<button
@@ -134,7 +135,7 @@ onMounted(() => {
class="disabled:cursor-wait flex items-center gap-2 px-1 h-9 shrink-0 rounded-lg md:text-sm text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50 w-full">
<div class="h-9 w-9 md:h-7 md:w-7 flex items-center justify-center">
<span v-if="creatingAgent" class="i-svg-spinners-ring-resize text-5 md:text-4"></span>
<span v-else class="i-mynaui-plus text-5 md:text-4"></span>
<span v-else class="i-mynaui-plus-solid text-5 md:text-4"></span>
</div>
<span>New Agent</span>
</button>
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { ResourceCategory } from '~/composables/useResources';
const { category, setCategory, categoryCount } = await useResources();
const categories: { key: ResourceCategory; label: string; icon: string }[] = [
{ key: 'all', label: 'All', icon: 'i-mynaui-folder' },
{ key: 'documents', label: 'Documents', icon: 'i-mynaui-file-text' },
{ key: 'images', label: 'Images', icon: 'i-mynaui-image' },
{ key: 'audio', label: 'Audio', icon: 'i-mynaui-music' },
{ key: 'videos', label: 'Videos', icon: 'i-mynaui-video' },
];
</script>
<template>
<nav class="flex flex-col gap-1 overflow-auto">
<button v-for="cat in categories" :key="cat.key" @click="setCategory(cat.key)"
class="flex items-center justify-between gap-2 px-2 h-9 shrink-0 rounded-lg transition-colors w-full"
:class="category === cat.key
? 'text-[var(--text-primary)] bg-[var(--color-hover)]'
: 'text-[var(--text-secondary)] @hover:bg-[var(--color-hover)]'">
<div class="flex items-center gap-2">
<div class="h-9 w-9 md:h-7 md:w-7 flex items-center justify-center">
<span class="text-5 md:text-4.5" :class="cat.icon"></span>
</div>
<span class="md:text-sm font-medium">{{ cat.label }}</span>
</div>
<span class="text-xs tabular-nums text-[var(--text-tertiary)]">
{{ categoryCount(cat.key) }}
</span>
</button>
</nav>
</template>
+3
View File
@@ -96,6 +96,7 @@ onUnmounted(() => {
const navKind = computed(() => {
if (route.path === '/') return 'home';
if (route.path.startsWith('/agent/')) return 'agent';
if (route.path === '/resources') return 'resources';
return null;
});
</script>
@@ -123,6 +124,7 @@ const navKind = computed(() => {
class="z-25 bg-[var(--bg-base)] relative flex flex-row gap-2 justify-between items-center shrink-0 pb-1.5 h-13 md:h-11 pt-2">
<SidenavHeader v-if="navKind === 'home'" />
<SidenavHeaderResources v-else-if="navKind === 'resources'" />
<SidenavHeaderAgent v-else-if="navKind === 'agent'" />
<div class="flex items-center justify-end text-[var(--text-secondary)] gap-0.5">
@@ -155,6 +157,7 @@ const navKind = computed(() => {
<!-- Main Menu -->
<SidenavNavHome v-if="navKind === 'home'" />
<SidenavNavAgent v-else-if="navKind === 'agent'" />
<SidenavNavResources v-else-if="navKind === 'resources'" />
</div>
<div class="flex justify-between pt-2 z-25 bg-[var(--bg-base)] pb-2">
+186
View File
@@ -0,0 +1,186 @@
import { attempt } from "~~/types/result";
import * as schema from '~~/drizzle/schema';
export type ResourceFile = typeof schema.files.$inferSelect;
export type ResourceCategory = 'all' | 'documents' | 'images' | 'audio' | 'videos';
const categoryMimeMap: Record<ResourceCategory, (mime: string) => boolean> = {
all: () => true,
documents: (mime) =>
mime === 'application/pdf' ||
mime.startsWith('text/') ||
mime.includes('document') ||
mime.includes('spreadsheet') ||
mime.includes('presentation'),
images: (mime) => mime.startsWith('image/'),
audio: (mime) => mime.startsWith('audio/'),
videos: (mime) => mime.startsWith('video/'),
};
export const useResources = async () => {
const files = useState<ResourceFile[]>('resources:files', () => []);
const loaded = useState('resources:loaded', () => false);
const selectedIds = useState<Set<string>>('resources:selected', () => new Set());
const category = useState<ResourceCategory>('resources:category', () => 'all');
const { refresh } = await useFetch<ResourceFile[]>('/api/files', {
key: 'resources_files_request',
immediate: !loaded.value,
onRequest() {
loaded.value = true;
},
onResponse({ response }) {
if (response.ok) {
files.value = response._data ?? [];
}
},
});
const filteredFiles = computed(() => {
const filter = categoryMimeMap[category.value];
return files.value.filter(f => filter(f.mimeType));
});
const setCategory = (cat: ResourceCategory) => {
category.value = cat;
clearSelection();
};
const categoryCount = (cat: ResourceCategory): number => {
const filter = categoryMimeMap[cat];
return files.value.filter(f => filter(f.mimeType)).length;
};
const toggleSelect = (id: string) => {
const next = new Set(selectedIds.value);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
selectedIds.value = next;
};
const selectAll = () => {
selectedIds.value = new Set(filteredFiles.value.map(f => f.id));
};
const clearSelection = () => {
selectedIds.value = new Set();
};
const isAllSelected = computed(() =>
filteredFiles.value.length > 0 && selectedIds.value.size === filteredFiles.value.length
);
const hasSelection = computed(() => selectedIds.value.size > 0);
const selectedCount = computed(() => selectedIds.value.size);
const deleteFile = async (id: string) => {
const file = files.value.find(f => f.id === id);
if (!file) return;
const res = await attempt($fetch(`/api/file/${id}`, {
method: 'DELETE',
onRequest() {
files.value = files.value.filter(f => f.id !== id);
const next = new Set(selectedIds.value);
next.delete(id);
selectedIds.value = next;
},
onRequestError() {
files.value = [...files.value, file].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
},
onResponseError() {
files.value = [...files.value, file].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
},
}));
return res;
};
const deleteSelected = async () => {
const ids = Array.from(selectedIds.value);
clearSelection();
const results = await Promise.allSettled(
ids.map(id => deleteFile(id))
);
await refresh();
return results;
};
const copyLink = async (file: ResourceFile) => {
const url = file.url;
await navigator.clipboard.writeText(url);
};
const downloadFile = (file: ResourceFile) => {
const url = file.url;
const a = document.createElement('a');
a.href = url;
a.download = file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const getFileIcon = (mimeType: string): string => {
if (mimeType.startsWith('image/')) return 'i-mynaui-image';
if (mimeType === 'application/pdf') return 'i-tabler-file-type-pdf';
if (mimeType.startsWith('text/')) return 'i-mynaui-file-text';
if (mimeType.startsWith('video/')) return 'i-mynaui-video';
if (mimeType.startsWith('audio/')) return 'i-mynaui-music';
return 'i-mynaui-file-text';
};
const isImage = (mimeType: string): boolean => mimeType.startsWith('image/');
const formatSize = (bytes: number): string => {
if (bytes === 0) return '-';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const size = bytes / Math.pow(1024, i);
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
};
const formatDate = (date: Date | string): string => {
const d = new Date(date);
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
return {
files,
filteredFiles,
selectedIds,
selectedCount,
hasSelection,
isAllSelected,
category,
setCategory,
categoryCount,
toggleSelect,
selectAll,
clearSelection,
deleteFile,
deleteSelected,
copyLink,
downloadFile,
getFileIcon,
isImage,
formatSize,
formatDate,
refresh,
};
};
+264
View File
@@ -0,0 +1,264 @@
<script setup lang="ts">
import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue';
const { open: sidebarOpen, openSidebar } = useSidebar();
const {
filteredFiles,
selectedIds,
selectedCount,
hasSelection,
isAllSelected,
toggleSelect,
selectAll,
clearSelection,
deleteFile,
deleteSelected,
copyLink,
downloadFile,
getFileIcon,
isImage,
formatSize,
formatDate,
} = await useResources();
const scrollRef = ref<HTMLElement | null>(null);
const dropdownOpen = ref(false);
const dropdownTrigger = ref<HTMLElement | null>(null);
const dropdownContent = ref(null);
const activeMenuFileId = ref<string | null>(null);
const previewSrc = ref<string | null>(null);
const previewOrigin = ref<DOMRect | null>(null);
const { floatingStyles, placement, middlewareData } = useFloating(dropdownTrigger, dropdownContent, {
placement: 'bottom-end',
whileElementsMounted: autoUpdate,
middleware: [offset(6), flip(), shift({ padding: 10 }), hide()],
transform: false,
});
const transformOrigin = computed(() =>
placement.value.startsWith('top')
? 'transform-origin-bottom-center'
: 'transform-origin-top-center'
);
const closeDropdown = () => {
dropdownOpen.value = false;
activeMenuFileId.value = null;
dropdownTrigger.value = null;
};
const menuFile = computed(() =>
filteredFiles.value.find(f => f.id === activeMenuFileId.value)
);
const handleRowClick = (e: MouseEvent) => {
const trigger = (e.target as HTMLElement).closest('[data-action]') as HTMLElement | null;
if (!trigger) return;
const fileId = trigger.dataset.fileId || (trigger.closest('[data-file-id]') as HTMLElement | null)?.dataset.fileId;
if (!fileId) return;
const action = trigger.dataset.action;
switch (action) {
case 'toggle-dropdown':
e.preventDefault();
e.stopPropagation();
if (activeMenuFileId.value === fileId && dropdownOpen.value) {
closeDropdown();
return;
}
activeMenuFileId.value = fileId;
dropdownTrigger.value = trigger;
dropdownOpen.value = true;
break;
case 'toggle-select':
e.preventDefault();
e.stopPropagation();
toggleSelect(fileId);
break;
case 'open':
e.preventDefault();
e.stopPropagation();
const file = filteredFiles.value.find(f => f.id === fileId);
if (file && isImage(file.mimeType)) {
const row = trigger.closest('[data-file-id]') as HTMLElement;
previewOrigin.value = row?.getBoundingClientRect() ?? null;
previewSrc.value = file.url;
}
break;
}
};
const handleSelectAll = () => {
if (isAllSelected.value) {
clearSelection();
} else {
selectAll();
}
};
const handleDeleteSelected = async () => {
await deleteSelected();
};
const closePreview = () => {
previewSrc.value = null;
previewOrigin.value = null;
};
</script>
<template>
<div class="h-full w-full flex flex-col">
<div class="h-14 flex items-center justify-between px-4 shrink-0">
<div class="flex items-center gap-2">
<button v-if="!sidebarOpen" @click="openSidebar"
class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent @hover:bg-[var(--color-hover)] transition-colors">
<span class="i-mynaui-panel-left-open text-5"></span>
</button>
<h1 class="text-lg font-semibold text-[var(--text-primary)]">Resources</h1>
</div>
</div>
<div ref="scrollRef" class="flex-1 overflow-auto pb-4">
<div v-if="filteredFiles.length === 0"
class="flex flex-col items-center justify-center h-full gap-2 text-[var(--text-tertiary)]">
<span class="i-tabler-books text-10"></span>
<span class="text-sm">No files found</span>
</div>
<div v-else @click="handleRowClick" class="flex flex-col">
<div
class="sticky top-0 z-5 bg-[var(--bg-surface)] flex items-center gap-3 px-3 h-10 shrink-0 text-[var(--text-tertiary)] text-xs font-medium uppercase tracking-wider border-b border-[var(--color-border)]">
<button @click="handleSelectAll"
class="flex items-center justify-center h-4 w-4 rounded border transition-colors shrink-0"
:class="isAllSelected
? 'bg-[var(--color-accent)] border-[var(--color-accent)]'
: 'border-[var(--color-border)] @hover:border-[var(--color-border-active)]'">
<span v-if="isAllSelected" class="i-mynaui-check text-3 text-[var(--color-accent-text)]"></span>
</button>
<span class="flex-1 min-w-0">Name</span>
<span class="w-20 text-right shrink-0 hidden md:block">Size</span>
<span class="w-24 text-right shrink-0 hidden md:block">Date</span>
<div class="w-8 shrink-0"></div>
</div>
<RowVirtualizerFixed :scroll-element="scrollRef" key-field="id" :items="filteredFiles" :item-size="48"
:overscan="20" :prerender="50">
<template v-slot="{ item: file }">
<div :data-file-id="file.id" class="group flex items-center gap-3 px-3 cursor-default h-12"
:class="selectedIds.has(file.id)
? 'bg-[var(--color-active)]'
: '@hover:bg-[var(--color-hover)]'">
<button data-action="toggle-select" :data-file-id="file.id"
class="flex items-center justify-center h-4 w-4 rounded border shrink-0 transition-colors"
:class="selectedIds.has(file.id)
? 'bg-[var(--color-accent)] border-[var(--color-accent)]'
: 'border-[var(--color-border)] @hover:border-[var(--color-border-active)]'">
<span v-if="selectedIds.has(file.id)"
class="i-mynaui-check text-3 text-[var(--color-accent-text)]"></span>
</button>
<div data-action="open" :data-file-id="file.id"
class="flex items-center gap-2 flex-1 min-w-0"
:class="isImage(file.mimeType) ? 'cursor-pointer' : ''">
<span class="text-6 text-[var(--text-tertiary)] shrink-0"
:class="getFileIcon(file.mimeType)"></span>
<span
class="md:text-sm font-medium text-[var(--text-primary)] overflow-hidden text-ellipsis whitespace-nowrap">
{{ file.name }}
</span>
</div>
<span
class="w-20 text-right text-xs text-[var(--text-tertiary)] tabular-nums shrink-0 hidden md:block">
{{ formatSize(file.size) }}
</span>
<span
class="w-24 text-right text-xs text-[var(--text-tertiary)] tabular-nums shrink-0 hidden md:block">
{{ formatDate(file.createdAt) }}
</span>
<div data-action="toggle-dropdown" :data-file-id="file.id"
class="text-[var(--text-secondary)] shrink-0 md:opacity-0 group-hover:opacity-100 p-1 flex items-center justify-center rounded-md @hover:bg-[var(--color-hover)] transition-[opacity,background-color] duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="pointer-events-none h-6 w-6 md:h-4.5 md:w-4.5 i-tabler-dots"></span>
</div>
</div>
</template>
</RowVirtualizerFixed>
</div>
</div>
<Transition
enter-active-class="transition-[opacity,transform] duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
enter-from-class="opacity-0 translate-y-4" enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
leave-from-class="opacity-100 translate-y-0" leave-to-class="opacity-0 translate-y-4">
<div v-if="hasSelection"
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-full px-4 py-2 shadow-xl">
<span class="text-sm font-medium text-[var(--text-primary)]">
{{ selectedCount }} selected
</span>
<div class="w-px h-4 bg-[var(--color-border)]"></div>
<button @click="handleDeleteSelected"
class="flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-medium text-red-500 @hover:bg-red-500/10 transition-colors">
<span class="i-mynaui-trash text-4"></span>
Delete
</button>
<button @click="clearSelection"
class="flex items-center justify-center p-1 rounded-full text-[var(--text-tertiary)] @hover:bg-[var(--color-hover)] transition-colors">
<span class="i-mynaui-x text-4"></span>
</button>
</div>
</Transition>
<Teleport to="body">
<Transition
enter-active-class="transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
enter-from-class="opacity-0 scale-95 translate-y-1" enter-to-class="opacity-100 scale-100 translate-y-0"
leave-active-class="transition-[opacity,transform] duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
leave-from-class="opacity-100 scale-100 translate-y-0"
leave-to-class="opacity-0 scale-95 translate-y-1">
<div v-if="dropdownOpen" ref="dropdownContent" :style="{
...floatingStyles,
visibility: middlewareData.hide?.referenceHidden
? 'hidden'
: 'visible',
}" class="fixed z-15" :class="transformOrigin">
<div v-click-outside="closeDropdown"
class="bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 shadow-xl flex flex-col gap-1 min-w-40">
<template v-if="menuFile">
<button @click="copyLink(menuFile); closeDropdown()"
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors flex items-center gap-2">
<span class="i-mynaui-copy text-4 text-[var(--text-tertiary)]"></span>
Copy Link
</button>
<button @click="downloadFile(menuFile); closeDropdown()"
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors flex items-center gap-2">
<span class="i-mynaui-download text-4 text-[var(--text-tertiary)]"></span>
Download
</button>
<div class="h-px bg-[var(--color-border)] my-1" />
<button @click="deleteFile(menuFile.id); closeDropdown()"
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors text-red-500 flex items-center gap-2">
<span class="i-mynaui-trash text-4"></span>
Delete
</button>
</template>
</div>
</div>
</Transition>
</Teleport>
<ImageViewer v-if="previewSrc" :src="previewSrc" :origin-rect="previewOrigin" @close="closePreview" />
</div>
</template>
+3 -1
View File
@@ -13,6 +13,7 @@ export default defineEventHandler(async (event) => {
id: z.string(),
name: z.string(),
mimeType: z.string(),
size: z.number(),
url: z.string(),
})
.safeParse(body),
@@ -24,13 +25,14 @@ export default defineEventHandler(async (event) => {
});
}
const { id, name, mimeType, url } = result.data;
const { id, name, mimeType, size, url } = result.data;
const file = await db.insert(files).values({
id,
userId,
name,
mimeType,
size,
url,
createdAt: new Date(),
}).returning();
+17
View File
@@ -0,0 +1,17 @@
import { db } from "~~/server/lib/db";
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id as string;
const result = await db.query.files.findMany({
where: {
userId,
},
orderBy: {
createdAt: 'desc',
}
});
return result;
});