Files
veridian/app/composables/useResources.ts
T
zoeissleeping a0f2e89154 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
2026-05-11 17:42:28 -05:00

187 lines
5.7 KiB
TypeScript

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,
};
};