Load Library modal thumbs with the same tiles and fetch as /library.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-28 20:25:16 -05:00
co-authored by Cursor
parent 5b0d7370ed
commit a33cadb6c2
+185 -92
View File
@@ -19,7 +19,11 @@
<header class="lib-modal-head"> <header class="lib-modal-head">
<h2 id="lib-modal-title">Library</h2> <h2 id="lib-modal-title">Library</h2>
<div class="lib-modal-head-actions"> <div class="lib-modal-head-actions">
<NuxtLink class="lib-modal-full" :to="{ path: '/library', query: folderId ? { folder: folderId } : {} }" @click="close">Full library</NuxtLink> <NuxtLink
class="lib-modal-full"
:to="{ path: '/library', query: activeFolderId && activeFolderId !== 'all' ? { folder: activeFolderId } : {} }"
@click="close"
>Full library</NuxtLink>
<button type="button" class="lib-modal-x" aria-label="Close" @click="close">×</button> <button type="button" class="lib-modal-x" aria-label="Close" @click="close">×</button>
</div> </div>
</header> </header>
@@ -33,7 +37,7 @@
> >
<div class="lib-modal-filters" role="group" aria-label="Type"> <div class="lib-modal-filters" role="group" aria-label="Type">
<button <button
v-for="opt in typeOptions" v-for="opt in filterOptions"
:key="opt.id" :key="opt.id"
type="button" type="button"
class="lib-modal-chip" class="lib-modal-chip"
@@ -43,33 +47,56 @@
</div> </div>
</div> </div>
<div class="lib-modal-body"> <div class="lib-modal-body">
<div class="lib-modal-grid" aria-label="Folder assets"> <div ref="gridScrollEl" class="lib-modal-grid" aria-label="Folder assets">
<button <p v-if="loading && !entries.length" class="lib-modal-empty">Loading library…</p>
v-for="item in filtered" <p v-else-if="loadError" class="lib-modal-empty">{{ loadError }}</p>
:key="item.id" <p v-else-if="!entries.length" class="lib-modal-empty">No items in this folder yet.</p>
type="button" <article
class="lib-modal-tile" v-for="entry in entries"
:data-active="selectedId === item.id ? 'true' : undefined" :key="entry.key"
@click="$emit('select', item)" class="library-thumb group relative cursor-pointer overflow-hidden rounded-2xl border bg-zinc-950"
:class="entry.key === focusedKey ? 'border-amber-300 ring-2 ring-amber-300/70' : 'border-white/10'"
@click="pickEntry(entry)"
> >
<span v-if="isHidden?.(item)" class="lib-modal-hid">Hidden</span> <div class="relative aspect-[4/5] bg-zinc-900">
<span v-else-if="item.kind === 'track'" class="lib-modal-hid">Audio</span> <div
<img v-if="entry.kind === 'track'"
v-else class="flex h-full w-full items-end gap-0.5 bg-zinc-900 px-4 py-8"
:src="item.kind === 'clip' ? `/api/library/clips/${item.id}/thumb` : stillUrl(item.id)" aria-hidden="true"
alt=""
> >
<span class="lib-modal-caption">{{ label(item) }}</span> <span
</button> v-for="n in 16"
<p v-if="!filtered.length" class="lib-modal-empty">No items in this folder yet.</p> :key="n"
class="flex-1 rounded-full bg-amber-400/70"
:style="{ height: `${20 + ((n * 17) % 60)}%` }"
/>
</div> </div>
<aside v-if="selected" class="lib-modal-detail"> <img
<p class="lib-modal-detail-name">{{ label(selected) }}</p> v-else-if="thumbSrc(entry)"
<div v-if="selected.kind !== 'track'" class="lib-modal-actions"> :src="thumbSrc(entry)"
<button type="button" @click="$emit('use-as-photo', selected)">Use as photo</button> alt=""
<button type="button" @click="$emit('copy-prompt', selected)">Copy prompt</button> decoding="async"
<button type="button" @click="$emit('load', selected)">Load</button> draggable="false"
<button type="button" class="danger" @click="$emit('delete', selected)">Delete</button> 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' : entry.kind === 'track' ? 'Music' : 'Image' }}
</span>
</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>
<aside v-if="detailAsset" class="lib-modal-detail">
<p class="lib-modal-detail-name">{{ detailLabel }}</p>
<div v-if="detailAsset.kind !== 'track'" class="lib-modal-actions">
<button type="button" @click="$emit('use-as-photo', detailAsset)">Use as photo</button>
<button type="button" @click="$emit('copy-prompt', detailAsset)">Copy prompt</button>
<button type="button" @click="$emit('load', detailAsset)">Load</button>
<button type="button" class="danger" @click="onDeleteClick">Delete</button>
<button type="button" class="primary" @click="useAndClose">Use and close</button> <button type="button" class="primary" @click="useAndClose">Use and close</button>
</div> </div>
<LibrarySettingsDetails :rows="detailRows" empty="No generator settings were saved with this item." /> <LibrarySettingsDetails :rows="detailRows" empty="No generator settings were saved with this item." />
@@ -81,12 +108,23 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {
buildLibraryEntries,
formatClipDuration,
formatWhen,
type LibraryClip,
type LibraryEntry,
type LibraryFilter,
type LibraryFolder,
type LibraryStill,
type LibraryTrack
} from '~/utils/libraryBrowse'
import { clipSettingsRows, stillSettingsRows, trackSettingsRows } from '~/utils/libraryGeneration' import { clipSettingsRows, stillSettingsRows, trackSettingsRows } from '~/utils/libraryGeneration'
import { assetLabel } from '~/shared/studio2/viewer.mjs' import { assetLabel } from '~/shared/studio2/viewer.mjs'
const props = defineProps<{ const props = defineProps<{
open: boolean open: boolean
assets: any[] assets?: any[]
selected?: any selected?: any
folderId?: string folderId?: string
isHidden?: (asset: any) => boolean isHidden?: (asset: any) => boolean
@@ -101,55 +139,137 @@ const emit = defineEmits<{
delete: [asset: any] delete: [asset: any]
}>() }>()
const query = ref('') const filterOptions = [
const filter = ref<'all' | 'images' | 'videos' | 'music'>('all')
const dialogEl = ref<HTMLElement | null>(null)
const typeOptions = [
{ id: 'all' as const, label: 'All' }, { id: 'all' as const, label: 'All' },
{ id: 'images' as const, label: 'Images' }, { id: 'images' as const, label: 'Images' },
{ id: 'videos' as const, label: 'Videos' }, { id: 'videos' as const, label: 'Videos' },
{ id: 'music' as const, label: 'Music' } { id: 'music' as const, label: 'Music' }
] ]
const selectedId = computed(() => props.selected?.id || '') const query = ref('')
const stillUrl = (id: string) => `/api/library/stills/${id}` const filter = ref<LibraryFilter>('all')
const label = (item: any) => assetLabel(item) const dialogEl = ref<HTMLElement | null>(null)
const gridScrollEl = ref<HTMLElement | null>(null)
const loading = ref(false)
const loadError = ref('')
const folders = ref<LibraryFolder[]>([])
const clips = ref<LibraryClip[]>([])
const stills = ref<LibraryStill[]>([])
const tracks = ref<LibraryTrack[]>([])
const fetchedAt = ref(0)
const filtered = computed(() => { const activeFolderId = computed(() => props.folderId || folders.value[0]?.id || 'all')
const q = query.value.trim().toLowerCase()
let list = [...(props.assets || [])] const entries = computed(() => buildLibraryEntries({
if (filter.value === 'images') list = list.filter(a => a.kind === 'still') clips: clips.value,
if (filter.value === 'videos') list = list.filter(a => a.kind === 'clip') stills: stills.value,
if (filter.value === 'music') list = list.filter(a => a.kind === 'track') tracks: tracks.value,
if (q) { folderId: activeFolderId.value,
list = list.filter(a => { filter: filter.value,
const hay = [ search: query.value,
a.name, a.filename, a.prompt, a.promptRaw, sort: 'date-desc'
a.studio2?.prompt, a.studio2?.promptRaw, a.studio2?.compiledPrompt, }))
a.studio2?.promptSections?.action
].filter(Boolean).join(' ').toLowerCase() const focusedKey = computed(() => {
return hay.includes(q) const id = props.selected?.id
}) if (!id) return ''
} const hit = entries.value.find(entry => assetFromEntry(entry)?.id === id)
// Newest first return hit?.key || ''
return list.sort((a, b) => (b.createdAt || b.studio2?.savedAt || 0) - (a.createdAt || a.studio2?.savedAt || 0))
}) })
const detailAsset = computed(() => {
if (props.selected) return props.selected
const first = entries.value[0]
return first ? assetFromEntry(first) : null
})
const detailLabel = computed(() => assetLabel(detailAsset.value))
const detailRows = computed(() => { const detailRows = computed(() => {
const item = props.selected const item = detailAsset.value
if (!item) return [] if (!item) return []
if (item.kind === 'clip') return clipSettingsRows(item) if (item.kind === 'clip') return clipSettingsRows(item)
if (item.kind === 'track') return trackSettingsRows(item) if (item.kind === 'track') return trackSettingsRows(item)
return stillSettingsRows(item) return stillSettingsRows(item)
}) })
function thumbSrc(entry: LibraryEntry) {
if (entry.kind === 'clip' && entry.group) return `/api/library/clips/${entry.group.head.id}/thumb`
if (entry.kind === 'track') return ''
if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.head.id}?thumb=1`
return ''
}
function cardMeta(entry: LibraryEntry) {
if (entry.kind === 'clip' && entry.group) {
return [formatClipDuration(entry.group.head.duration), formatWhen(entry.createdAt)].filter(Boolean).join(' · ')
}
if (entry.kind === 'track' && entry.track) {
return [formatClipDuration(entry.track.duration), formatWhen(entry.createdAt)].filter(Boolean).join(' · ')
}
return formatWhen(entry.createdAt)
}
function assetFromEntry(entry: LibraryEntry) {
if (entry.kind === 'clip' && entry.group) return { ...entry.group.head, kind: 'clip' as const }
if (entry.kind === 'track' && entry.track) return { ...entry.track, kind: 'track' as const }
if (entry.stillGroup) return { ...entry.stillGroup.head, kind: 'still' as const }
return null
}
function pickEntry(entry: LibraryEntry) {
const asset = assetFromEntry(entry)
if (!asset) return
const fromFamily = (props.assets || []).find((a: any) => a.id === asset.id)
emit('select', fromFamily ? { ...asset, ...fromFamily, kind: asset.kind } : asset)
}
function close() { emit('close') } function close() { emit('close') }
function useAndClose() { function useAndClose() {
if (props.selected) emit('select', props.selected) if (detailAsset.value) emit('select', detailAsset.value)
close() close()
} }
function removeLocalAsset(asset: any) {
if (!asset?.id) return
stills.value = stills.value.filter(s => s.id !== asset.id)
clips.value = clips.value.filter(c => c.id !== asset.id)
tracks.value = tracks.value.filter(t => t.id !== asset.id)
}
function onDeleteClick() {
const asset = detailAsset.value
if (!asset) return
emit('delete', asset)
removeLocalAsset(asset)
window.setTimeout(() => { void fetchLibrary(true) }, 500)
}
function applyLibrary(data: any) {
folders.value = data?.folders || []
clips.value = data?.clips || []
stills.value = data?.stills || []
tracks.value = data?.tracks || []
fetchedAt.value = Date.now()
}
async function fetchLibrary(force = false) {
if (loading.value) return
const stale = !fetchedAt.value || Date.now() - fetchedAt.value > 15_000
const empty = !stills.value.length && !clips.value.length && !tracks.value.length
if (!force && !empty && !stale) return
loading.value = true
loadError.value = ''
try {
applyLibrary(await $fetch('/api/library?light=1'))
} catch (e: any) {
loadError.value = e?.data?.statusMessage || e?.message || 'Could not load library'
} finally {
loading.value = false
}
}
function onTab(e: KeyboardEvent) { function onTab(e: KeyboardEvent) {
const root = dialogEl.value const root = dialogEl.value
if (!root) return if (!root) return
@@ -172,7 +292,11 @@ watch(() => props.open, (on) => {
if (!import.meta.client) return if (!import.meta.client) return
if (on) { if (on) {
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden'
nextTick(() => dialogEl.value?.focus()) void fetchLibrary(true)
nextTick(() => {
dialogEl.value?.focus()
if (gridScrollEl.value) gridScrollEl.value.scrollTop = 0
})
} else { } else {
document.body.style.overflow = '' document.body.style.overflow = ''
} }
@@ -203,6 +327,7 @@ onBeforeUnmount(() => {
display: flex; display: flex;
width: min(1100px, 100%); width: min(1100px, 100%);
max-height: min(92vh, 900px); max-height: min(92vh, 900px);
height: min(92vh, 900px);
margin: auto; margin: auto;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
@@ -298,7 +423,7 @@ onBeforeUnmount(() => {
display: flex; display: flex;
min-height: 0; min-height: 0;
flex: 1; flex: 1;
gap: 0; overflow: hidden;
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.lib-modal-body { flex-direction: column; } .lib-modal-body { flex-direction: column; }
@@ -307,47 +432,14 @@ onBeforeUnmount(() => {
display: grid; display: grid;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); min-height: 0;
height: 100%;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 10px; gap: 10px;
align-content: start; align-content: start;
overflow: auto; overflow: auto;
padding: 14px 16px; padding: 14px 16px;
} }
.lib-modal-tile {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
border: 2px solid transparent;
border-radius: 8px;
background: #222;
padding: 0;
color: inherit;
text-align: left;
cursor: pointer;
}
.lib-modal-tile[data-active='true'] { border-color: #f5a524; }
.lib-modal-tile img {
aspect-ratio: 4 / 3;
width: 100%;
object-fit: cover;
background: #111;
}
.lib-modal-hid {
display: grid;
aspect-ratio: 4 / 3;
place-items: center;
color: #9a9588;
font-size: 12px;
}
.lib-modal-caption {
padding: 6px 8px;
font-size: 11px;
color: #9a9588;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lib-modal-empty { .lib-modal-empty {
grid-column: 1 / -1; grid-column: 1 / -1;
color: #9a9588; color: #9a9588;
@@ -356,6 +448,7 @@ onBeforeUnmount(() => {
.lib-modal-detail { .lib-modal-detail {
flex: 0 0 min(320px, 38%); flex: 0 0 min(320px, 38%);
min-width: 0; min-width: 0;
min-height: 0;
overflow: auto; overflow: auto;
border-left: 1px solid #2a2a2e; border-left: 1px solid #2a2a2e;
padding: 14px 16px; padding: 14px 16px;