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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Resources
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+ No files found
+
+
+
+
+
+
Name
+
Size
+
Date
+
+
+
+
+
+
+
+
+
+
+
+ {{ file.name }}
+
+
+
+
+ {{ formatSize(file.size) }}
+
+
+
+ {{ formatDate(file.createdAt) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ selectedCount }} selected
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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;
+});