Files
aigen/pages/library.vue
T
TowstyandCursor e89b47b5f9 Add same-mode extra passes to Image v2, and hide Shot script from image Recommend.
Library tiles show a check when selected, and xAIGen can hide a preview from the details pane.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 18:17:17 -05:00

877 lines
36 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">
<header class="sticky top-0 z-20 border-b border-white/5 bg-zinc-950/70 backdrop-blur-xl">
<div class="mx-auto flex max-w-[90rem] items-center justify-between px-4 py-4 sm:px-6">
<div class="flex items-center gap-3">
<NuxtLink to="/" class="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-400 text-zinc-950 font-display font-extrabold">A</NuxtLink>
<div>
<h1 class="font-display text-xl font-bold leading-none">{{ instanceName }}</h1>
<p class="text-[11px] uppercase tracking-[0.22em] text-zinc-500">Library</p>
</div>
</div>
<div class="flex items-center gap-3 text-sm">
<NuxtLink to="/" class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white">Studio</NuxtLink>
<NuxtLink to="/library" class="rounded-full border border-amber-300/70 bg-amber-400/10 px-3 py-1 text-amber-100">Library</NuxtLink>
<NuxtLink to="/director" class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white">Director</NuxtLink>
<NuxtLink to="/queue" class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white">Queue</NuxtLink>
<button type="button" class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white" @click="settingsOpen = true">Settings</button>
<a v-if="authEnabled" href="/api/auth/logout" class="text-zinc-400 hover:text-white">Sign out</a>
</div>
</div>
</header>
<main class="mx-auto flex min-h-0 w-full max-w-[90rem] flex-1 flex-col gap-4 px-4 py-4 sm:px-6 lg:flex-row">
<section class="glass flex min-h-0 min-w-0 flex-1 flex-col rounded-3xl p-4 sm:p-5">
<div class="flex flex-wrap items-end gap-3">
<label class="min-w-[12rem] flex-1 text-sm">
<span class="mb-1 block text-xs text-zinc-500">Search</span>
<input
v-model="search"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
placeholder="Name or prompt"
type="search"
>
</label>
<label class="text-sm">
<span class="mb-1 block text-xs text-zinc-500">Sort</span>
<select v-model="sort" class="rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm">
<option value="date-desc">Newest</option>
<option value="date-asc">Oldest</option>
<option value="name-asc">Name A–Z</option>
<option value="name-desc">Name Z–A</option>
<option value="type">Type (images first)</option>
</select>
</label>
<div class="inline-flex rounded-full border border-white/10 p-0.5">
<button
v-for="option in filterOptions"
:key="option.id"
type="button"
class="rounded-full px-2.5 py-1.5 text-[11px]"
:class="filter === option.id ? 'bg-amber-400/15 text-amber-100' : 'text-zinc-500 hover:text-zinc-300'"
@click="filter = option.id"
>
{{ option.label }}
</button>
</div>
</div>
<div class="mt-4 flex min-w-0 flex-wrap gap-1.5">
<button
type="button"
class="rounded-full border px-2.5 py-1 text-[11px]"
:class="folderId === 'all' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400'"
@click="folderId = 'all'"
>
All
<span class="text-zinc-500">{{ allCount }}</span>
</button>
<button
v-for="folder in folders"
:key="folder.id"
type="button"
class="rounded-full border px-2.5 py-1 text-[11px]"
:class="folderChipClass(folder.id)"
@click="folderId = folder.id"
@dragover.prevent="onFolderDragOver(folder.id, $event)"
@dragleave="onFolderDragLeave(folder.id)"
@drop.prevent="onFolderDrop(folder.id, $event)"
>
{{ folder.name }}
<span class="text-zinc-500">{{ folderOutputCount(folder, clips, stills, filter) }}</span>
<span v-if="folder.protected && !folder.unlocked"> · lock</span>
</button>
</div>
<form v-if="browseFolder?.protected && !browseFolder.unlocked" class="mt-6 space-y-3" @submit.prevent="unlockFolder">
<p class="text-sm text-zinc-400">This folder is locked. Unlock to browse it.</p>
<input v-model="unlockPassword" type="password" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="Folder password">
<button type="submit" class="rounded-xl bg-amber-400 px-4 py-2 text-sm font-semibold text-zinc-950">Unlock</button>
</form>
<template v-else>
<div class="mt-3 flex flex-wrap items-center gap-2 text-xs">
<p class="text-zinc-500">{{ entries.length }} item{{ entries.length === 1 ? '' : 's' }}</p>
<button v-if="entries.length" type="button" class="text-zinc-400 hover:text-white" @click="selectedKeys.length === entries.length ? clearSelection() : selectAll()">
{{ selectedKeys.length && selectedKeys.length === entries.length ? 'Clear selection' : 'Select all' }}
</button>
<button v-if="browseFolder?.protected && browseFolder.unlocked" type="button" class="text-zinc-400 hover:text-white" @click="lockFolder">Lock folder</button>
</div>
<div
v-if="selectedKeys.length"
class="mt-3 flex flex-wrap items-center gap-2 rounded-xl border border-amber-300/20 bg-amber-400/5 px-3 py-2 text-xs"
>
<span class="font-medium text-amber-100">{{ selectedKeys.length }} selected</span>
<select
v-if="folders.length > 1"
class="max-w-[10rem] rounded-lg border border-white/10 bg-zinc-950 px-1.5 py-0.5 text-amber-300"
:value="''"
@change="onMoveSelected($event)"
>
<option disabled value="">Move to…</option>
<option v-for="folder in moveFolders" :key="folder.id" :value="folder.id">
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
</option>
</select>
<button type="button" class="text-red-300 hover:text-red-200" @click="askRemoveSelected">Delete</button>
<button type="button" class="text-zinc-400 hover:text-white" @click="clearSelection">Clear</button>
</div>
<p v-if="!entries.length" class="mt-8 text-center text-sm text-zinc-500">{{ emptyLabel }}</p>
<div
v-else
ref="scroller"
class="mt-3 min-h-[24rem] flex-1 overflow-y-auto"
@scroll="onScroll"
>
<div :style="{ height: `${gridHeight}px`, position: 'relative' }">
<div
class="grid"
:style="{
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
gap: `${GAP}px`,
transform: `translateY(${offsetY}px)`
}"
>
<article
v-for="entry in visibleEntries"
:key="entry.key"
draggable="true"
class="library-thumb group relative cursor-pointer overflow-hidden rounded-2xl border bg-zinc-950"
:class="cardClass(entry)"
@click="onCardClick(entry, $event)"
@dblclick="openInStudio(entry)"
@dragstart="onDragStart(entry, $event)"
@dragend="draggingKeys = []"
>
<div class="relative aspect-[4/5] bg-zinc-900">
<span v-if="thumbHidden(entry)" class="sensitive-glow block h-full w-full" />
<img
v-else-if="thumbSrc(entry)"
:src="thumbSrc(entry)"
alt=""
loading="lazy"
decoding="async"
draggable="false"
class="h-full w-full object-cover"
>
<span v-else class="block h-full w-full bg-zinc-800" />
<span class="absolute left-2 top-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-zinc-200">{{ entry.kind === 'clip' ? 'Video' : 'Image' }}</span>
<span
v-if="partCount(entry) > 1"
class="absolute right-2 top-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] text-amber-100"
>{{ partCount(entry) }}</span>
<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="selectedKeys.includes(entry.key)
? 'border-white bg-amber-400 text-zinc-950'
: 'border-white/30 bg-black/45'"
:aria-checked="selectedKeys.includes(entry.key)"
:aria-label="selectedKeys.includes(entry.key) ? 'Deselect' : 'Select'"
@click.stop="onSelectClick(entry.key, $event)"
>
<svg
v-if="selectedKeys.includes(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="previewHidden" 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"
>
<button
v-if="showPrivacyToggles"
type="button"
class="absolute right-2 top-2 z-10 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 disabled:opacity-50"
:title="previewHidden ? 'Show in library' : 'Hide in library'"
:aria-label="previewHidden ? 'Show in library' : 'Hide in library'"
:disabled="hideBusy"
@click="togglePreviewHidden"
>
<svg v-if="previewHidden" 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>
<a
v-if="downloadHref"
:href="downloadHref"
:download="focused.name"
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white"
>Download</a>
<button type="button" class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white" @click="startRename">Rename</button>
<select
v-if="folders.length > 1"
class="rounded-full border border-white/10 bg-zinc-950 px-2 py-1.5 text-xs text-amber-300"
:value="''"
@change="onMoveFocused($event)"
>
<option disabled value="">Move to…</option>
<option v-for="folder in moveFolders" :key="folder.id" :value="folder.id">{{ folder.name }}</option>
</select>
<button type="button" class="rounded-full border border-red-400/30 px-3 py-1.5 text-xs text-red-300" @click="askRemoveFocused">Delete</button>
</div>
<div v-if="focusedParts.length > 1" class="mt-4 grid grid-cols-4 gap-1.5">
<button
v-for="part in focusedParts"
:key="part.id"
type="button"
class="overflow-hidden rounded-lg border border-white/10"
:class="part.id === focusedPartId ? 'ring-2 ring-amber-300' : ''"
@click="focusPart(part.id)"
>
<img :src="partThumb(part)" alt="" class="aspect-square w-full object-cover" loading="lazy">
</button>
</div>
</template>
<p v-else class="text-sm text-zinc-500">Click an item to preview. Drag onto a folder to move. Shift-click or checkboxes to select several.</p>
</aside>
</main>
<SettingsModal
v-if="settingsOpen"
:folders="folders"
:auth-mode="authMode"
:username="userName"
@close="settingsOpen = false"
@updated="applyLibrary"
@error="toast"
@renamed="userName = $event"
/>
<Teleport to="body">
<div
v-if="pendingDelete"
class="fixed inset-0 z-[70] flex items-center justify-center bg-black/70 p-4"
role="dialog"
aria-modal="true"
@click.self="pendingDelete = null"
>
<div class="glass w-full max-w-md rounded-3xl p-5 sm:p-6">
<h2 class="font-display text-xl font-bold">{{ pendingDelete.title }}</h2>
<p class="mt-2 text-sm font-medium text-zinc-100">{{ pendingDelete.name }}</p>
<p v-if="pendingDelete.detail" class="mt-1 text-xs text-zinc-500">{{ pendingDelete.detail }}</p>
<p class="mt-3 text-sm text-zinc-400">{{ pendingDelete.consequence }}</p>
<div class="mt-5 flex flex-wrap justify-end gap-2">
<button type="button" class="rounded-full border border-white/15 px-4 py-2 text-sm text-zinc-300" @click="pendingDelete = null">Keep it</button>
<button
type="button"
class="rounded-full border border-red-400/40 bg-red-400/10 px-4 py-2 text-sm font-semibold text-red-200 disabled:opacity-50"
:disabled="deleteBusy"
@click="confirmDelete"
>
Delete
</button>
</div>
</div>
</div>
</Teleport>
<div class="pointer-events-none fixed bottom-4 right-4 z-50 space-y-2">
<div
v-for="item in toasts"
:key="item.id"
class="pointer-events-auto max-w-[min(20rem,calc(100vw-2rem))] rounded-2xl border border-red-400/30 bg-zinc-950/90 px-4 py-3 text-sm text-red-100 shadow-2xl"
>
{{ item.message }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
buildLibraryEntries,
folderOutputCount,
formatClipDuration,
formatWhen,
type LibraryClip,
type LibraryEntry,
type LibraryFilter,
type LibraryFolder,
type LibrarySort,
type LibraryStill
} from '~/utils/libraryBrowse'
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 showPrivacyToggles = computed(() => isXaigenStudio({
studio: runtimeConfig.public.studio,
instanceName: runtimeConfig.public.instanceName || instanceName.value
}))
const authEnabled = ref(false)
const authMode = ref('')
const userName = ref('')
const settingsOpen = ref(false)
const folders = ref<LibraryFolder[]>([])
const clips = ref<LibraryClip[]>([])
const stills = ref<LibraryStill[]>([])
const folderId = ref('all')
const filter = ref<LibraryFilter>('all')
const sort = ref<LibrarySort>('date-desc')
const search = ref('')
const selectedKeys = ref<string[]>([])
const lastSelectedKey = ref('')
const focusedKey = ref('')
const focusedPartId = ref('')
const unlockPassword = ref('')
const dropFolderId = ref('')
const draggingKeys = ref<string[]>([])
const renaming = ref(false)
const renameDraft = ref('')
const renameInput = ref<HTMLInputElement | null>(null)
const deleteBusy = ref(false)
const hideBusy = ref(false)
const pendingDelete = ref<{ title: string; name: string; detail?: string; consequence: string; keys: string[] } | null>(null)
const toasts = ref<Array<{ id: number; message: string }>>([])
let toastId = 0
const scroller = ref<HTMLElement | null>(null)
const scrollTop = ref(0)
const viewportH = ref(800)
const viewportW = ref(800)
const browseFolder = computed(() => folders.value.find(folder => folder.id === folderId.value) || null)
const entries = computed(() => buildLibraryEntries({
clips: clips.value,
stills: stills.value,
folderId: folderId.value,
filter: filter.value,
search: search.value,
sort: sort.value
}))
const allCount = computed(() => buildLibraryEntries({
clips: clips.value,
stills: stills.value,
folderId: 'all',
filter: filter.value,
search: '',
sort: 'date-desc'
}).length)
const moveFolders = computed(() => folders.value.filter(folder => folder.id !== folderId.value))
const focused = computed(() => entries.value.find(entry => entry.key === focusedKey.value) || null)
const emptyLabel = computed(() => {
if (search.value.trim()) return 'Nothing matches that search.'
if (filter.value === 'images') return 'No generated stills here yet.'
if (filter.value === 'videos') return 'No generated clips here yet.'
return 'Nothing in this folder yet.'
})
const cols = computed(() => Math.max(2, Math.floor((viewportW.value + GAP) / (CARD_MIN + GAP))))
const rowCount = computed(() => Math.ceil(entries.value.length / cols.value) || 0)
const gridHeight = computed(() => rowCount.value * ROW_H + Math.max(0, rowCount.value - 1) * GAP)
const startRow = computed(() => Math.max(0, Math.floor(scrollTop.value / (ROW_H + GAP)) - BUFFER_ROWS))
const visibleRowCount = computed(() => Math.ceil(viewportH.value / (ROW_H + GAP)) + BUFFER_ROWS * 2)
const offsetY = computed(() => startRow.value * (ROW_H + GAP))
const visibleEntries = computed(() => {
const start = startRow.value * cols.value
const end = start + visibleRowCount.value * cols.value
return entries.value.slice(start, end)
})
const previewVideo = computed(() => {
if (focused.value?.kind !== 'clip' || !focused.value.group) return ''
const id = focusedPartId.value || focused.value.group.head.id
return `/api/library/clips/${id}/video`
})
const 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 focusedPreviewItem = computed(() => {
const entry = focused.value
if (!entry) return null
if (entry.kind === 'clip') {
return entry.group?.parts.find(part => part.id === focusedPartId.value) || entry.group?.head || null
}
return entry.stillGroup?.parts.find(part => part.id === focusedPartId.value) || entry.stillGroup?.head || null
})
const previewHidden = computed(() => {
const item = focusedPreviewItem.value
if (!item) return false
return 'hideThumbnail' in item ? Boolean(item.hideThumbnail) : Boolean(item.hideInput)
})
const focusedParts = computed(() => {
if (focused.value?.kind === 'clip') return focused.value.group?.parts || []
return focused.value?.stillGroup?.parts || []
})
const detailPrompt = computed(() => {
if (focused.value?.kind === 'clip') return focused.value.group?.head.prompt || ''
return focused.value?.stillGroup?.head.prompt || ''
})
const detailMeta = computed(() => {
const entry = focused.value
if (!entry) return ''
if (entry.kind === 'clip' && entry.group) {
const item = entry.group.parts.find(part => part.id === focusedPartId.value) || entry.group.head
return [item.width && item.height ? `${item.width}×${item.height}` : '', formatClipDuration(item.duration), formatWhen(item.createdAt)].filter(Boolean).join(' · ')
}
const still = entry.stillGroup?.parts.find(part => part.id === focusedPartId.value) || entry.stillGroup?.head
if (!still) return ''
return [still.width && still.height ? `${still.width}×${still.height}` : 'image', formatWhen(still.createdAt)].filter(Boolean).join(' · ')
})
const downloadHref = computed(() => {
if (focused.value?.kind === 'clip' && focused.value.group) {
const id = focusedPartId.value || focused.value.group.head.id
return `/api/library/clips/${id}/video`
}
if (focused.value?.stillGroup) {
const id = focusedPartId.value || focused.value.stillGroup.head.id
return `/api/library/stills/${id}`
}
return ''
})
function toast(message: string) {
const id = ++toastId
toasts.value = [...toasts.value, { id, message }].slice(-4)
setTimeout(() => {
toasts.value = toasts.value.filter(item => item.id !== id)
}, 4200)
}
function applyLibrary(data: unknown) {
const payload = (data || {}) as { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[] }
folders.value = payload.folders || []
clips.value = payload.clips || []
stills.value = payload.stills || []
if (folderId.value !== 'all' && !folders.value.some(folder => folder.id === folderId.value)) {
folderId.value = folders.value[0]?.id || 'all'
}
}
async function loadLibrary() {
applyLibrary(await $fetch('/api/library?light=1'))
}
function folderChipClass(id: string) {
if (folderId.value === id) return 'border-amber-300 bg-amber-400/10 text-amber-100'
if (dropFolderId.value === id) return 'border-amber-300/70 bg-amber-400/10 text-amber-100'
return 'border-white/10 text-zinc-400'
}
function cardClass(entry: LibraryEntry) {
if (focusedKey.value === entry.key) return 'border-amber-300 ring-2 ring-amber-300/70'
if (selectedKeys.value.includes(entry.key)) return 'border-amber-300/40 bg-amber-400/5'
return 'border-white/10'
}
function thumbHidden(entry: LibraryEntry) {
if (entry.kind === 'clip') return Boolean(entry.group?.head.hideThumbnail)
return Boolean(entry.stillGroup?.head.hideInput)
}
function thumbSrc(entry: LibraryEntry) {
if (entry.kind === 'clip' && entry.group?.head.hasThumbnail) return `/api/library/clips/${entry.group.head.id}/thumb`
if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.head.id}?thumb=1`
return ''
}
function partCount(entry: LibraryEntry) {
return entry.kind === 'clip' ? (entry.group?.parts.length || 1) : (entry.stillGroup?.parts.length || 1)
}
function cardMeta(entry: LibraryEntry) {
if (entry.kind === 'clip' && entry.group) {
return [formatClipDuration(entry.group.head.duration), formatWhen(entry.createdAt)].filter(Boolean).join(' · ')
}
return formatWhen(entry.createdAt)
}
function partThumb(part: LibraryClip | LibraryStill) {
if ('hideThumbnail' in part) return `/api/library/clips/${part.id}/thumb`
return `/api/library/stills/${part.id}?thumb=1`
}
function focusPart(id: string) {
focusedPartId.value = id
}
function onScroll() {
scrollTop.value = scroller.value?.scrollTop || 0
}
function measure() {
const el = scroller.value
if (!el) return
viewportH.value = el.clientHeight || 800
viewportW.value = el.clientWidth || 800
scrollTop.value = el.scrollTop || 0
}
function clearSelection() {
selectedKeys.value = []
lastSelectedKey.value = ''
}
function selectAll() {
selectedKeys.value = entries.value.map(entry => entry.key)
lastSelectedKey.value = selectedKeys.value.at(-1) || ''
}
function onSelectClick(key: string, event: MouseEvent) {
const keys = entries.value.map(entry => entry.key)
if (event.shiftKey && lastSelectedKey.value) {
const start = keys.indexOf(lastSelectedKey.value)
const end = keys.indexOf(key)
if (start >= 0 && end >= 0) {
const [from, to] = start < end ? [start, end] : [end, start]
selectedKeys.value = [...new Set([...selectedKeys.value, ...keys.slice(from, to + 1)])]
lastSelectedKey.value = key
return
}
}
if (selectedKeys.value.includes(key)) selectedKeys.value = selectedKeys.value.filter(item => item !== key)
else selectedKeys.value = [...selectedKeys.value, key]
lastSelectedKey.value = key
}
function onCardClick(entry: LibraryEntry, event: MouseEvent) {
if ((event.target as HTMLElement | null)?.closest('input, select, textarea, a, button, label')) return
if (event.shiftKey || event.ctrlKey || event.metaKey) {
onSelectClick(entry.key, event)
return
}
focusedKey.value = entry.key
focusedPartId.value = entry.kind === 'clip' ? (entry.group?.head.id || '') : (entry.stillGroup?.head.id || '')
cancelRename()
}
function onDragStart(entry: LibraryEntry, event: DragEvent) {
const keys = selectedKeys.value.includes(entry.key) ? selectedKeys.value : [entry.key]
draggingKeys.value = keys
event.dataTransfer?.setData(DRAG_MIME, JSON.stringify(keys))
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'
}
function onFolderDragOver(id: string, event: DragEvent) {
event.dataTransfer && (event.dataTransfer.dropEffect = 'move')
dropFolderId.value = id
}
function onFolderDragLeave(id: string) {
if (dropFolderId.value === id) dropFolderId.value = ''
}
async function onFolderDrop(id: string, event: DragEvent) {
dropFolderId.value = ''
let keys = draggingKeys.value
try {
const raw = event.dataTransfer?.getData(DRAG_MIME)
if (raw) keys = JSON.parse(raw)
} catch { /* keep draggingKeys */ }
draggingKeys.value = []
await moveKeys(keys, id)
}
function selectedEntries(keys = selectedKeys.value) {
const chosen = new Set(keys)
return entries.value.filter(entry => chosen.has(entry.key))
}
async function moveKeys(keys: string[], destId: string) {
const items = selectedEntries(keys).filter((entry) => {
const current = entry.kind === 'clip' ? entry.group?.head.folderId : entry.stillGroup?.head.folderId
return current && current !== destId
})
if (!items.length) return
const dest = folders.value.find(folder => folder.id === destId)
const results = await Promise.allSettled(items.map((entry) => {
if (entry.kind === 'clip') {
return $fetch(`/api/library/clips/${entry.group!.head.id}`, { method: 'PATCH', body: { folderId: destId, family: true } })
}
return $fetch(`/api/library/stills/${entry.stillGroup!.head.id}`, { method: 'PATCH', body: { folderId: destId, family: true } })
}))
await loadLibrary()
const failed = results.filter(result => result.status === 'rejected').length
clearSelection()
if (failed) toast(`${items.length - failed} moved. ${failed} could not be moved.`)
else toast(`Moved ${items.length} to ${dest?.name || 'folder'}`)
}
async function onMoveSelected(event: Event) {
const select = event.target as HTMLSelectElement
const destId = select.value
select.value = ''
if (destId) await moveKeys(selectedKeys.value, destId)
}
async function onMoveFocused(event: Event) {
const select = event.target as HTMLSelectElement
const destId = select.value
select.value = ''
if (destId && focusedKey.value) await moveKeys([focusedKey.value], destId)
}
function openInStudio(entry: LibraryEntry) {
if (entry.kind === 'clip' && entry.group) {
navigateTo({ path: '/', query: { clip: focusedPartId.value || entry.group.head.id } })
return
}
if (entry.stillGroup) {
navigateTo({ path: '/', query: { still: focusedPartId.value || entry.stillGroup.head.id } })
}
}
function useInStudio(entry: LibraryEntry) {
if (entry.kind === 'clip' && entry.group) {
navigateTo({ path: '/', query: { clip: focusedPartId.value || entry.group.head.id, use: '1' } })
return
}
if (entry.stillGroup) {
navigateTo({ path: '/', query: { still: focusedPartId.value || entry.stillGroup.head.id, use: '1' } })
}
}
function startRename() {
if (!focused.value) return
renaming.value = true
renameDraft.value = focused.value.name
nextTick(() => {
renameInput.value?.focus()
renameInput.value?.select()
})
}
function cancelRename() {
renaming.value = false
renameDraft.value = ''
}
async function saveRename() {
const entry = focused.value
if (!renaming.value || !entry) return
const name = renameDraft.value.trim().slice(0, 80)
if (!name) {
toast('A name is required')
return
}
if (name === entry.name) {
cancelRename()
return
}
try {
if (entry.kind === 'clip' && entry.group) {
applyLibrary(await $fetch(`/api/library/clips/${entry.group.head.id}`, { method: 'PATCH', body: { name, family: true } }))
} else if (entry.stillGroup) {
applyLibrary(await $fetch(`/api/library/stills/${entry.stillGroup.head.id}`, { method: 'PATCH', body: { name, family: true } }))
}
cancelRename()
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not rename')
}
}
async function togglePreviewHidden() {
const entry = focused.value
const item = focusedPreviewItem.value
if (!entry || !item || hideBusy.value) return
hideBusy.value = true
const next = !previewHidden.value
try {
if (entry.kind === 'clip') {
applyLibrary(await $fetch(`/api/library/clips/${item.id}`, { method: 'PATCH', body: { hideThumbnail: next } }))
} else {
applyLibrary(await $fetch(`/api/library/stills/${item.id}`, { method: 'PATCH', body: { hideInput: next } }))
}
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not update visibility')
} finally {
hideBusy.value = false
}
}
function askRemoveFocused() {
if (!focused.value) return
askRemoveKeys([focused.value.key])
}
function askRemoveSelected() {
askRemoveKeys(selectedKeys.value)
}
function askRemoveKeys(keys: string[]) {
const items = selectedEntries(keys)
if (!items.length) return
const total = items.reduce((count, entry) => count + partCount(entry), 0)
pendingDelete.value = {
title: items.length === 1 ? 'Delete this item?' : 'Delete these items?',
name: items.slice(0, 3).map(entry => entry.name).join(', ') + (items.length > 3 ? ` and ${items.length - 3} more` : ''),
detail: `${items.length} collection${items.length === 1 ? '' : 's'} · ${total} part${total === 1 ? '' : 's'}`,
consequence: 'This removes them from the library.',
keys
}
}
async function confirmDelete() {
const pending = pendingDelete.value
if (!pending || deleteBusy.value) return
pendingDelete.value = null
deleteBusy.value = true
try {
const items = selectedEntries(pending.keys)
await Promise.allSettled(items.map((entry) => {
if (entry.kind === 'clip') {
return $fetch(`/api/library/clips/${entry.group!.head.id}`, { method: 'DELETE', query: { family: '1' } })
}
return $fetch(`/api/library/stills/${entry.stillGroup!.head.id}`, { method: 'DELETE', query: { family: '1' } })
}))
if (pending.keys.includes(focusedKey.value)) {
focusedKey.value = ''
focusedPartId.value = ''
}
clearSelection()
await loadLibrary()
} finally {
deleteBusy.value = false
}
}
async function unlockFolder() {
if (!browseFolder.value) return
try {
applyLibrary(await $fetch('/api/library/unlock', { method: 'POST', body: { password: unlockPassword.value, folderId: browseFolder.value.id } }))
unlockPassword.value = ''
} catch (error: any) {
toast(error?.data?.statusMessage || 'Unlock failed')
}
}
async function lockFolder() {
if (!browseFolder.value) return
try {
applyLibrary(await $fetch('/api/library/lock', { method: 'POST', body: { folderId: browseFolder.value.id } }))
} catch {
await loadLibrary()
}
}
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape') {
if (pendingDelete.value) pendingDelete.value = null
else if (renaming.value) cancelRename()
else clearSelection()
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a' && !renaming.value) {
event.preventDefault()
selectAll()
}
if (event.key === 'Delete' && selectedKeys.value.length && !renaming.value) askRemoveSelected()
}
watch([folderId, filter], () => {
clearSelection()
if (scroller.value) scroller.value.scrollTop = 0
})
watch(search, () => {
if (scroller.value) scroller.value.scrollTop = 0
})
watch(entries, async () => {
if (focusedKey.value && !entries.value.some(entry => entry.key === focusedKey.value)) {
focusedKey.value = ''
focusedPartId.value = ''
}
await nextTick()
measure()
if (scroller.value && resizeObserver && !observedScroller) {
resizeObserver.observe(scroller.value)
observedScroller = scroller.value
}
})
let resizeObserver: ResizeObserver | null = null
let observedScroller: HTMLElement | null = null
onMounted(async () => {
window.addEventListener('keydown', onKey)
const [me] = await Promise.all([
$fetch<{ user?: { name?: string; email?: string }; authEnabled?: boolean; authMode?: string; instanceName?: string }>('/api/auth/me').catch(() => ({ user: null })),
loadLibrary().catch(() => applyLibrary({ folders: [], clips: [], stills: [] }))
])
userName.value = me.user?.name || me.user?.email || ''
authEnabled.value = Boolean(me.authEnabled)
authMode.value = me.authMode || ''
instanceName.value = me.instanceName || instanceName.value
await nextTick()
measure()
resizeObserver = new ResizeObserver(() => measure())
if (scroller.value) {
resizeObserver.observe(scroller.value)
observedScroller = scroller.value
}
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKey)
resizeObserver?.disconnect()
})
</script>