Replace the under-stage filmstrip with a Library modal and stage eye.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user