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:
@@ -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>
|
||||
Reference in New Issue
Block a user