Files
aigen/components/studio2/LibraryModal.vue
T

399 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>