Replace the under-stage filmstrip with a Library modal and stage eye.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-28 20:11:14 -05:00
co-authored by Cursor
parent e06afe4a17
commit 7242d0faef
5 changed files with 601 additions and 221 deletions
+398
View File
@@ -0,0 +1,398 @@
<template>
<Teleport to="body">
<div
v-if="open"
class="lib-modal-root"
role="presentation"
@keydown.esc.prevent="close"
>
<div class="lib-modal-backdrop" @click="close" />
<section
ref="dialogEl"
class="lib-modal"
role="dialog"
aria-modal="true"
aria-labelledby="lib-modal-title"
tabindex="-1"
@keydown.tab="onTab"
>
<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>
<button type="button" class="lib-modal-x" aria-label="Close" @click="close">×</button>
</div>
</header>
<div class="lib-modal-toolbar">
<input
v-model="query"
type="search"
class="lib-modal-search"
placeholder="Name or prompt"
aria-label="Search library"
>
<div class="lib-modal-filters" role="group" aria-label="Type">
<button
v-for="opt in typeOptions"
:key="opt.id"
type="button"
class="lib-modal-chip"
:data-on="filter === opt.id ? 'true' : undefined"
@click="filter = opt.id"
>{{ opt.label }}</button>
</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)"
>
<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>
<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>
<button type="button" class="primary" @click="useAndClose">Use and close</button>
</div>
<LibrarySettingsDetails :rows="detailRows" empty="No generator settings were saved with this item." />
</aside>
</div>
</section>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { clipSettingsRows, stillSettingsRows, trackSettingsRows } from '~/utils/libraryGeneration'
import { assetLabel } from '~/shared/studio2/viewer.mjs'
const props = defineProps<{
open: boolean
assets: any[]
selected?: any
folderId?: string
isHidden?: (asset: any) => boolean
}>()
const emit = defineEmits<{
close: []
select: [asset: any]
'use-as-photo': [asset: any]
'copy-prompt': [asset: any]
load: [asset: any]
delete: [asset: any]
}>()
const query = ref('')
const filter = ref<'all' | 'images' | 'videos' | 'music'>('all')
const dialogEl = ref<HTMLElement | null>(null)
const typeOptions = [
{ 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 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 detailRows = computed(() => {
const item = props.selected
if (!item) return []
if (item.kind === 'clip') return clipSettingsRows(item)
if (item.kind === 'track') return trackSettingsRows(item)
return stillSettingsRows(item)
})
function close() { emit('close') }
function useAndClose() {
if (props.selected) emit('select', props.selected)
close()
}
function onTab(e: KeyboardEvent) {
const root = dialogEl.value
if (!root) return
const focusable = [...root.querySelectorAll<HTMLElement>(
'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
)].filter(el => el.offsetParent !== null || el === document.activeElement)
if (!focusable.length) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
watch(() => props.open, (on) => {
if (!import.meta.client) return
if (on) {
document.body.style.overflow = 'hidden'
nextTick(() => dialogEl.value?.focus())
} else {
document.body.style.overflow = ''
}
})
onBeforeUnmount(() => {
if (import.meta.client) document.body.style.overflow = ''
})
</script>
<style scoped>
.lib-modal-root {
position: fixed;
inset: 0;
z-index: 80;
display: flex;
align-items: stretch;
justify-content: center;
}
.lib-modal-backdrop {
position: absolute;
inset: 0;
background: #000a;
}
.lib-modal {
position: relative;
z-index: 1;
display: flex;
width: min(1100px, 100%);
max-height: min(92vh, 900px);
margin: auto;
flex-direction: column;
overflow: hidden;
border: 1px solid #2a2a2e;
border-radius: 12px;
background: #141416;
color: #f3f1ea;
outline: none;
}
@media (max-width: 767px) {
.lib-modal-root { align-items: flex-end; }
.lib-modal {
width: 100%;
max-height: 100dvh;
height: 100dvh;
margin: 0;
border-radius: 16px 16px 0 0;
}
}
.lib-modal-head {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid #2a2a2e;
padding: 12px 16px;
}
.lib-modal-head h2 {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
}
.lib-modal-head-actions {
display: flex;
align-items: center;
gap: 10px;
}
.lib-modal-full {
color: #f5a524;
font-size: 0.85rem;
text-decoration: none;
}
.lib-modal-x {
display: grid;
width: 40px;
height: 40px;
place-items: center;
border: 1px solid #2a2a2e;
border-radius: 8px;
background: transparent;
color: inherit;
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
}
.lib-modal-toolbar {
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
gap: 10px;
align-items: center;
border-bottom: 1px solid #2a2a2e;
padding: 10px 16px;
}
.lib-modal-search {
min-width: 12rem;
flex: 1;
border: 1px solid #2a2a2e;
border-radius: 8px;
background: #0b0b0d;
padding: 8px 10px;
color: inherit;
font: inherit;
}
.lib-modal-filters { display: inline-flex; gap: 4px; }
.lib-modal-chip {
border: 1px solid #2a2a2e;
border-radius: 999px;
background: transparent;
color: #9a9588;
padding: 6px 10px;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.lib-modal-chip[data-on='true'] {
border-color: #f5a524;
background: #f5a52422;
color: #f5a524;
}
.lib-modal-body {
display: flex;
min-height: 0;
flex: 1;
gap: 0;
}
@media (max-width: 767px) {
.lib-modal-body { flex-direction: column; }
}
.lib-modal-grid {
display: grid;
flex: 1;
min-width: 0;
grid-template-columns: repeat(auto-fill, minmax(120px, 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;
font-size: 0.9rem;
}
.lib-modal-detail {
flex: 0 0 min(320px, 38%);
min-width: 0;
overflow: auto;
border-left: 1px solid #2a2a2e;
padding: 14px 16px;
}
@media (max-width: 767px) {
.lib-modal-detail {
flex: 0 0 auto;
max-height: 40%;
border-left: 0;
border-top: 1px solid #2a2a2e;
}
}
.lib-modal-detail-name {
margin: 0 0 10px;
font-weight: 600;
}
.lib-modal-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 14px;
}
.lib-modal-actions button {
border: 1px solid #2a2a2e;
border-radius: 6px;
background: transparent;
color: inherit;
padding: 6px 10px;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.lib-modal-actions button.primary {
border-color: #f5a524;
background: #f5a524;
color: #1a1204;
font-weight: 700;
}
.lib-modal-actions button.danger { color: #fca5a5; }
</style>