diff --git a/app/components/FileSelector.vue b/app/components/FileSelector.vue index 5ca115c..b73ce13 100644 --- a/app/components/FileSelector.vue +++ b/app/components/FileSelector.vue @@ -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); } } diff --git a/app/components/Sidenav/HeaderResources.vue b/app/components/Sidenav/HeaderResources.vue new file mode 100644 index 0000000..59ad04d --- /dev/null +++ b/app/components/Sidenav/HeaderResources.vue @@ -0,0 +1,22 @@ + + + diff --git a/app/components/Sidenav/NavHome.vue b/app/components/Sidenav/NavHome.vue index 3d02f09..9c74431 100644 --- a/app/components/Sidenav/NavHome.vue +++ b/app/components/Sidenav/NavHome.vue @@ -1,6 +1,6 @@ + + diff --git a/app/components/Sidenav/index.vue b/app/components/Sidenav/index.vue index eba88bb..3c9e520 100644 --- a/app/components/Sidenav/index.vue +++ b/app/components/Sidenav/index.vue @@ -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; }); @@ -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"> +
@@ -155,6 +157,7 @@ const navKind = computed(() => { +
diff --git a/app/composables/useResources.ts b/app/composables/useResources.ts new file mode 100644 index 0000000..16b2f57 --- /dev/null +++ b/app/composables/useResources.ts @@ -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 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('resources:files', () => []); + const loaded = useState('resources:loaded', () => false); + const selectedIds = useState>('resources:selected', () => new Set()); + const category = useState('resources:category', () => 'all'); + + const { refresh } = await useFetch('/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, + }; +}; diff --git a/app/pages/resources.vue b/app/pages/resources.vue new file mode 100644 index 0000000..b325abf --- /dev/null +++ b/app/pages/resources.vue @@ -0,0 +1,264 @@ + + + diff --git a/server/api/file/index.post.ts b/server/api/file/index.post.ts index eae29b2..5e0eb31 100644 --- a/server/api/file/index.post.ts +++ b/server/api/file/index.post.ts @@ -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(); diff --git a/server/api/files.get.ts b/server/api/files.get.ts new file mode 100644 index 0000000..bd775a8 --- /dev/null +++ b/server/api/files.get.ts @@ -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; +});