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:
+1
-1
@@ -118,5 +118,5 @@ textarea {
|
|||||||
|
|
||||||
.library-thumb {
|
.library-thumb {
|
||||||
content-visibility: auto;
|
content-visibility: auto;
|
||||||
contain-intrinsic-size: auto 11rem;
|
contain-intrinsic-size: auto 15rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
<NuxtLink to="/" class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white">
|
<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
|
Studio
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/library" class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white">
|
||||||
|
Library
|
||||||
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/director"
|
to="/director"
|
||||||
class="rounded-full border border-amber-300/70 bg-amber-400/10 px-3 py-1 text-amber-100"
|
class="rounded-full border border-amber-300/70 bg-amber-400/10 px-3 py-1 text-amber-100"
|
||||||
|
|||||||
+29
-1
@@ -35,6 +35,12 @@
|
|||||||
<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">
|
<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
|
Settings
|
||||||
</button>
|
</button>
|
||||||
|
<NuxtLink
|
||||||
|
to="/library"
|
||||||
|
class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white"
|
||||||
|
>
|
||||||
|
Library
|
||||||
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/director"
|
to="/director"
|
||||||
class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white"
|
class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white"
|
||||||
@@ -1464,6 +1470,7 @@
|
|||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<h2 class="font-display text-xl font-bold">Library</h2>
|
<h2 class="font-display text-xl font-bold">Library</h2>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
|
<NuxtLink to="/library" class="text-xs text-amber-300 hover:text-amber-100">Open full library</NuxtLink>
|
||||||
<button
|
<button
|
||||||
v-if="libraryEntries.length"
|
v-if="libraryEntries.length"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -3547,7 +3554,8 @@ onMounted(async () => {
|
|||||||
await refreshStudioQueue()
|
await refreshStudioQueue()
|
||||||
await refreshLiveQueue()
|
await refreshLiveQueue()
|
||||||
await loadPromptPresets()
|
await loadPromptPresets()
|
||||||
applyIncomingDirectorScript()
|
if (route.query.clip || route.query.still) applyIncomingLibraryItem()
|
||||||
|
else applyIncomingDirectorScript()
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -3863,6 +3871,26 @@ async function deleteDirectorScript() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyIncomingLibraryItem() {
|
||||||
|
const clipId = String(route.query.clip || '').trim()
|
||||||
|
const stillId = String(route.query.still || '').trim()
|
||||||
|
const use = String(route.query.use || '') === '1'
|
||||||
|
if (clipId) {
|
||||||
|
const clip = clips.value.find(item => item.id === clipId)
|
||||||
|
if (clip) {
|
||||||
|
if (use) void useClipAsInput(clip)
|
||||||
|
else openClip(clip)
|
||||||
|
}
|
||||||
|
} else if (stillId) {
|
||||||
|
const still = stills.value.find(item => item.id === stillId)
|
||||||
|
if (still) {
|
||||||
|
if (use) void useStillAsInput(still)
|
||||||
|
else openStill(still)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void router.replace({ path: '/', query: {} })
|
||||||
|
}
|
||||||
|
|
||||||
function applyIncomingDirectorScript() {
|
function applyIncomingDirectorScript() {
|
||||||
const scriptId = String(route.query.script || '').trim()
|
const scriptId = String(route.query.script || '').trim()
|
||||||
if (scriptId) {
|
if (scriptId) {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -13,6 +13,9 @@
|
|||||||
<NuxtLink to="/" class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white">
|
<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
|
Studio
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/library" class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white">
|
||||||
|
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">
|
<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
|
Director
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
export type LibraryFilter = 'all' | 'images' | 'videos'
|
||||||
|
export type LibrarySort = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'type'
|
||||||
|
|
||||||
|
export type LibraryFolder = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
createdAt: number
|
||||||
|
thumbnailDefault: boolean
|
||||||
|
protected: boolean
|
||||||
|
unlocked: boolean
|
||||||
|
clipCount?: number
|
||||||
|
imageCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LibraryStill = {
|
||||||
|
id: string
|
||||||
|
folderId: string
|
||||||
|
filename: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
createdAt: number
|
||||||
|
hideInput?: boolean
|
||||||
|
role?: 'input' | 'output'
|
||||||
|
name?: string
|
||||||
|
prompt?: string
|
||||||
|
familyId?: string
|
||||||
|
parentStillId?: string
|
||||||
|
chainIndex?: number
|
||||||
|
settings?: { kind?: string; mode?: string; engine?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LibraryClip = {
|
||||||
|
id: string
|
||||||
|
folderId: string
|
||||||
|
name?: string
|
||||||
|
prompt: string
|
||||||
|
aspect: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
steps: number
|
||||||
|
turbo: boolean
|
||||||
|
seed: number
|
||||||
|
hideThumbnail: boolean
|
||||||
|
hasThumbnail?: boolean
|
||||||
|
hasVideo?: boolean
|
||||||
|
createdAt: number
|
||||||
|
duration?: number
|
||||||
|
familyId?: string
|
||||||
|
parentClipId?: string
|
||||||
|
chainIndex?: number
|
||||||
|
workflow?: string
|
||||||
|
stillId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClipGroup = { key: string; head: LibraryClip; parts: LibraryClip[] }
|
||||||
|
export type StillGroup = { key: string; head: LibraryStill; parts: LibraryStill[] }
|
||||||
|
export type LibraryEntry = {
|
||||||
|
kind: 'clip' | 'still'
|
||||||
|
key: string
|
||||||
|
createdAt: number
|
||||||
|
name: string
|
||||||
|
group?: ClipGroup
|
||||||
|
stillGroup?: StillGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
export function familyBaseName(name: string) {
|
||||||
|
return String(name || '').trim().replace(/\s+p\d+$/i, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clipFamilyKey(clip: LibraryClip) {
|
||||||
|
if (clip.familyId) return `family:${clip.folderId}:${clip.familyId}`
|
||||||
|
const base = familyBaseName(clip.name || '').toLowerCase()
|
||||||
|
if (base) return `name:${clip.folderId}:${base}`
|
||||||
|
return `solo:${clip.id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stillFamilyKey(still: LibraryStill) {
|
||||||
|
if (still.familyId) return `still-family:${still.folderId}:${still.familyId}`
|
||||||
|
return `still-solo:${still.id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortStillParts(a: LibraryStill, b: LibraryStill) {
|
||||||
|
const chainDelta = (b.chainIndex || 0) - (a.chainIndex || 0)
|
||||||
|
if (chainDelta) return chainDelta
|
||||||
|
return b.createdAt - a.createdAt
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chronologicalStills(parts: LibraryStill[]) {
|
||||||
|
return [...parts].sort((a, b) => {
|
||||||
|
const chain = (a.chainIndex || 0) - (b.chainIndex || 0)
|
||||||
|
if (chain) return chain
|
||||||
|
return a.createdAt - b.createdAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortClipParts(a: LibraryClip, b: LibraryClip) {
|
||||||
|
const durationDelta = (Number(b.duration) || 0) - (Number(a.duration) || 0)
|
||||||
|
if (durationDelta) return durationDelta
|
||||||
|
const chainDelta = (b.chainIndex || 0) - (a.chainIndex || 0)
|
||||||
|
if (chainDelta) return chainDelta
|
||||||
|
return a.createdAt - b.createdAt
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chronologicalParts(parts: LibraryClip[]) {
|
||||||
|
return [...parts].sort((a, b) => {
|
||||||
|
const chain = (a.chainIndex || 0) - (b.chainIndex || 0)
|
||||||
|
if (chain) return chain
|
||||||
|
return a.createdAt - b.createdAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stillTitle(still: LibraryStill) {
|
||||||
|
return still.name || still.filename.replace(/\.[a-z0-9]+$/i, '') || 'Edited still'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectionTitle(group: ClipGroup) {
|
||||||
|
const named = group.parts.map(part => familyBaseName(part.name || '')).find(Boolean)
|
||||||
|
return named || group.head.name || group.head.prompt || 'Untitled clip'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stillCollectionTitle(group: StillGroup) {
|
||||||
|
const named = group.parts.map(part => familyBaseName(part.name || '')).find(Boolean)
|
||||||
|
return named || stillTitle(group.head)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function entryTitle(entry: LibraryEntry) {
|
||||||
|
if (entry.kind === 'clip' && entry.group) return collectionTitle(entry.group)
|
||||||
|
if (entry.stillGroup) return stillCollectionTitle(entry.stillGroup)
|
||||||
|
return entry.name
|
||||||
|
}
|
||||||
|
|
||||||
|
export function entrySearchText(entry: LibraryEntry) {
|
||||||
|
if (entry.kind === 'clip' && entry.group) {
|
||||||
|
return entry.group.parts.map(part => `${part.name || ''} ${part.prompt || ''}`).join(' ')
|
||||||
|
}
|
||||||
|
if (entry.stillGroup) {
|
||||||
|
return entry.stillGroup.parts.map(part => `${part.name || ''} ${part.prompt || ''} ${part.filename || ''}`).join(' ')
|
||||||
|
}
|
||||||
|
return entry.name
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupClips(clips: LibraryClip[]): ClipGroup[] {
|
||||||
|
const groups = new Map<string, LibraryClip[]>()
|
||||||
|
for (const clip of clips) {
|
||||||
|
const key = clipFamilyKey(clip)
|
||||||
|
const list = groups.get(key) || []
|
||||||
|
list.push(clip)
|
||||||
|
groups.set(key, list)
|
||||||
|
}
|
||||||
|
return [...groups.entries()].map(([key, parts]) => {
|
||||||
|
const sorted = [...parts].sort(sortClipParts)
|
||||||
|
return { key, head: sorted[0], parts: sorted }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupStills(stills: LibraryStill[]): StillGroup[] {
|
||||||
|
const groups = new Map<string, LibraryStill[]>()
|
||||||
|
for (const still of stills) {
|
||||||
|
const key = stillFamilyKey(still)
|
||||||
|
const list = groups.get(key) || []
|
||||||
|
list.push(still)
|
||||||
|
groups.set(key, list)
|
||||||
|
}
|
||||||
|
return [...groups.entries()].map(([key, parts]) => {
|
||||||
|
const sorted = [...parts].sort(sortStillParts)
|
||||||
|
return { key, head: sorted[0], parts: sorted }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareName(a: string, b: string) {
|
||||||
|
return a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLibraryEntries(opts: {
|
||||||
|
clips: LibraryClip[]
|
||||||
|
stills: LibraryStill[]
|
||||||
|
folderId: string
|
||||||
|
filter: LibraryFilter
|
||||||
|
search: string
|
||||||
|
sort: LibrarySort
|
||||||
|
}): LibraryEntry[] {
|
||||||
|
const inFolder = (folderId: string) => !opts.folderId || opts.folderId === 'all' || folderId === opts.folderId
|
||||||
|
const clips = opts.clips.filter(clip => inFolder(clip.folderId) && clip.hasVideo !== false)
|
||||||
|
const stills = opts.stills.filter(still => inFolder(still.folderId) && still.role === 'output')
|
||||||
|
const videos: LibraryEntry[] = opts.filter === 'images'
|
||||||
|
? []
|
||||||
|
: groupClips(clips).map(group => ({
|
||||||
|
kind: 'clip' as const,
|
||||||
|
key: group.key,
|
||||||
|
createdAt: group.head.createdAt,
|
||||||
|
name: collectionTitle(group),
|
||||||
|
group
|
||||||
|
}))
|
||||||
|
const images: LibraryEntry[] = opts.filter === 'videos'
|
||||||
|
? []
|
||||||
|
: groupStills(stills).map(group => ({
|
||||||
|
kind: 'still' as const,
|
||||||
|
key: group.key,
|
||||||
|
createdAt: group.head.createdAt,
|
||||||
|
name: stillCollectionTitle(group),
|
||||||
|
stillGroup: group
|
||||||
|
}))
|
||||||
|
let entries = [...videos, ...images]
|
||||||
|
const needle = opts.search.trim().toLowerCase()
|
||||||
|
if (needle) {
|
||||||
|
entries = entries.filter(entry => entrySearchText(entry).toLowerCase().includes(needle))
|
||||||
|
}
|
||||||
|
const sort = opts.sort
|
||||||
|
entries.sort((a, b) => {
|
||||||
|
if (sort === 'name-asc') return compareName(a.name, b.name) || b.createdAt - a.createdAt
|
||||||
|
if (sort === 'name-desc') return compareName(b.name, a.name) || b.createdAt - a.createdAt
|
||||||
|
if (sort === 'date-asc') return a.createdAt - b.createdAt
|
||||||
|
if (sort === 'type') {
|
||||||
|
if (a.kind !== b.kind) return a.kind === 'still' ? -1 : 1
|
||||||
|
return b.createdAt - a.createdAt
|
||||||
|
}
|
||||||
|
return b.createdAt - a.createdAt
|
||||||
|
})
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
export function folderOutputCount(folder: LibraryFolder, clips: LibraryClip[], stills: LibraryStill[], filter: LibraryFilter) {
|
||||||
|
if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0)
|
||||||
|
const videos = clips.filter(clip => clip.folderId === folder.id).length
|
||||||
|
const images = stills.filter(still => still.folderId === folder.id && still.role === 'output').length
|
||||||
|
if (filter === 'videos') return videos
|
||||||
|
if (filter === 'images') return images
|
||||||
|
return videos + images
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatWhen(createdAt: number) {
|
||||||
|
if (!createdAt) return ''
|
||||||
|
try {
|
||||||
|
return new Date(createdAt).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatClipDuration(value?: number) {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return ''
|
||||||
|
const rounded = Math.round(value * 10) / 10
|
||||||
|
return Number.isInteger(rounded) ? `${rounded}s` : `${rounded.toFixed(1)}s`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user