1035 lines
42 KiB
Vue
1035 lines
42 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="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="hasHiddenThumbs"
|
||
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 flagged thumbnails' : 'Reveal all hidden 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 type="button" class="text-red-300 hover:text-red-200" @click="askRemoveSelected">Delete</button>
|
||
<button type="button" class="text-zinc-400 hover:text-white" @click="clearSelection">Clear</button>
|
||
</div>
|
||
|
||
<p v-if="!entries.length" class="mt-8 text-center text-sm text-zinc-500">{{ emptyLabel }}</p>
|
||
<div
|
||
v-else
|
||
ref="scroller"
|
||
class="mt-3 min-h-[24rem] flex-1 overflow-y-auto"
|
||
@scroll="onScroll"
|
||
>
|
||
<div :style="{ height: `${gridHeight}px`, position: 'relative' }">
|
||
<div
|
||
class="grid"
|
||
:style="{
|
||
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
||
gap: `${GAP}px`,
|
||
transform: `translateY(${offsetY}px)`
|
||
}"
|
||
>
|
||
<article
|
||
v-for="entry in visibleEntries"
|
||
:key="entry.key"
|
||
draggable="true"
|
||
class="library-thumb group relative cursor-pointer overflow-hidden rounded-2xl border bg-zinc-950"
|
||
:class="cardClass(entry)"
|
||
@click="onCardClick(entry, $event)"
|
||
@dblclick="openInStudio(entry)"
|
||
@dragstart="onDragStart(entry, $event)"
|
||
@dragend="draggingKeys = []"
|
||
>
|
||
<div class="relative aspect-[4/5] bg-zinc-900">
|
||
<span v-if="thumbHidden(entry)" class="sensitive-glow block h-full w-full" />
|
||
<img
|
||
v-else-if="thumbSrc(entry)"
|
||
:src="thumbSrc(entry)"
|
||
alt=""
|
||
loading="lazy"
|
||
decoding="async"
|
||
draggable="false"
|
||
class="h-full w-full object-cover"
|
||
>
|
||
<span v-else class="block h-full w-full bg-zinc-800" />
|
||
<span class="absolute left-2 top-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-zinc-200">{{ entry.kind === 'clip' ? 'Video' : 'Image' }}</span>
|
||
<span
|
||
v-if="partCount(entry) > 1"
|
||
class="absolute right-2 top-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] text-amber-100"
|
||
>{{ partCount(entry) }}</span>
|
||
<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 w-full shrink-0 overflow-y-auto rounded-3xl p-4 sm:p-5 lg:h-full lg:w-[22rem]">
|
||
<template v-if="focused">
|
||
<div class="relative overflow-hidden rounded-2xl bg-zinc-900">
|
||
<span v-if="detailsHidden" class="sensitive-glow block aspect-video w-full" />
|
||
<video
|
||
v-else-if="focused.kind === 'clip' && previewVideo"
|
||
:src="previewVideo"
|
||
class="aspect-video w-full bg-black object-contain"
|
||
controls
|
||
playsinline
|
||
/>
|
||
<img
|
||
v-else-if="focused.kind === 'still' && previewStillSrc"
|
||
:src="previewStillSrc"
|
||
alt=""
|
||
decoding="async"
|
||
class="aspect-video w-full object-contain"
|
||
>
|
||
<img
|
||
v-else-if="thumbSrc(focused)"
|
||
:src="thumbSrc(focused)"
|
||
alt=""
|
||
class="aspect-video w-full object-cover"
|
||
>
|
||
<FamilyNav
|
||
: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"
|
||
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>
|
||
<input
|
||
v-if="renaming"
|
||
ref="renameInput"
|
||
v-model="renameDraft"
|
||
class="mt-3 w-full rounded-xl border border-amber-300/40 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 focus:ring-2"
|
||
maxlength="80"
|
||
@keydown.enter.prevent="saveRename"
|
||
@keydown.escape.prevent="cancelRename"
|
||
@blur="saveRename"
|
||
>
|
||
<h2 v-else class="mt-3 font-display text-lg font-bold leading-snug">{{ focused.name }}</h2>
|
||
<p class="mt-1 text-xs text-zinc-500">{{ detailMeta }}</p>
|
||
<p v-if="detailPrompt" class="mt-3 max-h-32 overflow-y-auto whitespace-pre-wrap text-xs text-zinc-400">{{ detailPrompt }}</p>
|
||
<div class="mt-4 flex flex-wrap gap-2">
|
||
<button type="button" class="rounded-full border border-amber-300/40 px-3 py-1.5 text-xs text-amber-100" @click="openInStudio(focused)">Open in Studio</button>
|
||
<button type="button" class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white" @click="useInStudio(focused)">Use as input</button>
|
||
<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 type="button" class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white" @click="startRename">Rename</button>
|
||
<select
|
||
v-if="folders.length > 1"
|
||
class="rounded-full border border-white/10 bg-zinc-950 px-2 py-1.5 text-xs text-amber-300"
|
||
:value="''"
|
||
@change="onMoveFocused($event)"
|
||
>
|
||
<option disabled value="">Move to…</option>
|
||
<option v-for="folder in moveFolders" :key="folder.id" :value="folder.id">{{ folder.name }}</option>
|
||
</select>
|
||
<button type="button" class="rounded-full border border-red-400/30 px-3 py-1.5 text-xs text-red-300" @click="askRemoveFocused">Delete</button>
|
||
</div>
|
||
<div v-if="focusedParts.length > 1" class="mt-4 grid grid-cols-4 gap-1.5">
|
||
<button
|
||
v-for="part in focusedParts"
|
||
:key="part.id"
|
||
type="button"
|
||
class="overflow-hidden rounded-lg border border-white/10"
|
||
:class="part.id === focusedPartId ? 'ring-2 ring-amber-300' : ''"
|
||
@click="focusPart(part.id)"
|
||
>
|
||
<img :src="partThumb(part)" alt="" class="aspect-square w-full object-cover" loading="lazy">
|
||
</button>
|
||
</div>
|
||
</template>
|
||
<p v-else class="text-sm text-zinc-500">Click an item to preview. Drag onto a folder to move. Shift-click or checkboxes to select several.</p>
|
||
</aside>
|
||
</main>
|
||
|
||
<Teleport to="body">
|
||
<div
|
||
v-if="pendingDelete"
|
||
class="fixed inset-0 z-[70] flex items-center justify-center bg-black/70 p-4"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
@click.self="pendingDelete = null"
|
||
>
|
||
<div class="glass w-full max-w-md rounded-3xl p-5 sm:p-6">
|
||
<h2 class="font-display text-xl font-bold">{{ pendingDelete.title }}</h2>
|
||
<p class="mt-2 text-sm font-medium text-zinc-100">{{ pendingDelete.name }}</p>
|
||
<p v-if="pendingDelete.detail" class="mt-1 text-xs text-zinc-500">{{ pendingDelete.detail }}</p>
|
||
<p class="mt-3 text-sm text-zinc-400">{{ pendingDelete.consequence }}</p>
|
||
<div class="mt-5 flex flex-wrap justify-end gap-2">
|
||
<button type="button" class="rounded-full border border-white/15 px-4 py-2 text-sm text-zinc-300" @click="pendingDelete = null">Keep it</button>
|
||
<button
|
||
type="button"
|
||
class="rounded-full border border-red-400/40 bg-red-400/10 px-4 py-2 text-sm font-semibold text-red-200 disabled:opacity-50"
|
||
:disabled="deleteBusy"
|
||
@click="confirmDelete"
|
||
>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Teleport>
|
||
|
||
<div class="pointer-events-none fixed bottom-4 right-4 z-50 space-y-2">
|
||
<div
|
||
v-for="item in toasts"
|
||
:key="item.id"
|
||
class="pointer-events-auto max-w-[min(20rem,calc(100vw-2rem))] rounded-2xl border border-red-400/30 bg-zinc-950/90 px-4 py-3 text-sm text-red-100 shadow-2xl"
|
||
>
|
||
{{ item.message }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import {
|
||
buildLibraryEntries,
|
||
chronologicalParts,
|
||
chronologicalStills,
|
||
formatClipDuration,
|
||
formatWhen,
|
||
type LibraryClip,
|
||
type LibraryEntry,
|
||
type LibraryFilter,
|
||
type LibraryFolder,
|
||
type LibrarySort,
|
||
type LibraryStill
|
||
} from '~/utils/libraryBrowse'
|
||
import { isXaigenStudio } from '~/utils/videoModels'
|
||
import { formatPromptParts } from '~/utils/promptParts'
|
||
|
||
const CARD_MIN = 156
|
||
const GAP = 10
|
||
const ROW_H = 248
|
||
const BUFFER_ROWS = 3
|
||
const DRAG_MIME = 'application/x-aigen-library'
|
||
const filterOptions = [
|
||
{ id: 'all' as const, label: 'All' },
|
||
{ id: 'images' as const, label: 'Images' },
|
||
{ id: 'videos' as const, label: 'Videos' }
|
||
]
|
||
|
||
const runtimeConfig = useRuntimeConfig()
|
||
const instanceName = ref(String(runtimeConfig.public.instanceName || 'AIGen'))
|
||
const 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 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 lastSelectedKey = ref('')
|
||
const focusedKey = ref('')
|
||
const focusedPartId = ref('')
|
||
const unlockPassword = ref('')
|
||
const dropFolderId = ref('')
|
||
const draggingKeys = ref<string[]>([])
|
||
const renaming = ref(false)
|
||
const renameDraft = ref('')
|
||
const renameInput = ref<HTMLInputElement | null>(null)
|
||
const deleteBusy = ref(false)
|
||
const 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,
|
||
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,
|
||
folderId: 'all',
|
||
filter: filter.value,
|
||
search: '',
|
||
sort: 'date-desc'
|
||
}).length
|
||
})
|
||
const folderChipCounts = computed(() => {
|
||
const map: Record<string, { videos: number; images: number }> = {}
|
||
for (const clip of clips.value) {
|
||
if (clip.hasVideo === false) continue
|
||
const row = map[clip.folderId] || (map[clip.folderId] = { videos: 0, images: 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 })
|
||
row.images += 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.'
|
||
return 'Nothing in this folder yet.'
|
||
})
|
||
const cols = computed(() => Math.max(2, Math.floor((viewportW.value + GAP) / (CARD_MIN + GAP))))
|
||
const rowCount = computed(() => Math.ceil(entries.value.length / cols.value) || 0)
|
||
const gridHeight = computed(() => rowCount.value * ROW_H + Math.max(0, rowCount.value - 1) * GAP)
|
||
const startRow = computed(() => Math.max(0, Math.floor(scrollTop.value / (ROW_H + GAP)) - BUFFER_ROWS))
|
||
const visibleRowCount = computed(() => Math.ceil(viewportH.value / (ROW_H + GAP)) + BUFFER_ROWS * 2)
|
||
const offsetY = computed(() => startRow.value * (ROW_H + GAP))
|
||
const visibleEntries = computed(() => {
|
||
const start = startRow.value * cols.value
|
||
const end = start + visibleRowCount.value * cols.value
|
||
return entries.value.slice(start, end)
|
||
})
|
||
const previewVideo = computed(() => {
|
||
if (focused.value?.kind !== 'clip' || !focused.value.group) return ''
|
||
const id = focusedPartId.value || focused.value.group.head.id
|
||
return `/api/library/clips/${id}/video`
|
||
})
|
||
const 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}?thumb=1`
|
||
})
|
||
const focusedParts = computed(() => {
|
||
if (focused.value?.kind === 'clip') return focused.value.group?.parts || []
|
||
return focused.value?.stillGroup?.parts || []
|
||
})
|
||
const focusedNavParts = computed(() => {
|
||
const parts = focusedParts.value
|
||
if (parts.length < 2) return []
|
||
if (focused.value?.kind === 'still') return chronologicalStills(parts as LibraryStill[])
|
||
return chronologicalParts(parts as LibraryClip[])
|
||
})
|
||
const focusedNavIndex = computed(() => {
|
||
const id = focusedPartId.value
|
||
const index = focusedNavParts.value.findIndex(part => part.id === id)
|
||
return index < 0 ? 0 : index
|
||
})
|
||
const detailPrompt = 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 formatPromptParts(item)
|
||
}
|
||
const still = focused.value?.stillGroup?.parts.find(part => part.id === focusedPartId.value) || focused.value?.stillGroup?.head
|
||
return formatPromptParts(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(' · ')
|
||
}
|
||
const still = entry.stillGroup?.parts.find(part => part.id === focusedPartId.value) || entry.stillGroup?.head
|
||
if (!still) return ''
|
||
return [still.width && still.height ? `${still.width}×${still.height}` : 'image', formatWhen(still.createdAt)].filter(Boolean).join(' · ')
|
||
})
|
||
const downloadHref = computed(() => {
|
||
if (focused.value?.kind === 'clip' && focused.value.group) {
|
||
const id = focusedPartId.value || focused.value.group.head.id
|
||
return `/api/library/clips/${id}/video`
|
||
}
|
||
if (focused.value?.stillGroup) {
|
||
const id = focusedPartId.value || focused.value.stillGroup.head.id
|
||
return `/api/library/stills/${id}`
|
||
}
|
||
return ''
|
||
})
|
||
|
||
function toast(message: string) {
|
||
const id = ++toastId
|
||
toasts.value = [...toasts.value, { id, message }].slice(-4)
|
||
setTimeout(() => {
|
||
toasts.value = toasts.value.filter(item => item.id !== id)
|
||
}, 4200)
|
||
}
|
||
|
||
function applyLibrary(data: unknown) {
|
||
const payload = (data || {}) as {
|
||
id?: string
|
||
hideInput?: boolean
|
||
hideThumbnail?: boolean
|
||
folders?: LibraryFolder[]
|
||
clips?: LibraryClip[]
|
||
stills?: LibraryStill[]
|
||
}
|
||
if (payload.id && !payload.folders && !payload.clips && !payload.stills) {
|
||
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 || []
|
||
if (folderId.value !== 'all' && !folders.value.some(folder => folder.id === folderId.value)) {
|
||
folderId.value = folders.value[0]?.id || 'all'
|
||
}
|
||
}
|
||
|
||
async function loadLibrary() {
|
||
applyLibrary(await $fetch('/api/library?light=1'))
|
||
}
|
||
|
||
function folderChipCount(folder: LibraryFolder) {
|
||
if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 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
|
||
return row.videos + row.images
|
||
}
|
||
|
||
function folderChipClass(id: string) {
|
||
if (folderId.value === id) return 'border-amber-300 bg-amber-400/10 text-amber-100'
|
||
if (dropFolderId.value === id) return 'border-amber-300/70 bg-amber-400/10 text-amber-100'
|
||
return 'border-white/10 text-zinc-400'
|
||
}
|
||
|
||
function cardClass(entry: LibraryEntry) {
|
||
if (focusedKey.value === entry.key) return 'border-amber-300 ring-2 ring-amber-300/70'
|
||
if (selectedSet.value.has(entry.key)) return 'border-amber-300/40 bg-amber-400/5'
|
||
return 'border-white/10'
|
||
}
|
||
|
||
function itemHidden(entry: LibraryEntry) {
|
||
if (entry.kind === 'clip') return Boolean(entry.group?.head.hideThumbnail)
|
||
return Boolean(entry.stillGroup?.head.hideInput)
|
||
}
|
||
|
||
function thumbHidden(entry: LibraryEntry) {
|
||
const id = entry.kind === 'clip' ? entry.group?.head.id : entry.stillGroup?.head.id
|
||
if (id && forceHiddenIds.value.includes(id)) return true
|
||
return itemHidden(entry) && !revealAll.value
|
||
}
|
||
|
||
const hasHiddenThumbs = computed(() => entries.value.some(entry => itemHidden(entry)))
|
||
|
||
function toggleRevealAll() {
|
||
revealAll.value = !revealAll.value
|
||
forceHiddenIds.value = []
|
||
if (focused.value && itemHidden(focused.value)) {
|
||
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?.head.hasThumbnail) return `/api/library/clips/${entry.group.head.id}/thumb`
|
||
if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.head.id}?thumb=1`
|
||
return ''
|
||
}
|
||
|
||
function partCount(entry: LibraryEntry) {
|
||
return entry.kind === 'clip' ? (entry.group?.parts.length || 1) : (entry.stillGroup?.parts.length || 1)
|
||
}
|
||
|
||
function cardMeta(entry: LibraryEntry) {
|
||
if (entry.kind === 'clip' && entry.group) {
|
||
return [formatClipDuration(entry.group.head.duration), formatWhen(entry.createdAt)].filter(Boolean).join(' · ')
|
||
}
|
||
return formatWhen(entry.createdAt)
|
||
}
|
||
|
||
function partThumb(part: LibraryClip | LibraryStill) {
|
||
if ('hideThumbnail' in part) return `/api/library/clips/${part.id}/thumb`
|
||
return `/api/library/stills/${part.id}?thumb=1`
|
||
}
|
||
|
||
function focusPart(id: string) {
|
||
focusedPartId.value = id
|
||
}
|
||
|
||
function 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 selectAll() {
|
||
selectedKeys.value = entries.value.map(entry => entry.key)
|
||
lastSelectedKey.value = selectedKeys.value.at(-1) || ''
|
||
}
|
||
|
||
function onSelectClick(key: string, event: MouseEvent) {
|
||
const keys = entries.value.map(entry => entry.key)
|
||
if (event.shiftKey && lastSelectedKey.value) {
|
||
const start = keys.indexOf(lastSelectedKey.value)
|
||
const end = keys.indexOf(key)
|
||
if (start >= 0 && end >= 0) {
|
||
const [from, to] = start < end ? [start, end] : [end, start]
|
||
selectedKeys.value = [...new Set([...selectedKeys.value, ...keys.slice(from, to + 1)])]
|
||
lastSelectedKey.value = key
|
||
return
|
||
}
|
||
}
|
||
if (selectedKeys.value.includes(key)) selectedKeys.value = selectedKeys.value.filter(item => item !== key)
|
||
else selectedKeys.value = [...selectedKeys.value, key]
|
||
lastSelectedKey.value = key
|
||
}
|
||
|
||
function onCardClick(entry: LibraryEntry, event: MouseEvent) {
|
||
if ((event.target as HTMLElement | null)?.closest('input, select, textarea, a, button, label')) return
|
||
if (event.shiftKey || event.ctrlKey || event.metaKey) {
|
||
onSelectClick(entry.key, event)
|
||
return
|
||
}
|
||
const sameCard = focusedKey.value === entry.key
|
||
focusedKey.value = entry.key
|
||
focusedPartId.value = entry.kind === 'clip' ? (entry.group?.head.id || '') : (entry.stillGroup?.head.id || '')
|
||
if (!sameCard) detailsHidden.value = itemHidden(entry) && !revealAll.value
|
||
cancelRename()
|
||
}
|
||
|
||
function onDragStart(entry: LibraryEntry, event: DragEvent) {
|
||
const keys = selectedKeys.value.includes(entry.key) ? selectedKeys.value : [entry.key]
|
||
draggingKeys.value = keys
|
||
event.dataTransfer?.setData(DRAG_MIME, JSON.stringify(keys))
|
||
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'
|
||
}
|
||
|
||
function onFolderDragOver(id: string, event: DragEvent) {
|
||
event.dataTransfer && (event.dataTransfer.dropEffect = 'move')
|
||
dropFolderId.value = id
|
||
}
|
||
|
||
function onFolderDragLeave(id: string) {
|
||
if (dropFolderId.value === id) dropFolderId.value = ''
|
||
}
|
||
|
||
async function onFolderDrop(id: string, event: DragEvent) {
|
||
dropFolderId.value = ''
|
||
let keys = draggingKeys.value
|
||
try {
|
||
const raw = event.dataTransfer?.getData(DRAG_MIME)
|
||
if (raw) keys = JSON.parse(raw)
|
||
} catch { /* keep draggingKeys */ }
|
||
draggingKeys.value = []
|
||
await moveKeys(keys, id)
|
||
}
|
||
|
||
function selectedEntries(keys = selectedKeys.value) {
|
||
const chosen = new Set(keys)
|
||
return entries.value.filter(entry => chosen.has(entry.key))
|
||
}
|
||
|
||
async function moveKeys(keys: string[], destId: string) {
|
||
const items = selectedEntries(keys).filter((entry) => {
|
||
const current = entry.kind === 'clip' ? entry.group?.head.folderId : entry.stillGroup?.head.folderId
|
||
return current && current !== destId
|
||
})
|
||
if (!items.length) return
|
||
const dest = folders.value.find(folder => folder.id === destId)
|
||
const results = await Promise.allSettled(items.map((entry) => {
|
||
if (entry.kind === 'clip') {
|
||
return $fetch(`/api/library/clips/${entry.group!.head.id}`, { method: 'PATCH', body: { folderId: destId, family: true } })
|
||
}
|
||
return $fetch(`/api/library/stills/${entry.stillGroup!.head.id}`, { method: 'PATCH', body: { folderId: destId, family: true } })
|
||
}))
|
||
await loadLibrary()
|
||
const failed = results.filter(result => result.status === 'rejected').length
|
||
clearSelection()
|
||
if (failed) toast(`${items.length - failed} moved. ${failed} could not be moved.`)
|
||
else toast(`Moved ${items.length} to ${dest?.name || 'folder'}`)
|
||
}
|
||
|
||
async function onMoveSelected(event: Event) {
|
||
const select = event.target as HTMLSelectElement
|
||
const destId = select.value
|
||
select.value = ''
|
||
if (destId) await moveKeys(selectedKeys.value, destId)
|
||
}
|
||
|
||
async function onMoveFocused(event: Event) {
|
||
const select = event.target as HTMLSelectElement
|
||
const destId = select.value
|
||
select.value = ''
|
||
if (destId && focusedKey.value) await moveKeys([focusedKey.value], destId)
|
||
}
|
||
|
||
function openInStudio(entry: LibraryEntry) {
|
||
if (entry.kind === 'clip' && entry.group) {
|
||
navigateTo({ path: '/', query: { clip: focusedPartId.value || entry.group.head.id } })
|
||
return
|
||
}
|
||
if (entry.stillGroup) {
|
||
navigateTo({ path: '/', query: { still: focusedPartId.value || entry.stillGroup.head.id } })
|
||
}
|
||
}
|
||
|
||
function useInStudio(entry: LibraryEntry) {
|
||
if (entry.kind === 'clip' && entry.group) {
|
||
navigateTo({ path: '/', query: { clip: focusedPartId.value || entry.group.head.id, use: '1' } })
|
||
return
|
||
}
|
||
if (entry.stillGroup) {
|
||
navigateTo({ path: '/', query: { still: focusedPartId.value || entry.stillGroup.head.id, use: '1' } })
|
||
}
|
||
}
|
||
|
||
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.stillGroup) {
|
||
applyLibrary(await $fetch(`/api/library/stills/${entry.stillGroup.head.id}`, { method: 'PATCH', body: { name, family: true } }))
|
||
}
|
||
cancelRename()
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || 'Could not rename')
|
||
}
|
||
}
|
||
|
||
function askRemoveFocused() {
|
||
if (!focused.value) return
|
||
askRemoveKeys([focused.value.key])
|
||
}
|
||
|
||
function askRemoveSelected() {
|
||
askRemoveKeys(selectedKeys.value)
|
||
}
|
||
|
||
function askRemoveKeys(keys: string[]) {
|
||
const items = selectedEntries(keys)
|
||
if (!items.length) return
|
||
const total = items.reduce((count, entry) => count + partCount(entry), 0)
|
||
pendingDelete.value = {
|
||
title: items.length === 1 ? 'Delete this item?' : 'Delete these items?',
|
||
name: items.slice(0, 3).map(entry => entry.name).join(', ') + (items.length > 3 ? ` and ${items.length - 3} more` : ''),
|
||
detail: `${items.length} collection${items.length === 1 ? '' : 's'} · ${total} part${total === 1 ? '' : 's'}`,
|
||
consequence: 'This removes them from the library.',
|
||
keys
|
||
}
|
||
}
|
||
|
||
async function confirmDelete() {
|
||
const pending = pendingDelete.value
|
||
if (!pending || deleteBusy.value) return
|
||
pendingDelete.value = null
|
||
deleteBusy.value = true
|
||
try {
|
||
const items = selectedEntries(pending.keys)
|
||
await Promise.allSettled(items.map((entry) => {
|
||
if (entry.kind === 'clip') {
|
||
return $fetch(`/api/library/clips/${entry.group!.head.id}`, { method: 'DELETE', query: { family: '1' } })
|
||
}
|
||
return $fetch(`/api/library/stills/${entry.stillGroup!.head.id}`, { method: 'DELETE', query: { family: '1' } })
|
||
}))
|
||
if (pending.keys.includes(focusedKey.value)) {
|
||
focusedKey.value = ''
|
||
focusedPartId.value = ''
|
||
}
|
||
clearSelection()
|
||
await loadLibrary()
|
||
} finally {
|
||
deleteBusy.value = false
|
||
}
|
||
}
|
||
|
||
async function unlockFolder() {
|
||
if (!browseFolder.value) return
|
||
try {
|
||
applyLibrary(await $fetch('/api/library/unlock', { method: 'POST', body: { password: unlockPassword.value, folderId: browseFolder.value.id } }))
|
||
unlockPassword.value = ''
|
||
} catch (error: any) {
|
||
toast(error?.data?.statusMessage || 'Unlock failed')
|
||
}
|
||
}
|
||
|
||
async function lockFolder() {
|
||
if (!browseFolder.value) return
|
||
try {
|
||
applyLibrary(await $fetch('/api/library/lock', { method: 'POST', body: { folderId: browseFolder.value.id } }))
|
||
} catch {
|
||
await loadLibrary()
|
||
}
|
||
}
|
||
|
||
function onKey(event: KeyboardEvent) {
|
||
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 (renaming.value) cancelRename()
|
||
else clearSelection()
|
||
}
|
||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a' && !renaming.value) {
|
||
event.preventDefault()
|
||
selectAll()
|
||
}
|
||
if (event.key === 'Delete' && selectedKeys.value.length && !renaming.value) askRemoveSelected()
|
||
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(focusedKey, (key) => {
|
||
if (!key) detailsHidden.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)
|
||
const [me] = await Promise.all([
|
||
$fetch<{ user?: { name?: string; email?: string }; authEnabled?: boolean; authMode?: string; instanceName?: string }>('/api/auth/me').catch(() => ({ user: null })),
|
||
loadLibrary().catch(() => applyLibrary({ folders: [], clips: [], stills: [] }))
|
||
])
|
||
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>
|