Add a dedicated Library page for search, sort, select, and drag-to-folder.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,802 @@
|
||||
<template>
|
||||
<div class="flex h-dvh min-w-0 flex-col overflow-hidden">
|
||||
<header class="sticky top-0 z-20 border-b border-white/5 bg-zinc-950/70 backdrop-blur-xl">
|
||||
<div class="mx-auto flex max-w-[90rem] items-center justify-between px-4 py-4 sm:px-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<NuxtLink to="/" class="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-400 text-zinc-950 font-display font-extrabold">A</NuxtLink>
|
||||
<div>
|
||||
<h1 class="font-display text-xl font-bold leading-none">{{ instanceName }}</h1>
|
||||
<p class="text-[11px] uppercase tracking-[0.22em] text-zinc-500">Library</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<NuxtLink to="/" class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white">Studio</NuxtLink>
|
||||
<NuxtLink to="/library" class="rounded-full border border-amber-300/70 bg-amber-400/10 px-3 py-1 text-amber-100">Library</NuxtLink>
|
||||
<NuxtLink to="/director" class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white">Director</NuxtLink>
|
||||
<NuxtLink to="/queue" class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white">Queue</NuxtLink>
|
||||
<button type="button" class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white" @click="settingsOpen = true">Settings</button>
|
||||
<a v-if="authEnabled" href="/api/auth/logout" class="text-zinc-400 hover:text-white">Sign out</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto flex min-h-0 w-full max-w-[90rem] flex-1 flex-col gap-4 px-4 py-4 sm:px-6 lg:flex-row">
|
||||
<section class="glass flex min-h-0 min-w-0 flex-1 flex-col rounded-3xl p-4 sm:p-5">
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<label class="min-w-[12rem] flex-1 text-sm">
|
||||
<span class="mb-1 block text-xs text-zinc-500">Search</span>
|
||||
<input
|
||||
v-model="search"
|
||||
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
|
||||
placeholder="Name or prompt"
|
||||
type="search"
|
||||
>
|
||||
</label>
|
||||
<label class="text-sm">
|
||||
<span class="mb-1 block text-xs text-zinc-500">Sort</span>
|
||||
<select v-model="sort" class="rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm">
|
||||
<option value="date-desc">Newest</option>
|
||||
<option value="date-asc">Oldest</option>
|
||||
<option value="name-asc">Name A–Z</option>
|
||||
<option value="name-desc">Name Z–A</option>
|
||||
<option value="type">Type (images first)</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="inline-flex rounded-full border border-white/10 p-0.5">
|
||||
<button
|
||||
v-for="option in filterOptions"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
class="rounded-full px-2.5 py-1.5 text-[11px]"
|
||||
:class="filter === option.id ? 'bg-amber-400/15 text-amber-100' : 'text-zinc-500 hover:text-zinc-300'"
|
||||
@click="filter = option.id"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex min-w-0 flex-wrap gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border px-2.5 py-1 text-[11px]"
|
||||
:class="folderId === 'all' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400'"
|
||||
@click="folderId = 'all'"
|
||||
>
|
||||
All
|
||||
<span class="text-zinc-500">{{ allCount }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="folder in folders"
|
||||
:key="folder.id"
|
||||
type="button"
|
||||
class="rounded-full border px-2.5 py-1 text-[11px]"
|
||||
:class="folderChipClass(folder.id)"
|
||||
@click="folderId = folder.id"
|
||||
@dragover.prevent="onFolderDragOver(folder.id, $event)"
|
||||
@dragleave="onFolderDragLeave(folder.id)"
|
||||
@drop.prevent="onFolderDrop(folder.id, $event)"
|
||||
>
|
||||
{{ folder.name }}
|
||||
<span class="text-zinc-500">{{ folderOutputCount(folder, clips, stills, filter) }}</span>
|
||||
<span v-if="folder.protected && !folder.unlocked"> · lock</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form v-if="browseFolder?.protected && !browseFolder.unlocked" class="mt-6 space-y-3" @submit.prevent="unlockFolder">
|
||||
<p class="text-sm text-zinc-400">This folder is locked. Unlock to browse it.</p>
|
||||
<input v-model="unlockPassword" type="password" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="Folder password">
|
||||
<button type="submit" class="rounded-xl bg-amber-400 px-4 py-2 text-sm font-semibold text-zinc-950">Unlock</button>
|
||||
</form>
|
||||
|
||||
<template v-else>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2 text-xs">
|
||||
<p class="text-zinc-500">{{ entries.length }} item{{ entries.length === 1 ? '' : 's' }}</p>
|
||||
<button v-if="entries.length" type="button" class="text-zinc-400 hover:text-white" @click="selectedKeys.length === entries.length ? clearSelection() : selectAll()">
|
||||
{{ selectedKeys.length && selectedKeys.length === entries.length ? 'Clear selection' : 'Select all' }}
|
||||
</button>
|
||||
<button v-if="browseFolder?.protected && browseFolder.unlocked" type="button" class="text-zinc-400 hover:text-white" @click="lockFolder">Lock folder</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedKeys.length"
|
||||
class="mt-3 flex flex-wrap items-center gap-2 rounded-xl border border-amber-300/20 bg-amber-400/5 px-3 py-2 text-xs"
|
||||
>
|
||||
<span class="font-medium text-amber-100">{{ selectedKeys.length }} selected</span>
|
||||
<select
|
||||
v-if="folders.length > 1"
|
||||
class="max-w-[10rem] rounded-lg border border-white/10 bg-zinc-950 px-1.5 py-0.5 text-amber-300"
|
||||
:value="''"
|
||||
@change="onMoveSelected($event)"
|
||||
>
|
||||
<option disabled value="">Move to…</option>
|
||||
<option v-for="folder in moveFolders" :key="folder.id" :value="folder.id">
|
||||
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
|
||||
</option>
|
||||
</select>
|
||||
<button type="button" class="text-red-300 hover:text-red-200" @click="askRemoveSelected">Delete</button>
|
||||
<button type="button" class="text-zinc-400 hover:text-white" @click="clearSelection">Clear</button>
|
||||
</div>
|
||||
|
||||
<p v-if="!entries.length" class="mt-8 text-center text-sm text-zinc-500">{{ emptyLabel }}</p>
|
||||
<div
|
||||
v-else
|
||||
ref="scroller"
|
||||
class="mt-3 min-h-[24rem] flex-1 overflow-y-auto"
|
||||
@scroll="onScroll"
|
||||
>
|
||||
<div :style="{ height: `${gridHeight}px`, position: 'relative' }">
|
||||
<div
|
||||
class="grid"
|
||||
:style="{
|
||||
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
||||
gap: `${GAP}px`,
|
||||
transform: `translateY(${offsetY}px)`
|
||||
}"
|
||||
>
|
||||
<article
|
||||
v-for="entry in visibleEntries"
|
||||
:key="entry.key"
|
||||
draggable="true"
|
||||
class="library-thumb group relative cursor-pointer overflow-hidden rounded-2xl border bg-zinc-950"
|
||||
:class="cardClass(entry)"
|
||||
@click="onCardClick(entry, $event)"
|
||||
@dblclick="openInStudio(entry)"
|
||||
@dragstart="onDragStart(entry, $event)"
|
||||
@dragend="draggingKeys = []"
|
||||
>
|
||||
<div class="relative aspect-[4/5] bg-zinc-900">
|
||||
<span v-if="thumbHidden(entry)" class="sensitive-glow block h-full w-full" />
|
||||
<img
|
||||
v-else-if="thumbSrc(entry)"
|
||||
:src="thumbSrc(entry)"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
class="h-full w-full object-cover"
|
||||
>
|
||||
<span v-else class="block h-full w-full bg-zinc-800" />
|
||||
<span class="absolute left-2 top-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-zinc-200">{{ entry.kind === 'clip' ? 'Video' : 'Image' }}</span>
|
||||
<span
|
||||
v-if="partCount(entry) > 1"
|
||||
class="absolute right-2 top-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] text-amber-100"
|
||||
>{{ partCount(entry) }}</span>
|
||||
<label class="absolute bottom-2 left-2" @click.stop>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-white/20 bg-zinc-950 accent-amber-400"
|
||||
:checked="selectedKeys.includes(entry.key)"
|
||||
@click.prevent="onSelectClick(entry.key, $event)"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
<div class="px-2.5 py-2">
|
||||
<p class="truncate text-xs font-medium text-zinc-100">{{ entry.name }}</p>
|
||||
<p class="truncate text-[11px] text-zinc-500">{{ cardMeta(entry) }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<aside class="glass w-full shrink-0 overflow-y-auto rounded-3xl p-4 sm:p-5 lg:h-full lg:w-[22rem]">
|
||||
<template v-if="focused">
|
||||
<div class="overflow-hidden rounded-2xl bg-zinc-900">
|
||||
<span v-if="thumbHidden(focused)" class="sensitive-glow block aspect-video w-full" />
|
||||
<video
|
||||
v-else-if="focused.kind === 'clip' && previewVideo"
|
||||
:src="previewVideo"
|
||||
class="aspect-video w-full bg-black object-contain"
|
||||
controls
|
||||
playsinline
|
||||
/>
|
||||
<img
|
||||
v-else-if="focused.kind === 'still' && focused.stillGroup"
|
||||
:src="`/api/library/stills/${focused.stillGroup.head.id}`"
|
||||
alt=""
|
||||
class="aspect-video w-full object-contain"
|
||||
>
|
||||
<img
|
||||
v-else-if="thumbSrc(focused)"
|
||||
:src="thumbSrc(focused)"
|
||||
alt=""
|
||||
class="aspect-video w-full object-cover"
|
||||
>
|
||||
</div>
|
||||
<input
|
||||
v-if="renaming"
|
||||
ref="renameInput"
|
||||
v-model="renameDraft"
|
||||
class="mt-3 w-full rounded-xl border border-amber-300/40 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 focus:ring-2"
|
||||
maxlength="80"
|
||||
@keydown.enter.prevent="saveRename"
|
||||
@keydown.escape.prevent="cancelRename"
|
||||
@blur="saveRename"
|
||||
>
|
||||
<h2 v-else class="mt-3 font-display text-lg font-bold leading-snug">{{ focused.name }}</h2>
|
||||
<p class="mt-1 text-xs text-zinc-500">{{ detailMeta }}</p>
|
||||
<p v-if="detailPrompt" class="mt-3 max-h-32 overflow-y-auto whitespace-pre-wrap text-xs text-zinc-400">{{ detailPrompt }}</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<button type="button" class="rounded-full border border-amber-300/40 px-3 py-1.5 text-xs text-amber-100" @click="openInStudio(focused)">Open in Studio</button>
|
||||
<button type="button" class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white" @click="useInStudio(focused)">Use as input</button>
|
||||
<a
|
||||
v-if="downloadHref"
|
||||
:href="downloadHref"
|
||||
:download="focused.name"
|
||||
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white"
|
||||
>Download</a>
|
||||
<button type="button" class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white" @click="startRename">Rename</button>
|
||||
<select
|
||||
v-if="folders.length > 1"
|
||||
class="rounded-full border border-white/10 bg-zinc-950 px-2 py-1.5 text-xs text-amber-300"
|
||||
:value="''"
|
||||
@change="onMoveFocused($event)"
|
||||
>
|
||||
<option disabled value="">Move to…</option>
|
||||
<option v-for="folder in moveFolders" :key="folder.id" :value="folder.id">{{ folder.name }}</option>
|
||||
</select>
|
||||
<button type="button" class="rounded-full border border-red-400/30 px-3 py-1.5 text-xs text-red-300" @click="askRemoveFocused">Delete</button>
|
||||
</div>
|
||||
<div v-if="focusedParts.length > 1" class="mt-4 grid grid-cols-4 gap-1.5">
|
||||
<button
|
||||
v-for="part in focusedParts"
|
||||
:key="part.id"
|
||||
type="button"
|
||||
class="overflow-hidden rounded-lg border border-white/10"
|
||||
:class="part.id === focusedPartId ? 'ring-2 ring-amber-300' : ''"
|
||||
@click="focusPart(part.id)"
|
||||
>
|
||||
<img :src="partThumb(part)" alt="" class="aspect-square w-full object-cover" loading="lazy">
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="text-sm text-zinc-500">Click an item to preview. Drag onto a folder to move. Shift-click or checkboxes to select several.</p>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<SettingsModal
|
||||
v-if="settingsOpen"
|
||||
:folders="folders"
|
||||
:auth-mode="authMode"
|
||||
:username="userName"
|
||||
@close="settingsOpen = false"
|
||||
@updated="applyLibrary"
|
||||
@error="toast"
|
||||
@renamed="userName = $event"
|
||||
/>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="pendingDelete"
|
||||
class="fixed inset-0 z-[70] flex items-center justify-center bg-black/70 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@click.self="pendingDelete = null"
|
||||
>
|
||||
<div class="glass w-full max-w-md rounded-3xl p-5 sm:p-6">
|
||||
<h2 class="font-display text-xl font-bold">{{ pendingDelete.title }}</h2>
|
||||
<p class="mt-2 text-sm font-medium text-zinc-100">{{ pendingDelete.name }}</p>
|
||||
<p v-if="pendingDelete.detail" class="mt-1 text-xs text-zinc-500">{{ pendingDelete.detail }}</p>
|
||||
<p class="mt-3 text-sm text-zinc-400">{{ pendingDelete.consequence }}</p>
|
||||
<div class="mt-5 flex flex-wrap justify-end gap-2">
|
||||
<button type="button" class="rounded-full border border-white/15 px-4 py-2 text-sm text-zinc-300" @click="pendingDelete = null">Keep it</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-red-400/40 bg-red-400/10 px-4 py-2 text-sm font-semibold text-red-200 disabled:opacity-50"
|
||||
:disabled="deleteBusy"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<div class="pointer-events-none fixed bottom-4 right-4 z-50 space-y-2">
|
||||
<div
|
||||
v-for="item in toasts"
|
||||
:key="item.id"
|
||||
class="pointer-events-auto max-w-[min(20rem,calc(100vw-2rem))] rounded-2xl border border-red-400/30 bg-zinc-950/90 px-4 py-3 text-sm text-red-100 shadow-2xl"
|
||||
>
|
||||
{{ item.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
buildLibraryEntries,
|
||||
folderOutputCount,
|
||||
formatClipDuration,
|
||||
formatWhen,
|
||||
type LibraryClip,
|
||||
type LibraryEntry,
|
||||
type LibraryFilter,
|
||||
type LibraryFolder,
|
||||
type LibrarySort,
|
||||
type LibraryStill
|
||||
} from '~/utils/libraryBrowse'
|
||||
|
||||
const CARD_MIN = 156
|
||||
const GAP = 10
|
||||
const ROW_H = 248
|
||||
const BUFFER_ROWS = 3
|
||||
const DRAG_MIME = 'application/x-aigen-library'
|
||||
const filterOptions = [
|
||||
{ id: 'all' as const, label: 'All' },
|
||||
{ id: 'images' as const, label: 'Images' },
|
||||
{ id: 'videos' as const, label: 'Videos' }
|
||||
]
|
||||
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const instanceName = ref(String(runtimeConfig.public.instanceName || 'AIGen'))
|
||||
const authEnabled = ref(false)
|
||||
const authMode = ref('')
|
||||
const userName = ref('')
|
||||
const settingsOpen = ref(false)
|
||||
const folders = ref<LibraryFolder[]>([])
|
||||
const clips = ref<LibraryClip[]>([])
|
||||
const stills = ref<LibraryStill[]>([])
|
||||
const folderId = ref('all')
|
||||
const filter = ref<LibraryFilter>('all')
|
||||
const sort = ref<LibrarySort>('date-desc')
|
||||
const search = ref('')
|
||||
const selectedKeys = ref<string[]>([])
|
||||
const lastSelectedKey = ref('')
|
||||
const focusedKey = ref('')
|
||||
const focusedPartId = ref('')
|
||||
const unlockPassword = ref('')
|
||||
const dropFolderId = ref('')
|
||||
const draggingKeys = ref<string[]>([])
|
||||
const renaming = ref(false)
|
||||
const renameDraft = ref('')
|
||||
const renameInput = ref<HTMLInputElement | null>(null)
|
||||
const deleteBusy = ref(false)
|
||||
const pendingDelete = ref<{ title: string; name: string; detail?: string; consequence: string; keys: string[] } | null>(null)
|
||||
const toasts = ref<Array<{ id: number; message: string }>>([])
|
||||
let toastId = 0
|
||||
const scroller = ref<HTMLElement | null>(null)
|
||||
const scrollTop = ref(0)
|
||||
const viewportH = ref(800)
|
||||
const viewportW = ref(800)
|
||||
|
||||
const browseFolder = computed(() => folders.value.find(folder => folder.id === folderId.value) || null)
|
||||
const entries = computed(() => buildLibraryEntries({
|
||||
clips: clips.value,
|
||||
stills: stills.value,
|
||||
folderId: folderId.value,
|
||||
filter: filter.value,
|
||||
search: search.value,
|
||||
sort: sort.value
|
||||
}))
|
||||
const allCount = computed(() => buildLibraryEntries({
|
||||
clips: clips.value,
|
||||
stills: stills.value,
|
||||
folderId: 'all',
|
||||
filter: filter.value,
|
||||
search: '',
|
||||
sort: 'date-desc'
|
||||
}).length)
|
||||
const moveFolders = computed(() => folders.value.filter(folder => folder.id !== folderId.value))
|
||||
const focused = computed(() => entries.value.find(entry => entry.key === focusedKey.value) || null)
|
||||
const emptyLabel = computed(() => {
|
||||
if (search.value.trim()) return 'Nothing matches that search.'
|
||||
if (filter.value === 'images') return 'No generated stills here yet.'
|
||||
if (filter.value === 'videos') return 'No generated clips here yet.'
|
||||
return 'Nothing in this folder yet.'
|
||||
})
|
||||
const cols = computed(() => Math.max(2, Math.floor((viewportW.value + GAP) / (CARD_MIN + GAP))))
|
||||
const rowCount = computed(() => Math.ceil(entries.value.length / cols.value) || 0)
|
||||
const gridHeight = computed(() => rowCount.value * ROW_H + Math.max(0, rowCount.value - 1) * GAP)
|
||||
const startRow = computed(() => Math.max(0, Math.floor(scrollTop.value / (ROW_H + GAP)) - BUFFER_ROWS))
|
||||
const visibleRowCount = computed(() => Math.ceil(viewportH.value / (ROW_H + GAP)) + BUFFER_ROWS * 2)
|
||||
const offsetY = computed(() => startRow.value * (ROW_H + GAP))
|
||||
const visibleEntries = computed(() => {
|
||||
const start = startRow.value * cols.value
|
||||
const end = start + visibleRowCount.value * cols.value
|
||||
return entries.value.slice(start, end)
|
||||
})
|
||||
const previewVideo = computed(() => {
|
||||
if (focused.value?.kind !== 'clip' || !focused.value.group) return ''
|
||||
const id = focusedPartId.value || focused.value.group.head.id
|
||||
return `/api/library/clips/${id}/video`
|
||||
})
|
||||
const focusedParts = computed(() => {
|
||||
if (focused.value?.kind === 'clip') return focused.value.group?.parts || []
|
||||
return focused.value?.stillGroup?.parts || []
|
||||
})
|
||||
const detailPrompt = computed(() => {
|
||||
if (focused.value?.kind === 'clip') return focused.value.group?.head.prompt || ''
|
||||
return focused.value?.stillGroup?.head.prompt || ''
|
||||
})
|
||||
const detailMeta = computed(() => {
|
||||
const entry = focused.value
|
||||
if (!entry) return ''
|
||||
if (entry.kind === 'clip' && entry.group) {
|
||||
const item = entry.group.parts.find(part => part.id === focusedPartId.value) || entry.group.head
|
||||
return [item.width && item.height ? `${item.width}×${item.height}` : '', formatClipDuration(item.duration), formatWhen(item.createdAt)].filter(Boolean).join(' · ')
|
||||
}
|
||||
const still = entry.stillGroup?.parts.find(part => part.id === focusedPartId.value) || entry.stillGroup?.head
|
||||
if (!still) return ''
|
||||
return [still.width && still.height ? `${still.width}×${still.height}` : 'image', formatWhen(still.createdAt)].filter(Boolean).join(' · ')
|
||||
})
|
||||
const downloadHref = computed(() => {
|
||||
if (focused.value?.kind === 'clip' && focused.value.group) {
|
||||
const id = focusedPartId.value || focused.value.group.head.id
|
||||
return `/api/library/clips/${id}/video`
|
||||
}
|
||||
if (focused.value?.stillGroup) {
|
||||
const id = focusedPartId.value || focused.value.stillGroup.head.id
|
||||
return `/api/library/stills/${id}`
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
function toast(message: string) {
|
||||
const id = ++toastId
|
||||
toasts.value = [...toasts.value, { id, message }].slice(-4)
|
||||
setTimeout(() => {
|
||||
toasts.value = toasts.value.filter(item => item.id !== id)
|
||||
}, 4200)
|
||||
}
|
||||
|
||||
function applyLibrary(data: unknown) {
|
||||
const payload = (data || {}) as { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[] }
|
||||
folders.value = payload.folders || []
|
||||
clips.value = payload.clips || []
|
||||
stills.value = payload.stills || []
|
||||
if (folderId.value !== 'all' && !folders.value.some(folder => folder.id === folderId.value)) {
|
||||
folderId.value = folders.value[0]?.id || 'all'
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLibrary() {
|
||||
applyLibrary(await $fetch('/api/library?light=1'))
|
||||
}
|
||||
|
||||
function folderChipClass(id: string) {
|
||||
if (folderId.value === id) return 'border-amber-300 bg-amber-400/10 text-amber-100'
|
||||
if (dropFolderId.value === id) return 'border-amber-300/70 bg-amber-400/10 text-amber-100'
|
||||
return 'border-white/10 text-zinc-400'
|
||||
}
|
||||
|
||||
function cardClass(entry: LibraryEntry) {
|
||||
if (focusedKey.value === entry.key) return 'border-amber-300 ring-2 ring-amber-300/70'
|
||||
if (selectedKeys.value.includes(entry.key)) return 'border-amber-300/40 bg-amber-400/5'
|
||||
return 'border-white/10'
|
||||
}
|
||||
|
||||
function thumbHidden(entry: LibraryEntry) {
|
||||
if (entry.kind === 'clip') return Boolean(entry.group?.head.hideThumbnail)
|
||||
return Boolean(entry.stillGroup?.head.hideInput)
|
||||
}
|
||||
|
||||
function thumbSrc(entry: LibraryEntry) {
|
||||
if (entry.kind === 'clip' && entry.group?.head.hasThumbnail) return `/api/library/clips/${entry.group.head.id}/thumb`
|
||||
if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.head.id}?thumb=1`
|
||||
return ''
|
||||
}
|
||||
|
||||
function partCount(entry: LibraryEntry) {
|
||||
return entry.kind === 'clip' ? (entry.group?.parts.length || 1) : (entry.stillGroup?.parts.length || 1)
|
||||
}
|
||||
|
||||
function cardMeta(entry: LibraryEntry) {
|
||||
if (entry.kind === 'clip' && entry.group) {
|
||||
return [formatClipDuration(entry.group.head.duration), formatWhen(entry.createdAt)].filter(Boolean).join(' · ')
|
||||
}
|
||||
return formatWhen(entry.createdAt)
|
||||
}
|
||||
|
||||
function partThumb(part: LibraryClip | LibraryStill) {
|
||||
if ('hideThumbnail' in part) return `/api/library/clips/${part.id}/thumb`
|
||||
return `/api/library/stills/${part.id}?thumb=1`
|
||||
}
|
||||
|
||||
function focusPart(id: string) {
|
||||
focusedPartId.value = id
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
scrollTop.value = scroller.value?.scrollTop || 0
|
||||
}
|
||||
|
||||
function measure() {
|
||||
const el = scroller.value
|
||||
if (!el) return
|
||||
viewportH.value = el.clientHeight || 800
|
||||
viewportW.value = el.clientWidth || 800
|
||||
scrollTop.value = el.scrollTop || 0
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedKeys.value = []
|
||||
lastSelectedKey.value = ''
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
selectedKeys.value = entries.value.map(entry => entry.key)
|
||||
lastSelectedKey.value = selectedKeys.value.at(-1) || ''
|
||||
}
|
||||
|
||||
function onSelectClick(key: string, event: MouseEvent) {
|
||||
const keys = entries.value.map(entry => entry.key)
|
||||
if (event.shiftKey && lastSelectedKey.value) {
|
||||
const start = keys.indexOf(lastSelectedKey.value)
|
||||
const end = keys.indexOf(key)
|
||||
if (start >= 0 && end >= 0) {
|
||||
const [from, to] = start < end ? [start, end] : [end, start]
|
||||
selectedKeys.value = [...new Set([...selectedKeys.value, ...keys.slice(from, to + 1)])]
|
||||
lastSelectedKey.value = key
|
||||
return
|
||||
}
|
||||
}
|
||||
if (selectedKeys.value.includes(key)) selectedKeys.value = selectedKeys.value.filter(item => item !== key)
|
||||
else selectedKeys.value = [...selectedKeys.value, key]
|
||||
lastSelectedKey.value = key
|
||||
}
|
||||
|
||||
function onCardClick(entry: LibraryEntry, event: MouseEvent) {
|
||||
if ((event.target as HTMLElement | null)?.closest('input, select, textarea, a, button, label')) return
|
||||
if (event.shiftKey || event.ctrlKey || event.metaKey) {
|
||||
onSelectClick(entry.key, event)
|
||||
return
|
||||
}
|
||||
focusedKey.value = entry.key
|
||||
focusedPartId.value = entry.kind === 'clip' ? (entry.group?.head.id || '') : (entry.stillGroup?.head.id || '')
|
||||
cancelRename()
|
||||
}
|
||||
|
||||
function onDragStart(entry: LibraryEntry, event: DragEvent) {
|
||||
const keys = selectedKeys.value.includes(entry.key) ? selectedKeys.value : [entry.key]
|
||||
draggingKeys.value = keys
|
||||
event.dataTransfer?.setData(DRAG_MIME, JSON.stringify(keys))
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
function onFolderDragOver(id: string, event: DragEvent) {
|
||||
event.dataTransfer && (event.dataTransfer.dropEffect = 'move')
|
||||
dropFolderId.value = id
|
||||
}
|
||||
|
||||
function onFolderDragLeave(id: string) {
|
||||
if (dropFolderId.value === id) dropFolderId.value = ''
|
||||
}
|
||||
|
||||
async function onFolderDrop(id: string, event: DragEvent) {
|
||||
dropFolderId.value = ''
|
||||
let keys = draggingKeys.value
|
||||
try {
|
||||
const raw = event.dataTransfer?.getData(DRAG_MIME)
|
||||
if (raw) keys = JSON.parse(raw)
|
||||
} catch { /* keep draggingKeys */ }
|
||||
draggingKeys.value = []
|
||||
await moveKeys(keys, id)
|
||||
}
|
||||
|
||||
function selectedEntries(keys = selectedKeys.value) {
|
||||
const chosen = new Set(keys)
|
||||
return entries.value.filter(entry => chosen.has(entry.key))
|
||||
}
|
||||
|
||||
async function moveKeys(keys: string[], destId: string) {
|
||||
const items = selectedEntries(keys).filter((entry) => {
|
||||
const current = entry.kind === 'clip' ? entry.group?.head.folderId : entry.stillGroup?.head.folderId
|
||||
return current && current !== destId
|
||||
})
|
||||
if (!items.length) return
|
||||
const dest = folders.value.find(folder => folder.id === destId)
|
||||
const results = await Promise.allSettled(items.map((entry) => {
|
||||
if (entry.kind === 'clip') {
|
||||
return $fetch(`/api/library/clips/${entry.group!.head.id}`, { method: 'PATCH', body: { folderId: destId, family: true } })
|
||||
}
|
||||
return $fetch(`/api/library/stills/${entry.stillGroup!.head.id}`, { method: 'PATCH', body: { folderId: destId, family: true } })
|
||||
}))
|
||||
await loadLibrary()
|
||||
const failed = results.filter(result => result.status === 'rejected').length
|
||||
clearSelection()
|
||||
if (failed) toast(`${items.length - failed} moved. ${failed} could not be moved.`)
|
||||
else toast(`Moved ${items.length} to ${dest?.name || 'folder'}`)
|
||||
}
|
||||
|
||||
async function onMoveSelected(event: Event) {
|
||||
const select = event.target as HTMLSelectElement
|
||||
const destId = select.value
|
||||
select.value = ''
|
||||
if (destId) await moveKeys(selectedKeys.value, destId)
|
||||
}
|
||||
|
||||
async function onMoveFocused(event: Event) {
|
||||
const select = event.target as HTMLSelectElement
|
||||
const destId = select.value
|
||||
select.value = ''
|
||||
if (destId && focusedKey.value) await moveKeys([focusedKey.value], destId)
|
||||
}
|
||||
|
||||
function openInStudio(entry: LibraryEntry) {
|
||||
if (entry.kind === 'clip' && entry.group) {
|
||||
navigateTo({ path: '/', query: { clip: focusedPartId.value || entry.group.head.id } })
|
||||
return
|
||||
}
|
||||
if (entry.stillGroup) {
|
||||
navigateTo({ path: '/', query: { still: focusedPartId.value || entry.stillGroup.head.id } })
|
||||
}
|
||||
}
|
||||
|
||||
function useInStudio(entry: LibraryEntry) {
|
||||
if (entry.kind === 'clip' && entry.group) {
|
||||
navigateTo({ path: '/', query: { clip: focusedPartId.value || entry.group.head.id, use: '1' } })
|
||||
return
|
||||
}
|
||||
if (entry.stillGroup) {
|
||||
navigateTo({ path: '/', query: { still: focusedPartId.value || entry.stillGroup.head.id, use: '1' } })
|
||||
}
|
||||
}
|
||||
|
||||
function startRename() {
|
||||
if (!focused.value) return
|
||||
renaming.value = true
|
||||
renameDraft.value = focused.value.name
|
||||
nextTick(() => {
|
||||
renameInput.value?.focus()
|
||||
renameInput.value?.select()
|
||||
})
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
renaming.value = false
|
||||
renameDraft.value = ''
|
||||
}
|
||||
|
||||
async function saveRename() {
|
||||
const entry = focused.value
|
||||
if (!renaming.value || !entry) return
|
||||
const name = renameDraft.value.trim().slice(0, 80)
|
||||
if (!name) {
|
||||
toast('A name is required')
|
||||
return
|
||||
}
|
||||
if (name === entry.name) {
|
||||
cancelRename()
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (entry.kind === 'clip' && entry.group) {
|
||||
applyLibrary(await $fetch(`/api/library/clips/${entry.group.head.id}`, { method: 'PATCH', body: { name, family: true } }))
|
||||
} else if (entry.stillGroup) {
|
||||
applyLibrary(await $fetch(`/api/library/stills/${entry.stillGroup.head.id}`, { method: 'PATCH', body: { name, family: true } }))
|
||||
}
|
||||
cancelRename()
|
||||
} catch (error: any) {
|
||||
toast(error?.data?.statusMessage || 'Could not rename')
|
||||
}
|
||||
}
|
||||
|
||||
function askRemoveFocused() {
|
||||
if (!focused.value) return
|
||||
askRemoveKeys([focused.value.key])
|
||||
}
|
||||
|
||||
function askRemoveSelected() {
|
||||
askRemoveKeys(selectedKeys.value)
|
||||
}
|
||||
|
||||
function askRemoveKeys(keys: string[]) {
|
||||
const items = selectedEntries(keys)
|
||||
if (!items.length) return
|
||||
const total = items.reduce((count, entry) => count + partCount(entry), 0)
|
||||
pendingDelete.value = {
|
||||
title: items.length === 1 ? 'Delete this item?' : 'Delete these items?',
|
||||
name: items.slice(0, 3).map(entry => entry.name).join(', ') + (items.length > 3 ? ` and ${items.length - 3} more` : ''),
|
||||
detail: `${items.length} collection${items.length === 1 ? '' : 's'} · ${total} part${total === 1 ? '' : 's'}`,
|
||||
consequence: 'This removes them from the library.',
|
||||
keys
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const pending = pendingDelete.value
|
||||
if (!pending || deleteBusy.value) return
|
||||
pendingDelete.value = null
|
||||
deleteBusy.value = true
|
||||
try {
|
||||
const items = selectedEntries(pending.keys)
|
||||
await Promise.allSettled(items.map((entry) => {
|
||||
if (entry.kind === 'clip') {
|
||||
return $fetch(`/api/library/clips/${entry.group!.head.id}`, { method: 'DELETE', query: { family: '1' } })
|
||||
}
|
||||
return $fetch(`/api/library/stills/${entry.stillGroup!.head.id}`, { method: 'DELETE', query: { family: '1' } })
|
||||
}))
|
||||
if (pending.keys.includes(focusedKey.value)) {
|
||||
focusedKey.value = ''
|
||||
focusedPartId.value = ''
|
||||
}
|
||||
clearSelection()
|
||||
await loadLibrary()
|
||||
} finally {
|
||||
deleteBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function unlockFolder() {
|
||||
if (!browseFolder.value) return
|
||||
try {
|
||||
applyLibrary(await $fetch('/api/library/unlock', { method: 'POST', body: { password: unlockPassword.value, folderId: browseFolder.value.id } }))
|
||||
unlockPassword.value = ''
|
||||
} catch (error: any) {
|
||||
toast(error?.data?.statusMessage || 'Unlock failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function lockFolder() {
|
||||
if (!browseFolder.value) return
|
||||
try {
|
||||
applyLibrary(await $fetch('/api/library/lock', { method: 'POST', body: { folderId: browseFolder.value.id } }))
|
||||
} catch {
|
||||
await loadLibrary()
|
||||
}
|
||||
}
|
||||
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
if (pendingDelete.value) pendingDelete.value = null
|
||||
else if (renaming.value) cancelRename()
|
||||
else clearSelection()
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a' && !renaming.value) {
|
||||
event.preventDefault()
|
||||
selectAll()
|
||||
}
|
||||
if (event.key === 'Delete' && selectedKeys.value.length && !renaming.value) askRemoveSelected()
|
||||
}
|
||||
|
||||
watch([folderId, filter], () => {
|
||||
clearSelection()
|
||||
if (scroller.value) scroller.value.scrollTop = 0
|
||||
})
|
||||
watch(search, () => {
|
||||
if (scroller.value) scroller.value.scrollTop = 0
|
||||
})
|
||||
watch(entries, async () => {
|
||||
if (focusedKey.value && !entries.value.some(entry => entry.key === focusedKey.value)) {
|
||||
focusedKey.value = ''
|
||||
focusedPartId.value = ''
|
||||
}
|
||||
await nextTick()
|
||||
measure()
|
||||
if (scroller.value && resizeObserver && !observedScroller) {
|
||||
resizeObserver.observe(scroller.value)
|
||||
observedScroller = scroller.value
|
||||
}
|
||||
})
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let observedScroller: HTMLElement | null = null
|
||||
onMounted(async () => {
|
||||
window.addEventListener('keydown', onKey)
|
||||
const [me] = await Promise.all([
|
||||
$fetch<{ user?: { name?: string; email?: string }; authEnabled?: boolean; authMode?: string; instanceName?: string }>('/api/auth/me').catch(() => ({ user: null })),
|
||||
loadLibrary().catch(() => applyLibrary({ folders: [], clips: [], stills: [] }))
|
||||
])
|
||||
userName.value = me.user?.name || me.user?.email || ''
|
||||
authEnabled.value = Boolean(me.authEnabled)
|
||||
authMode.value = me.authMode || ''
|
||||
instanceName.value = me.instanceName || instanceName.value
|
||||
await nextTick()
|
||||
measure()
|
||||
resizeObserver = new ResizeObserver(() => measure())
|
||||
if (scroller.value) {
|
||||
resizeObserver.observe(scroller.value)
|
||||
observedScroller = scroller.value
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user