@@ -0,0 +1,802 @@
< 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 >
< label class = "absolute bottom-2 left-2" @click.stop >
< input
type = "checkbox"
class = "h-4 w-4 rounded border-white/20 bg-zinc-950 accent-amber-400"
:checked = "selectedKeys.includes(entry.key)"
@click.prevent ="onSelectClick(entry.key, $event)"
>
< / label >
< / 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 = "overflow-hidden rounded-2xl bg-zinc-900" >
< span v-if = "thumbHidden(focused)" 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' && focused.stillGroup"
:src = "`/api/library/stills/${focused.stillGroup.head.id}`"
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"
>
< / 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'
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 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 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 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' )
}
}
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 >