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:
Towsty
2026-08-30 17:24:58 -05:00
co-authored by Cursor
parent 0e1222d035
commit fd9831dfcf
6 changed files with 1082 additions and 2 deletions
+802
View File
@@ -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>