Load Library modal thumbs with the same tiles and fetch as /library.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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=""
|
||||
>
|
||||
<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>
|
||||
<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
|
||||
v-for="n in 16"
|
||||
:key="n"
|
||||
class="flex-1 rounded-full bg-amber-400/70"
|
||||
:style="{ height: `${20 + ((n * 17) % 60)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<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="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>
|
||||
<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;
|
||||
|
||||
Reference in New Issue
Block a user