a0f2e89154
- 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
187 lines
5.7 KiB
TypeScript
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,
|
|
};
|
|
};
|