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">
<h2 id="lib-modal-title">Library</h2>
<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>
</div>
</header>
@@ -33,7 +37,7 @@
>
<div class="lib-modal-filters" role="group" aria-label="Type">
<button
v-for="opt in typeOptions"
v-for="opt in filterOptions"
:key="opt.id"
type="button"
class="lib-modal-chip"
@@ -43,33 +47,56 @@
</div>
</div>
<div class="lib-modal-body">
<div class="lib-modal-grid" aria-label="Folder assets">
<button
v-for="item in filtered"
:key="item.id"
type="button"
class="lib-modal-tile"
:data-active="selectedId === item.id ? 'true' : undefined"
@click="$emit('select', item)"
<div ref="gridScrollEl" class="lib-modal-grid" aria-label="Folder assets">
<p v-if="loading && !entries.length" class="lib-modal-empty">Loading library…</p>
<p v-else-if="loadError" class="lib-modal-empty">{{ loadError }}</p>
<p v-else-if="!entries.length" class="lib-modal-empty">No items in this folder yet.</p>
<article
v-for="entry in entries"
:key="entry.key"
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>
<span v-else-if="item.kind === 'track'" class="lib-modal-hid">Audio</span>
<img
v-else
:src="item.kind === 'clip' ? `/api/library/clips/${item.id}/thumb` : stillUrl(item.id)"
alt=""
<div class="relative aspect-[4/5] bg-zinc-900">
<div
v-if="entry.kind === 'track'"
class="flex h-full w-full items-end gap-0.5 bg-zinc-900 px-4 py-8"
aria-hidden="true"
>
<span class="lib-modal-caption">{{ label(item) }}</span>
</button>
<p v-if="!filtered.length" class="lib-modal-empty">No items in this folder yet.</p>
<span
v-for="n in 16"
:key="n"
class="flex-1 rounded-full bg-amber-400/70"
:style="{ height: `${20 + ((n * 17) % 60)}%` }"
/>
</div>
<aside v-if="selected" class="lib-modal-detail">
<p class="lib-modal-detail-name">{{ label(selected) }}</p>
<div v-if="selected.kind !== 'track'" class="lib-modal-actions">
<button type="button" @click="$emit('use-as-photo', selected)">Use as photo</button>
<button type="button" @click="$emit('copy-prompt', selected)">Copy prompt</button>
<button type="button" @click="$emit('load', selected)">Load</button>
<button type="button" class="danger" @click="$emit('delete', selected)">Delete</button>
<img
v-else-if="thumbSrc(entry)"
:src="thumbSrc(entry)"
alt=""
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' : 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>
</div>
<LibrarySettingsDetails :rows="detailRows" empty="No generator settings were saved with this item." />
@@ -81,12 +108,23 @@
</template>
<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 { assetLabel } from '~/shared/studio2/viewer.mjs'
const props = defineProps<{
open: boolean
assets: any[]
assets?: any[]
selected?: any
folderId?: string
isHidden?: (asset: any) => boolean
@@ -101,55 +139,137 @@ const emit = defineEmits<{
delete: [asset: any]
}>()
const query = ref('')
const filter = ref<'all' | 'images' | 'videos' | 'music'>('all')
const dialogEl = ref<HTMLElement | null>(null)
const typeOptions = [
const filterOptions = [
{ id: 'all' as const, label: 'All' },
{ id: 'images' as const, label: 'Images' },
{ id: 'videos' as const, label: 'Videos' },
{ id: 'music' as const, label: 'Music' }
]
const selectedId = computed(() => props.selected?.id || '')
const stillUrl = (id: string) => `/api/library/stills/${id}`
const label = (item: any) => assetLabel(item)
const query = ref('')
const filter = ref<LibraryFilter>('all')
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 q = query.value.trim().toLowerCase()
let list = [...(props.assets || [])]
if (filter.value === 'images') list = list.filter(a => a.kind === 'still')
if (filter.value === 'videos') list = list.filter(a => a.kind === 'clip')
if (filter.value === 'music') list = list.filter(a => a.kind === 'track')
if (q) {
list = list.filter(a => {
const hay = [
a.name, a.filename, a.prompt, a.promptRaw,
a.studio2?.prompt, a.studio2?.promptRaw, a.studio2?.compiledPrompt,
a.studio2?.promptSections?.action
].filter(Boolean).join(' ').toLowerCase()
return hay.includes(q)
})
}
// Newest first
return list.sort((a, b) => (b.createdAt || b.studio2?.savedAt || 0) - (a.createdAt || a.studio2?.savedAt || 0))
const activeFolderId = computed(() => props.folderId || folders.value[0]?.id || 'all')
const entries = computed(() => buildLibraryEntries({
clips: clips.value,
stills: stills.value,
tracks: tracks.value,
folderId: activeFolderId.value,
filter: filter.value,
search: query.value,
sort: 'date-desc'
}))
const focusedKey = computed(() => {
const id = props.selected?.id
if (!id) return ''
const hit = entries.value.find(entry => assetFromEntry(entry)?.id === id)
return hit?.key || ''
})
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 item = props.selected
const item = detailAsset.value
if (!item) return []
if (item.kind === 'clip') return clipSettingsRows(item)
if (item.kind === 'track') return trackSettingsRows(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 useAndClose() {
if (props.selected) emit('select', props.selected)
if (detailAsset.value) emit('select', detailAsset.value)
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) {
const root = dialogEl.value
if (!root) return
@@ -172,7 +292,11 @@ watch(() => props.open, (on) => {
if (!import.meta.client) return
if (on) {
document.body.style.overflow = 'hidden'
nextTick(() => dialogEl.value?.focus())
void fetchLibrary(true)
nextTick(() => {
dialogEl.value?.focus()
if (gridScrollEl.value) gridScrollEl.value.scrollTop = 0
})
} else {
document.body.style.overflow = ''
}
@@ -203,6 +327,7 @@ onBeforeUnmount(() => {
display: flex;
width: min(1100px, 100%);
max-height: min(92vh, 900px);
height: min(92vh, 900px);
margin: auto;
flex-direction: column;
overflow: hidden;
@@ -298,7 +423,7 @@ onBeforeUnmount(() => {
display: flex;
min-height: 0;
flex: 1;
gap: 0;
overflow: hidden;
}
@media (max-width: 767px) {
.lib-modal-body { flex-direction: column; }
@@ -307,47 +432,14 @@ onBeforeUnmount(() => {
display: grid;
flex: 1;
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;
align-content: start;
overflow: auto;
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 {
grid-column: 1 / -1;
color: #9a9588;
@@ -356,6 +448,7 @@ onBeforeUnmount(() => {
.lib-modal-detail {
flex: 0 0 min(320px, 38%);
min-width: 0;
min-height: 0;
overflow: auto;
border-left: 1px solid #2a2a2e;
padding: 14px 16px;