Files
aigen/pages/library.vue
T

1331 lines
56 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="flex h-dvh min-w-0 flex-col overflow-hidden">
<AppHeader
subtitle="Library"
current="library"
wide
/>
<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="searchDraft"
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">{{ folderChipCount(folder) }}</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="rounded-full border px-2.5 py-1 text-[11px]"
:class="selectMode ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
@click="toggleSelectMode"
>
{{ selectMode ? 'Done selecting' : 'Select' }}
</button>
<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="showPrivacyToggles && entries.length"
type="button"
class="rounded-full border px-2.5 py-1 text-[11px]"
:class="revealAll ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
:title="revealAll ? 'Hide all thumbnails' : 'Reveal all thumbnails'"
@click="toggleRevealAll"
>
<span class="inline-flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-3.5 w-3.5">
<path v-if="revealAll" stroke-linecap="round" d="M3 3l18 18M10.6 10.6a2 2 0 102.8 2.8M9.9 5.1A9.8 9.8 0 0121 12a9.8 9.8 0 01-1.7 2.9M6.1 6.1A9.8 9.8 0 003 12a9.8 9.8 0 0012.9 6.1" />
<template v-else>
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z" />
<circle cx="12" cy="12" r="3" />
</template>
</svg>
{{ revealAll ? 'Hide all' : 'Reveal all' }}
</span>
</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 v-if="canGroupSelection" type="button" class="rounded-lg border border-amber-300/40 px-2 py-1 text-amber-100 disabled:opacity-50" :disabled="groupBusy" @click="groupSelectedImages()">Group images</button>
<button v-if="canUngroupSelection" type="button" class="text-amber-100 disabled:opacity-50" :disabled="groupBusy" @click="groupSelectedImages(true)">Ungroup</button>
<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 overscroll-y-contain [overflow-anchor:none]"
@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" />
<div
v-else-if="entry.kind === 'track'"
class="flex h-full w-full items-end gap-0.5 bg-zinc-900 px-4 py-8"
aria-hidden="true"
>
<span
v-for="n in 16"
:key="n"
class="flex-1 rounded-full bg-amber-400/70"
:style="{ height: `${20 + ((n * 17) % 60)}%` }"
/>
</div>
<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" />
<FamilyNav v-if="entry.kind === 'still'" :count="partCount(entry)" :index="cardImageIndex(entry)" @step="stepCardImage(entry, $event)" />
<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' : entry.kind === 'track' ? 'Music' : '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>
<button
type="button"
role="checkbox"
class="absolute bottom-2 left-2 flex h-4 w-4 items-center justify-center rounded-[4px] border shadow-[0_1px_4px_rgba(0,0,0,0.55)]"
:class="selectedSet.has(entry.key)
? 'border-white bg-amber-400 text-zinc-950'
: 'border-white/30 bg-black/45'"
:aria-checked="selectedSet.has(entry.key)"
:aria-label="selectedSet.has(entry.key) ? 'Deselect' : 'Select'"
@click.stop="onSelectClick(entry.key, $event)"
>
<svg
v-if="selectedSet.has(entry.key)"
class="h-3 w-3"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<path d="M3.2 8.2 6.4 11.3 12.8 4.6" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</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 overflow-y-auto overscroll-y-contain [overflow-anchor:none] p-4 sm:p-5"
:class="focused
? 'fixed inset-0 z-[60] rounded-none lg:static lg:z-auto lg:h-full lg:w-[min(38rem,42vw)] lg:shrink-0 lg:rounded-3xl'
: 'hidden w-full shrink-0 rounded-3xl lg:block lg:h-full lg:w-[22rem]'"
>
<template v-if="focused">
<div class="mb-3 flex items-center justify-between">
<p class="text-sm text-zinc-400">{{ selectMode ? 'Selecting' : 'Details' }}</p>
<button
type="button"
class="rounded-full border border-white/20 bg-zinc-950 px-3 py-1.5 text-sm text-amber-100"
@click="clearFocus"
>
Close
</button>
</div>
<div class="relative overflow-hidden rounded-2xl bg-zinc-900">
<span v-if="detailsHidden" class="sensitive-glow block aspect-[4/5] w-full" />
<video
v-else-if="focused.kind === 'clip' && previewVideo"
:src="previewVideo"
class="max-h-[min(52vh,32rem)] w-full bg-black object-contain"
controls
playsinline
/>
<div v-else-if="focused.kind === 'track' && previewAudio" class="p-4">
<AudioPlayer :src="previewAudio" :download-name="`${focused.name}.${focused.track?.ext || 'flac'}`" />
</div>
<button
v-else-if="focused.kind === 'still' && previewStillSrc"
type="button"
class="block w-full"
title="View larger"
@click="lightboxOpen = true"
>
<img
:src="previewStillSrc"
alt=""
decoding="async"
class="max-h-[min(52vh,32rem)] w-full object-contain"
>
</button>
<button
v-else-if="thumbSrc(focused)"
type="button"
class="block w-full"
title="View larger"
@click="lightboxOpen = true"
>
<img
:src="thumbSrc(focused)"
alt=""
class="max-h-[min(52vh,32rem)] w-full object-contain"
>
</button>
<FamilyNav
v-if="focused.kind !== 'track'"
:count="focusedNavParts.length"
:index="focusedNavIndex"
:prev-label="focused?.kind === 'clip' ? 'Previous clip' : 'Previous image'"
:next-label="focused?.kind === 'clip' ? 'Next clip' : 'Next image'"
@step="stepFocusedFamily"
/>
<button
v-if="showPrivacyToggles && focused.kind !== 'track'"
type="button"
class="absolute right-2 top-2 z-30 flex h-8 w-8 items-center justify-center rounded-full border border-white/20 bg-black/60 text-zinc-100 hover:border-amber-300/50 hover:text-amber-100"
:title="detailsHidden ? 'Show preview' : 'Hide preview'"
:aria-label="detailsHidden ? 'Show preview' : 'Hide preview'"
@click.stop="toggleFocusedHidden"
>
<svg v-if="detailsHidden" class="h-4 w-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M3 3l18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<path d="M10.6 10.7a2.5 2.5 0 0 0 3.7 3.6M9.9 5.2A11 11 0 0 1 12 5c6.5 0 10 7 10 7a18 18 0 0 1-4.1 4.8M6.7 6.8A18 18 0 0 0 2 12s3.5 7 10 7c1.3 0 2.5-.3 3.6-.7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<svg v-else class="h-4 w-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2" />
</svg>
</button>
</div>
<div v-if="focused.kind === 'clip' && focusedDisplayNodes.length > 1 && !detailsHidden" class="mt-3 space-y-1.5">
<template v-for="node in focusedDisplayNodes" :key="node.key">
<p
v-if="node.type === 'fork'"
class="rounded-md border-l-2 border-amber-300/50 bg-amber-400/5 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-amber-100"
:style="node.depth ? { marginLeft: `${Math.min(node.depth, 6) * 0.65}rem` } : undefined"
>
{{ node.label }}
</p>
<button
v-else
type="button"
class="flex w-full items-center gap-2 overflow-hidden rounded-lg border border-white/10 p-1 text-left"
:class="node.clip.id === focusedPartId ? 'ring-2 ring-amber-300' : ''"
:style="node.depth ? { marginLeft: `${Math.min(node.depth, 6) * 0.65}rem` } : undefined"
@click="focusPart(node.clip.id)"
>
<span class="relative h-12 w-12 shrink-0 overflow-hidden rounded bg-zinc-900">
<span v-if="showPrivacyToggles && !revealAll" class="sensitive-glow block h-full w-full" />
<img
v-else
:src="partThumb(node.clip)"
alt=""
class="h-full w-full object-cover"
loading="lazy"
>
</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-xs font-medium text-amber-100">{{ clipFamilyPartTitle(node.clip, focused.group!.parts) }}</span>
<span v-if="node.fromLabel || node.branchLabel" class="block truncate text-[11px] text-amber-200/80">
{{ [node.fromLabel, node.branchLabel].filter(Boolean).join(' · ') }}
</span>
<span class="block truncate text-[11px] text-zinc-500">{{ node.clip.name }}</span>
</span>
</button>
</template>
</div>
<details v-else-if="focusedParts.length > 1 && !detailsHidden" class="mt-3">
<summary class="cursor-pointer text-sm text-amber-100">Expand images ({{ focusedParts.length }})</summary>
<div class="mt-2 grid grid-cols-5 gap-1.5">
<button
v-for="part in focusedParts"
:key="part.id"
type="button"
class="relative overflow-hidden rounded-lg border border-white/10"
:class="part.id === focusedPartId ? 'ring-2 ring-amber-300' : ''"
@click="focusPart(part.id)"
>
<span v-if="showPrivacyToggles && !revealAll" class="sensitive-glow block aspect-square w-full" />
<img
v-else
:src="partThumb(part)"
alt=""
class="aspect-square w-full object-cover"
loading="lazy"
>
</button>
</div>
</details>
<p v-else-if="focused.kind === 'clip' && focusedDisplayNodes.length > 1 && detailsHidden" class="mt-3 text-xs text-zinc-500">
{{ focusedDisplayNodes.filter(node => node.type === 'clip').length }} parts hidden with preview
</p>
<div class="mt-3 flex flex-wrap gap-2">
<VideoUpscaleControl v-if="focused.kind === 'clip' && focused.group" :clip-id="focusedPartId || focused.group.head.id" @complete="loadLibrary()" />
<button type="button" class="rounded-full border border-amber-300/40 px-3 py-1.5 text-xs text-amber-100" @click="openInStudio(focused)">{{ focused.kind === 'track' ? 'Open in Music' : 'Open in Studio' }}</button>
<button v-if="focused.kind !== 'track'" 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>
<button
v-if="focused.kind === 'still' && focusedStillRole !== 'input'"
type="button"
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white"
:disabled="keepingInputBusy"
@click="saveFocusedAsInput"
>{{ keepingInputBusy ? 'Saving…' : 'Save as input' }}</button>
<button
v-if="focused.kind === 'still' && backupStudioEnabled"
type="button"
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white"
:disabled="backupBusy"
@click="sendFocusedToBackup"
>{{ backupBusy ? 'Sending…' : 'Send to Backup' }}</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
v-if="!detailsHidden && focused.kind !== 'track' && (previewVideo || previewStillSrc || thumbSrc(focused))"
type="button"
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white"
@click="lightboxOpen = true"
>View larger</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="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>
<input
v-if="renaming"
ref="renameInput"
v-model="renameDraft"
class="mt-4 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-4 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="focusedBranchHint" class="mt-1 text-xs text-amber-200/80">{{ focusedBranchHint }}</p>
<LibrarySettingsDetails
:rows="focusedSettingsRows"
empty="No generator settings were saved with this item."
/>
</template>
<p v-else class="text-sm text-zinc-500">Click an item for details. Turn on Select to tap thumbnails into a group. Checkboxes and Shift-click still work.</p>
</aside>
</main>
<Teleport to="body">
<div
v-if="lightboxOpen && focused && !detailsHidden && focused.kind !== 'track'"
class="fixed inset-0 z-[80] flex items-center justify-center bg-black/85 p-3 sm:p-6"
role="dialog"
aria-modal="true"
aria-label="Larger preview"
@click.self="lightboxOpen = false"
>
<button
type="button"
class="absolute right-4 top-4 rounded-full border border-white/20 bg-zinc-950 px-3 py-1.5 text-sm text-amber-100"
@click="lightboxOpen = false"
>
Close
</button>
<video
v-if="focused.kind === 'clip' && previewVideo"
:src="previewVideo"
class="max-h-[92dvh] max-w-[96vw] bg-black object-contain"
controls
autoplay
playsinline
/>
<img
v-else-if="previewStillSrc || thumbSrc(focused)"
:src="previewStillSrc || thumbSrc(focused)"
alt=""
class="max-h-[92dvh] max-w-[96vw] object-contain"
>
</div>
<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,
chronologicalStills,
clipBranchFromLabel,
clipBranchLetterLabel,
clipFamilyPartTitle,
familyBranchSummary,
familyDisplayNodes,
formatClipDuration,
formatWhen,
treeOrderedParts,
wrappedIndex,
type FamilyDisplayNode,
type LibraryClip,
type LibraryEntry,
type LibraryFilter,
type LibraryFolder,
type LibrarySort,
type LibraryStill,
type LibraryTrack
} from '~/utils/libraryBrowse'
import { isXaigenStudio } from '~/utils/videoModels'
import { clipSettingsRows, stillSettingsRows, trackSettingsRows } from '~/utils/libraryGeneration'
const CARD_MIN = 156
const GAP = 10
const CARD_CAPTION = 50
const BUFFER_ROWS = 4
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' },
{ id: 'music' as const, label: 'Music' }
]
const runtimeConfig = useRuntimeConfig()
const instanceName = ref(String(runtimeConfig.public.instanceName || 'AIGen'))
const backupStudioEnabled = computed(() => Boolean(runtimeConfig.public.backupStudioEnabled))
const backupBusy = ref(false)
const showPrivacyToggles = computed(() => isXaigenStudio({
studio: runtimeConfig.public.studio,
instanceName: runtimeConfig.public.instanceName || instanceName.value
}))
const folders = ref<LibraryFolder[]>([])
const clips = ref<LibraryClip[]>([])
const stills = ref<LibraryStill[]>([])
const tracks = ref<LibraryTrack[]>([])
const folderId = ref('all')
const filter = ref<LibraryFilter>('all')
const sort = ref<LibrarySort>('date-desc')
const searchDraft = ref('')
const search = ref('')
const selectedKeys = ref<string[]>([])
const groupBusy = ref(false)
const cardImages = ref<Record<string, number>>({})
const canGroupSelection = computed(() => selectedEntries().length >= 2 && selectedEntries().every(entry => entry.kind === 'still'))
const canUngroupSelection = computed(() => selectedEntries().length > 0 && selectedEntries().every(entry => entry.kind === 'still' && entry.stillGroup?.head.manualGroupId))
function cardImageIndex(entry: LibraryEntry) {
return (cardImages.value[entry.key] || 0) % Math.max(1, partCount(entry))
}
function stepCardImage(entry: LibraryEntry, delta: number) {
cardImages.value[entry.key] = wrappedIndex(partCount(entry), cardImageIndex(entry), delta)
if (focusedKey.value === entry.key) focusedPartId.value = entry.stillGroup!.parts[cardImageIndex(entry)].id
}
async function groupSelectedImages(ungroup = false) {
if (groupBusy.value) return
groupBusy.value = true
try {
const ids = selectedEntries().flatMap(entry => entry.stillGroup?.parts.map(part => part.id) || [])
applyLibrary(await $fetch('/api/library/still-groups', { method: 'POST', body: { ids, ungroup } }))
clearSelection()
clearFocus()
toast(ungroup ? 'Images ungrouped. Original iterations restored.' : 'Images grouped. Use the arrows to browse, or open the group to expand it.')
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not update image group')
} finally {
groupBusy.value = false
}
}
const lastSelectedKey = ref('')
const selectMode = ref(false)
const lightboxOpen = ref(false)
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 detailsHidden = ref(false)
const revealAll = ref(false)
const hidingFocused = ref(false)
const forceHiddenIds = ref<string[]>([])
const pendingDelete = ref<{ title: string; name: string; detail?: string; consequence: string; keys: string[] } | null>(null)
const keepingInputBusy = ref(false)
const toasts = ref<Array<{ id: number; message: string }>>([])
let toastId = 0
let searchTimer: ReturnType<typeof setTimeout> | null = null
const scroller = ref<HTMLElement | null>(null)
const scrollTop = ref(0)
let scrollRaf = 0
const viewportH = ref(800)
const viewportW = ref(800)
const selectedSet = computed(() => new Set(selectedKeys.value))
const browseFolder = computed(() => folders.value.find(folder => folder.id === folderId.value) || null)
const entries = computed(() => buildLibraryEntries({
clips: clips.value,
stills: stills.value,
tracks: tracks.value,
folderId: folderId.value,
filter: filter.value,
search: search.value,
sort: sort.value
}))
const allCount = computed(() => {
if (folderId.value === 'all' && !search.value.trim()) return entries.value.length
return buildLibraryEntries({
clips: clips.value,
stills: stills.value,
tracks: tracks.value,
folderId: 'all',
filter: filter.value,
search: '',
sort: 'date-desc'
}).length
})
const folderChipCounts = computed(() => {
const map: Record<string, { videos: number; images: number; music: number }> = {}
for (const clip of clips.value) {
if (clip.hasVideo === false) continue
const row = map[clip.folderId] || (map[clip.folderId] = { videos: 0, images: 0, music: 0 })
row.videos += 1
}
for (const still of stills.value) {
if (still.role !== 'output') continue
const row = map[still.folderId] || (map[still.folderId] = { videos: 0, images: 0, music: 0 })
row.images += 1
}
for (const track of tracks.value) {
const row = map[track.folderId] || (map[track.folderId] = { videos: 0, images: 0, music: 0 })
row.music += 1
}
return map
})
const moveFolders = computed(() => folders.value.filter(folder => folder.id !== folderId.value))
const focused = computed(() => entries.value.find(entry => entry.key === focusedKey.value) || null)
const focusedStill = computed(() => {
if (focused.value?.kind !== 'still' || !focused.value.stillGroup) return null
return focused.value.stillGroup.parts.find(part => part.id === focusedPartId.value) || focused.value.stillGroup.head
})
const focusedStillRole = computed(() => focusedStill.value?.role === 'input' ? 'input' : (focusedStill.value ? (focusedStill.value.role || 'output') : ''))
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.'
if (filter.value === 'music') return 'No generated tracks here yet.'
return 'Nothing in this folder yet.'
})
const cols = computed(() => Math.max(2, Math.floor((viewportW.value + GAP) / (CARD_MIN + GAP))))
const rowH = computed(() => {
const width = Math.max(CARD_MIN, (viewportW.value - GAP * (cols.value - 1)) / cols.value)
return Math.round(width * 5 / 4) + CARD_CAPTION
})
const rowStride = computed(() => rowH.value + GAP)
const rowCount = computed(() => Math.ceil(entries.value.length / cols.value) || 0)
const gridHeight = computed(() => rowCount.value * rowH.value + Math.max(0, rowCount.value - 1) * GAP)
const startRow = computed(() => Math.max(0, Math.floor(scrollTop.value / rowStride.value) - BUFFER_ROWS))
const visibleRowCount = computed(() => Math.ceil(viewportH.value / rowStride.value) + BUFFER_ROWS * 2)
const offsetY = computed(() => startRow.value * rowStride.value)
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 previewStillSrc = computed(() => {
if (focused.value?.kind !== 'still' || !focused.value.stillGroup) return ''
const id = focusedPartId.value || focused.value.stillGroup.head.id
return `/api/library/stills/${id}`
})
const previewAudio = computed(() => {
if (focused.value?.kind !== 'track' || !focused.value.track) return ''
return `/api/library/tracks/${focused.value.track.id}/audio`
})
const focusedParts = computed(() => {
if (focused.value?.kind === 'clip') return focused.value.group?.parts || []
return focused.value?.stillGroup?.parts || []
})
const focusedDisplayNodes = computed((): FamilyDisplayNode[] => {
if (focused.value?.kind !== 'clip' || !focused.value.group) return []
return familyDisplayNodes(focused.value.group.parts)
})
const focusedNavParts = computed(() => {
const parts = focusedParts.value
if (parts.length < 2) return []
if (focused.value?.kind === 'still') return chronologicalStills(parts as LibraryStill[])
return treeOrderedParts(parts as LibraryClip[]).map(node => node.clip)
})
const focusedBranchHint = computed(() => {
if (focused.value?.kind !== 'clip' || !focused.value.group) return ''
const parts = focused.value.group.parts
const current = parts.find(part => part.id === focusedPartId.value)
if (!current) return familyBranchSummary(parts)
const bits = [clipBranchFromLabel(current, parts), clipBranchLetterLabel(current)].filter(Boolean)
return bits.join(' · ') || familyBranchSummary(parts)
})
const focusedNavIndex = computed(() => {
const id = focusedPartId.value
const index = focusedNavParts.value.findIndex(part => part.id === id)
return index < 0 ? 0 : index
})
const focusedSettingsRows = computed(() => {
if (focused.value?.kind === 'clip' && focused.value.group) {
const item = focused.value.group.parts.find(part => part.id === focusedPartId.value) || focused.value.group.head
return clipSettingsRows(item)
}
if (focused.value?.kind === 'track' && focused.value.track) {
return trackSettingsRows(focused.value.track)
}
const still = focused.value?.stillGroup?.parts.find(part => part.id === focusedPartId.value) || focused.value?.stillGroup?.head
return still ? stillSettingsRows(still) : []
})
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(' · ')
}
if (entry.kind === 'track' && entry.track) {
return [formatClipDuration(entry.track.duration), entry.track.ext?.toUpperCase(), formatWhen(entry.track.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?.kind === 'track' && focused.value.track) {
return `/api/library/tracks/${focused.value.track.id}/audio`
}
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 {
id?: string
hideInput?: boolean
hideThumbnail?: boolean
folders?: LibraryFolder[]
clips?: LibraryClip[]
stills?: LibraryStill[]
tracks?: LibraryTrack[]
}
if (payload.id && !payload.folders && !payload.clips && !payload.stills && !payload.tracks) {
if (typeof payload.hideInput === 'boolean') {
const still = stills.value.find(item => item.id === payload.id)
if (still) still.hideInput = payload.hideInput
}
if (typeof payload.hideThumbnail === 'boolean') {
const clip = clips.value.find(item => item.id === payload.id)
if (clip) clip.hideThumbnail = payload.hideThumbnail
}
return
}
folders.value = payload.folders || []
clips.value = payload.clips || []
stills.value = payload.stills || []
tracks.value = payload.tracks || []
if (folderId.value !== 'all' && !folders.value.some(folder => folder.id === folderId.value)) {
folderId.value = folders.value[0]?.id || 'all'
}
}
async function loadLibrary() {
// First page of metadata only — never follow with a full light=1 catalog scan
const first = await $fetch<any>('/api/library?limit=50')
applyLibrary(first)
}
function folderChipCount(folder: LibraryFolder) {
if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0) + (folder.trackCount || 0)
const row = folderChipCounts.value[folder.id]
if (!row) return 0
if (filter.value === 'videos') return row.videos
if (filter.value === 'images') return row.images
if (filter.value === 'music') return row.music
return row.videos + row.images + row.music
}
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 (selectMode.value && selectedSet.value.has(entry.key)) return 'border-amber-300 ring-2 ring-amber-300/70'
if (focusedKey.value === entry.key) return 'border-amber-300 ring-2 ring-amber-300/70'
if (selectedSet.value.has(entry.key)) return 'border-amber-300/40 bg-amber-400/5'
return selectMode.value ? 'border-amber-300/25' : 'border-white/10'
}
function thumbHidden(entry: LibraryEntry) {
if (!showPrivacyToggles.value || entry.kind === 'track') return false
const id = entry.kind === 'clip' ? entry.group?.head.id : entry.stillGroup?.parts[cardImageIndex(entry)]?.id
if (id && forceHiddenIds.value.includes(id)) return true
// Session cover for the whole grid — not limited to individually flagged items.
return !revealAll.value
}
function toggleRevealAll() {
revealAll.value = !revealAll.value
forceHiddenIds.value = []
if (focused.value && focused.value.kind !== 'track') {
detailsHidden.value = !revealAll.value
}
}
async function toggleFocusedHidden() {
const entry = focused.value
if (!entry || hidingFocused.value) return
const hide = !detailsHidden.value
detailsHidden.value = hide
const id = entry.kind === 'clip'
? (focusedPartId.value || entry.group?.head.id || '')
: (focusedStill.value?.id || '')
if (hide && revealAll.value && id && !forceHiddenIds.value.includes(id)) {
forceHiddenIds.value = [...forceHiddenIds.value, id]
} else if (!hide && id) {
forceHiddenIds.value = forceHiddenIds.value.filter(item => item !== id)
}
hidingFocused.value = true
try {
if (entry.kind === 'clip' && entry.group) {
const id = focusedPartId.value || entry.group.head.id
const clip = clips.value.find(item => item.id === id)
if (clip) clip.hideThumbnail = hide
applyLibrary(await $fetch(`/api/library/clips/${id}`, { method: 'PATCH', body: { hideThumbnail: hide } }))
} else if (focusedStill.value) {
const id = focusedStill.value.id
const still = stills.value.find(item => item.id === id)
if (still) still.hideInput = hide
applyLibrary(await $fetch(`/api/library/stills/${id}`, { method: 'PATCH', body: { hideInput: hide } }))
}
} catch (error: any) {
detailsHidden.value = !hide
toast(error?.data?.statusMessage || 'Could not update thumbnail')
} finally {
hidingFocused.value = false
}
}
function thumbSrc(entry: LibraryEntry) {
if (entry.kind === 'clip' && entry.group) return `/api/library/clips/${entry.group.head.id}/thumb`
if (entry.kind === 'track') return ''
if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.parts[cardImageIndex(entry)].id}/thumb`
return ''
}
function partCount(entry: LibraryEntry) {
if (entry.kind === 'track') return 1
return entry.kind === 'clip' ? (entry.group?.parts.length || 1) : (entry.stillGroup?.parts.length || 1)
}
function cardMeta(entry: LibraryEntry) {
if (entry.kind === 'clip' && entry.group) {
const summary = familyBranchSummary(entry.group.parts)
return [formatClipDuration(entry.group.head.duration), formatWhen(entry.createdAt), summary].filter(Boolean).join(' · ')
}
if (entry.kind === 'track' && entry.track) {
return [formatClipDuration(entry.track.duration), formatWhen(entry.createdAt)].filter(Boolean).join(' · ')
}
return formatWhen(entry.createdAt)
}
function partThumb(part: LibraryClip | LibraryStill) {
if ('hideThumbnail' in part && !('role' in part)) {
const clip = part as LibraryClip
// Prefer this clip’s first frame so siblings don’t all look like the Start thumb.
return `/api/library/clips/${clip.id}/first-frame?thumb=1&v=${encodeURIComponent(clip.id)}`
}
return `/api/library/stills/${part.id}/thumb`
}
function focusPart(id: string) {
focusedPartId.value = id
}
function stepFocusedFamily(delta: number) {
const parts = focusedNavParts.value
if (parts.length < 2) return
const next = parts[(focusedNavIndex.value + delta + parts.length) % parts.length]
if (next) focusPart(next.id)
}
function onScroll() {
if (scrollRaf) return
scrollRaf = requestAnimationFrame(() => {
scrollRaf = 0
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 clearFocus() {
focusedKey.value = ''
focusedPartId.value = ''
detailsHidden.value = false
lightboxOpen.value = false
cancelRename()
}
function toggleSelectMode() {
selectMode.value = !selectMode.value
if (selectMode.value) clearFocus()
}
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 (selectMode.value || event.shiftKey || event.ctrlKey || event.metaKey) {
onSelectClick(entry.key, event)
return
}
const sameCard = focusedKey.value === entry.key
focusedKey.value = entry.key
focusedPartId.value = entry.kind === 'clip'
? (entry.group?.head.id || '')
: entry.kind === 'track'
? (entry.track?.id || '')
: (entry.stillGroup?.parts[cardImageIndex(entry)]?.id || '')
if (!sameCard) detailsHidden.value = thumbHidden(entry)
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.kind === 'track'
? entry.track?.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 } })
}
if (entry.kind === 'track') {
return $fetch(`/api/library/tracks/${entry.track!.id}`, { method: 'PATCH', body: { folderId: destId } })
}
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) {
const part = focusedParts.value.find(part => part.id === focusedPartId.value) || focusedParts.value[0]
if ((part as any)?.studio2) {
navigateTo({ path: '/studio-2', query: { [entry.kind === 'clip' ? 'clip' : 'still']: part!.id } })
return
}
if (entry.kind === 'clip' && entry.group) {
navigateTo({ path: '/', query: { clip: focusedPartId.value || entry.group.head.id } })
return
}
if (entry.kind === 'track' && entry.track) {
navigateTo({ path: '/music', query: { track: entry.track.id } })
return
}
if (entry.stillGroup) {
navigateTo({ path: '/', query: { still: focusedPartId.value || entry.stillGroup.head.id } })
}
}
function useInStudio(entry: LibraryEntry) {
const part = focusedParts.value.find(part => part.id === focusedPartId.value) || focusedParts.value[0]
if ((part as any)?.studio2) {
navigateTo({ path: '/studio-2', query: { [entry.kind === 'clip' ? 'clip' : 'still']: part!.id, use: '1' } })
return
}
if (entry.kind === 'clip' && entry.group) {
navigateTo({ path: '/', query: { clip: focusedPartId.value || entry.group.head.id, use: '1' } })
return
}
if (entry.stillGroup) {
const stillId = focusedPartId.value || entry.stillGroup.head.id
// Do not force Video/Image — studio stays on whatever tab the user left it on.
navigateTo({
path: '/',
query: { still: stillId, use: '1' }
})
}
}
async function sendFocusedToBackup() {
const still = focusedStill.value
if (!still || !backupStudioEnabled.value || backupBusy.value) return
backupBusy.value = true
try {
await $fetch(`/api/library/stills/${still.id}/backup`, { method: 'POST' })
toast('Sent to Backup')
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not send that still to Backup')
} finally {
backupBusy.value = false
}
}
async function saveFocusedAsInput() {
const still = focusedStill.value
if (!still || keepingInputBusy.value) return
keepingInputBusy.value = true
try {
applyLibrary(await $fetch(`/api/library/stills/${still.id}/keep-input`, { method: 'POST' }))
toast('Saved to Input. Pick it later from Choose an image → Input.')
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not save that still as input')
} finally {
keepingInputBusy.value = false
}
}
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.kind === 'track' && entry.track) {
applyLibrary(await $fetch(`/api/library/tracks/${entry.track.id}`, { method: 'PATCH', body: { name } }))
} 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' } })
}
if (entry.kind === 'track') {
return $fetch(`/api/library/tracks/${entry.track!.id}`, { method: 'DELETE' })
}
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) {
const target = event.target as HTMLElement | null
const typing = Boolean(target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable))
if (event.key === 'Escape') {
if (pendingDelete.value) pendingDelete.value = null
else if (lightboxOpen.value) lightboxOpen.value = false
else if (renaming.value) cancelRename()
else if (focusedKey.value) clearFocus()
else if (selectMode.value) selectMode.value = false
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()
if (!typing && (event.key === 'ArrowLeft' || event.key === 'ArrowRight') && focusedNavParts.value.length > 1) {
event.preventDefault()
stepFocusedFamily(event.key === 'ArrowLeft' ? -1 : 1)
}
}
watch(searchDraft, (value) => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
search.value = value
}, 160)
})
watch([folderId, filter], () => {
clearSelection()
if (scroller.value) scroller.value.scrollTop = 0
})
watch(search, () => {
if (scroller.value) scroller.value.scrollTop = 0
})
watch([cols, rowH], ([nextCols, nextRowH], [prevCols, prevRowH]) => {
const el = scroller.value
if (!el || !prevRowH) return
if (nextCols === prevCols && nextRowH === prevRowH) return
const index = Math.floor(el.scrollTop / (prevRowH + GAP)) * prevCols
el.scrollTop = Math.floor(index / nextCols) * (nextRowH + GAP)
scrollTop.value = el.scrollTop
})
watch(focusedKey, (key) => {
if (!key) {
detailsHidden.value = false
lightboxOpen.value = false
}
})
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)
// Do not block chrome/grid on the full catalog — first page paints via loadLibrary's first apply
void loadLibrary().catch(() => applyLibrary({ folders: [], clips: [], stills: [] }))
const me = await $fetch<{ user?: { name?: string; email?: string }; authEnabled?: boolean; authMode?: string; instanceName?: string }>('/api/auth/me').catch(() => ({ user: null }))
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>