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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
const { isHovered } = useSidenavContext();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="flex items-center rounded-lg overflow-hidden flex-1 min-w-0">
|
||||
<div class="w-10 md:w-8 flex flex-shrink-0 items-center">
|
||||
<NuxtLink to="/"
|
||||
class="flex @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg decoration-none transition-inherit text-[var(--text-secondary)] p-1.5">
|
||||
<span class="i-mynaui-home text-5.5 md:text-4.5"></span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 pr-1">
|
||||
<span class="i-mynaui-chevron-right inline-block text-5 md:text-4"></span>
|
||||
<span
|
||||
class="md:text-sm font-medium text-ellipsis overflow-hidden text-[var(--text-secondary)] whitespace-nowrap">
|
||||
Resources
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue';
|
||||
import type { DialogType } from '~/composables/useDialog';
|
||||
import { DialogType } from '~/composables/useDialog';
|
||||
|
||||
const { openDialog } = useDialog();
|
||||
const { agents, createAgent, deleteAgent, updateAgent } = await useAgents();
|
||||
@@ -115,7 +115,8 @@ onMounted(() => {
|
||||
</span>
|
||||
</div>
|
||||
</SidenavItem>
|
||||
<SidenavItem to="/" name="Home" icon="i-mynaui-home" />
|
||||
<SidenavItem to="/" name="Home" icon="i-mynaui-home" active />
|
||||
<SidenavItem to="/resources" name="Resources" icon="i-tabler-books" />
|
||||
|
||||
<!-- Header Toggle -->
|
||||
<button
|
||||
@@ -134,7 +135,7 @@ onMounted(() => {
|
||||
class="disabled:cursor-wait flex items-center gap-2 px-1 h-9 shrink-0 rounded-lg md:text-sm text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50 w-full">
|
||||
<div class="h-9 w-9 md:h-7 md:w-7 flex items-center justify-center">
|
||||
<span v-if="creatingAgent" class="i-svg-spinners-ring-resize text-5 md:text-4"></span>
|
||||
<span v-else class="i-mynaui-plus text-5 md:text-4"></span>
|
||||
<span v-else class="i-mynaui-plus-solid text-5 md:text-4"></span>
|
||||
</div>
|
||||
<span>New Agent</span>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { ResourceCategory } from '~/composables/useResources';
|
||||
|
||||
const { category, setCategory, categoryCount } = await useResources();
|
||||
|
||||
const categories: { key: ResourceCategory; label: string; icon: string }[] = [
|
||||
{ key: 'all', label: 'All', icon: 'i-mynaui-folder' },
|
||||
{ key: 'documents', label: 'Documents', icon: 'i-mynaui-file-text' },
|
||||
{ key: 'images', label: 'Images', icon: 'i-mynaui-image' },
|
||||
{ key: 'audio', label: 'Audio', icon: 'i-mynaui-music' },
|
||||
{ key: 'videos', label: 'Videos', icon: 'i-mynaui-video' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="flex flex-col gap-1 overflow-auto">
|
||||
<button v-for="cat in categories" :key="cat.key" @click="setCategory(cat.key)"
|
||||
class="flex items-center justify-between gap-2 px-2 h-9 shrink-0 rounded-lg transition-colors w-full"
|
||||
:class="category === cat.key
|
||||
? 'text-[var(--text-primary)] bg-[var(--color-hover)]'
|
||||
: 'text-[var(--text-secondary)] @hover:bg-[var(--color-hover)]'">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-9 w-9 md:h-7 md:w-7 flex items-center justify-center">
|
||||
<span class="text-5 md:text-4.5" :class="cat.icon"></span>
|
||||
</div>
|
||||
<span class="md:text-sm font-medium">{{ cat.label }}</span>
|
||||
</div>
|
||||
<span class="text-xs tabular-nums text-[var(--text-tertiary)]">
|
||||
{{ categoryCount(cat.key) }}
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -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;
|
||||
});
|
||||
</script>
|
||||
@@ -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">
|
||||
|
||||
<SidenavHeader v-if="navKind === 'home'" />
|
||||
<SidenavHeaderResources v-else-if="navKind === 'resources'" />
|
||||
<SidenavHeaderAgent v-else-if="navKind === 'agent'" />
|
||||
|
||||
<div class="flex items-center justify-end text-[var(--text-secondary)] gap-0.5">
|
||||
@@ -155,6 +157,7 @@ const navKind = computed(() => {
|
||||
<!-- Main Menu -->
|
||||
<SidenavNavHome v-if="navKind === 'home'" />
|
||||
<SidenavNavAgent v-else-if="navKind === 'agent'" />
|
||||
<SidenavNavResources v-else-if="navKind === 'resources'" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between pt-2 z-25 bg-[var(--bg-base)] pb-2">
|
||||
|
||||
@@ -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<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,
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
Reference in New Issue
Block a user