1333 lines
56 KiB
Vue
1333 lines
56 KiB
Vue
<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 — grid paints immediately; full catalog follows for grouping/search
|
||
const first = await $fetch<any>('/api/library?limit=50')
|
||
applyLibrary(first)
|
||
const data = await $fetch<any>('/api/library?light=1')
|
||
applyLibrary(data)
|
||
}
|
||
|
||
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>
|