Files
aigen/pages/library.vue
T
TowstyandCursor 0ab42f488b Show Send to Backup on AIGen output and library stills.
The control was hidden until BACKUP_STUDIO_URL was set, so it never appeared next to Send to Refine.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 18:53:37 -05:00

976 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="flex h-dvh min-w-0 flex-col overflow-hidden">
<AppHeader
subtitle="Library"
current="library"
wide
/>
<main class="mx-auto flex min-h-0 w-full max-w-[90rem] flex-1 flex-col gap-4 px-4 py-4 sm:px-6 lg:flex-row">
<section class="glass flex min-h-0 min-w-0 flex-1 flex-col rounded-3xl p-4 sm:p-5">
<div class="flex flex-wrap items-end gap-3">
<label class="min-w-[12rem] flex-1 text-sm">
<span class="mb-1 block text-xs text-zinc-500">Search</span>
<input
v-model="searchDraft"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
placeholder="Name or prompt"
type="search"
>
</label>
<label class="text-sm">
<span class="mb-1 block text-xs text-zinc-500">Sort</span>
<select v-model="sort" class="rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm">
<option value="date-desc">Newest</option>
<option value="date-asc">Oldest</option>
<option value="name-asc">Name A–Z</option>
<option value="name-desc">Name Z–A</option>
<option value="type">Type (images first)</option>
</select>
</label>
<div class="inline-flex rounded-full border border-white/10 p-0.5">
<button
v-for="option in filterOptions"
:key="option.id"
type="button"
class="rounded-full px-2.5 py-1.5 text-[11px]"
:class="filter === option.id ? 'bg-amber-400/15 text-amber-100' : 'text-zinc-500 hover:text-zinc-300'"
@click="filter = option.id"
>
{{ option.label }}
</button>
</div>
</div>
<div class="mt-4 flex min-w-0 flex-wrap gap-1.5">
<button
type="button"
class="rounded-full border px-2.5 py-1 text-[11px]"
:class="folderId === 'all' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400'"
@click="folderId = 'all'"
>
All
<span class="text-zinc-500">{{ allCount }}</span>
</button>
<button
v-for="folder in folders"
:key="folder.id"
type="button"
class="rounded-full border px-2.5 py-1 text-[11px]"
:class="folderChipClass(folder.id)"
@click="folderId = folder.id"
@dragover.prevent="onFolderDragOver(folder.id, $event)"
@dragleave="onFolderDragLeave(folder.id)"
@drop.prevent="onFolderDrop(folder.id, $event)"
>
{{ folder.name }}
<span class="text-zinc-500">{{ folderChipCount(folder) }}</span>
<span v-if="folder.protected && !folder.unlocked"> · lock</span>
</button>
</div>
<form v-if="browseFolder?.protected && !browseFolder.unlocked" class="mt-6 space-y-3" @submit.prevent="unlockFolder">
<p class="text-sm text-zinc-400">This folder is locked. Unlock to browse it.</p>
<input v-model="unlockPassword" type="password" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="Folder password">
<button type="submit" class="rounded-xl bg-amber-400 px-4 py-2 text-sm font-semibold text-zinc-950">Unlock</button>
</form>
<template v-else>
<div class="mt-3 flex flex-wrap items-center gap-2 text-xs">
<p class="text-zinc-500">{{ entries.length }} item{{ entries.length === 1 ? '' : 's' }}</p>
<button v-if="entries.length" type="button" class="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=""
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="detailsHidden = !detailsHidden"
>
<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'
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 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}`
})
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 item.prompt || ''
}
const still = focused.value?.stillGroup?.parts.find(part => part.id === focusedPartId.value) || focused.value?.stillGroup?.head
return still?.prompt || ''
})
const detailMeta = computed(() => {
const entry = focused.value
if (!entry) return ''
if (entry.kind === 'clip' && entry.group) {
const item = entry.group.parts.find(part => part.id === focusedPartId.value) || entry.group.head
return [item.width && item.height ? `${item.width}×${item.height}` : '', formatClipDuration(item.duration), formatWhen(item.createdAt)].filter(Boolean).join(' · ')
}
const still = entry.stillGroup?.parts.find(part => part.id === focusedPartId.value) || entry.stillGroup?.head
if (!still) return ''
return [still.width && still.height ? `${still.width}×${still.height}` : 'image', formatWhen(still.createdAt)].filter(Boolean).join(' · ')
})
const downloadHref = computed(() => {
if (focused.value?.kind === 'clip' && focused.value.group) {
const id = focusedPartId.value || focused.value.group.head.id
return `/api/library/clips/${id}/video`
}
if (focused.value?.stillGroup) {
const id = focusedPartId.value || focused.value.stillGroup.head.id
return `/api/library/stills/${id}`
}
return ''
})
function toast(message: string) {
const id = ++toastId
toasts.value = [...toasts.value, { id, message }].slice(-4)
setTimeout(() => {
toasts.value = toasts.value.filter(item => item.id !== id)
}, 4200)
}
function applyLibrary(data: unknown) {
const payload = (data || {}) as { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[] }
folders.value = payload.folders || []
clips.value = payload.clips || []
stills.value = payload.stills || []
if (folderId.value !== 'all' && !folders.value.some(folder => folder.id === folderId.value)) {
folderId.value = folders.value[0]?.id || 'all'
}
}
async function loadLibrary() {
applyLibrary(await $fetch('/api/library?light=1'))
}
function 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) {
return itemHidden(entry) && !revealAll.value
}
const hasHiddenThumbs = computed(() => entries.value.some(entry => itemHidden(entry)))
function toggleRevealAll() {
revealAll.value = !revealAll.value
if (focused.value && itemHidden(focused.value)) {
detailsHidden.value = !revealAll.value
}
}
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 as a hidden input.')
} 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>