Desktop Batch splits only on double blank lines when enabled; library hide actually conceals thumbs and pinned clicks stick; steps/CFG restore per image model; mobile opens a near-fullscreen prompt editor.
702 lines
20 KiB
Vue
702 lines
20 KiB
Vue
<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: 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>
|
||
<div class="lib-modal-toolbar">
|
||
<input
|
||
v-model="query"
|
||
type="search"
|
||
class="lib-modal-search"
|
||
placeholder="Name or prompt"
|
||
aria-label="Search library"
|
||
>
|
||
<button
|
||
type="button"
|
||
class="lib-modal-preview-all"
|
||
@click="$emit('toggle-preview')"
|
||
>{{ previewHidden ? 'Show all previews' : 'Hide all previews' }}</button>
|
||
<div class="lib-modal-filters" role="group" aria-label="Type">
|
||
<button
|
||
v-for="opt in filterOptions"
|
||
: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 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="lib-modal-tile"
|
||
:data-thumb-key="entry.key"
|
||
:data-active="entry.key === focusedKey ? 'true' : undefined"
|
||
@click="pickEntry(entry)"
|
||
>
|
||
<div class="lib-modal-media">
|
||
<div
|
||
v-if="entry.kind === 'track'"
|
||
class="lib-modal-track"
|
||
aria-hidden="true"
|
||
>
|
||
<span
|
||
v-for="n in 16"
|
||
:key="n"
|
||
class="lib-modal-bar"
|
||
:style="{ height: `${20 + ((n * 17) % 60)}%` }"
|
||
/>
|
||
</div>
|
||
<img
|
||
v-else-if="!previewHidden && liveThumbs.has(entry.key) && thumbSrc(entry)"
|
||
:src="thumbSrc(entry)"
|
||
alt=""
|
||
loading="lazy"
|
||
decoding="async"
|
||
draggable="false"
|
||
>
|
||
<span v-else class="lib-modal-fallback">{{ previewHidden ? 'Hidden' : '' }}</span>
|
||
<span class="lib-modal-kind">
|
||
{{ entry.kind === 'clip' ? 'Video' : entry.kind === 'track' ? 'Music' : 'Image' }}
|
||
</span>
|
||
</div>
|
||
<div class="lib-modal-caption">
|
||
<p class="lib-modal-name">{{ entry.name }}</p>
|
||
<p class="lib-modal-meta">{{ 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-image', detailAsset)">Use image</button>
|
||
<button type="button" @click="$emit('use-prompt', detailAsset)">Use prompt</button>
|
||
<button type="button" @click="$emit('copy-prompt', detailAsset)">Copy prompt</button>
|
||
<button type="button" @click="$emit('copy-raw-prompt', detailAsset)">Copy raw prompt</button>
|
||
<button type="button" @click="$emit('load-input', detailAsset)">Load input</button>
|
||
<button type="button" @click="$emit('load-input-raw', detailAsset)">Load input raw</button>
|
||
<button type="button" @click="$emit('toggle-preview')">{{ previewHidden ? 'Show output' : 'Hide output' }}</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." />
|
||
</aside>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</Teleport>
|
||
</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[]
|
||
selected?: any
|
||
folderId?: string
|
||
previewHidden?: boolean
|
||
isHidden?: (asset: any) => boolean
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
close: []
|
||
select: [asset: any]
|
||
'use-image': [asset: any]
|
||
'use-prompt': [asset: any]
|
||
'copy-prompt': [asset: any]
|
||
'copy-raw-prompt': [asset: any]
|
||
'load-input': [asset: any]
|
||
'load-input-raw': [asset: any]
|
||
delete: [asset: any]
|
||
'toggle-preview': []
|
||
}>()
|
||
|
||
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 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 nextCursor = ref<string | null>(null)
|
||
const liveThumbs = ref(new Set<string>())
|
||
let thumbObserver: IntersectionObserver | null = null
|
||
let scrollLoadBound = false
|
||
|
||
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 = 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`
|
||
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 (detailAsset.value) emit('use-image', 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 mergeIds(prev: { id: string }[], next: { id: string }[]) {
|
||
const map = new Map(prev.map(item => [item.id, item]))
|
||
for (const item of next) map.set(item.id, item)
|
||
return [...map.values()]
|
||
}
|
||
|
||
function applyLibraryPage(data: any, replace: boolean) {
|
||
folders.value = data?.folders || folders.value
|
||
if (replace) {
|
||
clips.value = data?.clips || []
|
||
stills.value = data?.stills || []
|
||
tracks.value = data?.tracks || []
|
||
} else {
|
||
clips.value = mergeIds(clips.value, data?.clips || []) as LibraryClip[]
|
||
stills.value = mergeIds(stills.value, data?.stills || []) as LibraryStill[]
|
||
tracks.value = mergeIds(tracks.value, data?.tracks || []) as LibraryTrack[]
|
||
}
|
||
nextCursor.value = data?.nextCursor || null
|
||
fetchedAt.value = Date.now()
|
||
}
|
||
|
||
async function fetchLibraryPage(cursor: string | null) {
|
||
const folder = activeFolderId.value && activeFolderId.value !== 'all' ? activeFolderId.value : ''
|
||
const type = filter.value === 'all' ? 'all' : filter.value === 'images' ? 'images' : filter.value === 'videos' ? 'videos' : 'music'
|
||
const qs = new URLSearchParams({ limit: '50' })
|
||
if (folder) qs.set('folderId', folder)
|
||
if (type !== 'all') qs.set('type', type)
|
||
if (cursor) qs.set('cursor', cursor)
|
||
return await $fetch<any>(`/api/library?${qs.toString()}`)
|
||
}
|
||
|
||
let fetchGen = 0
|
||
|
||
async function fetchLibrary(force = false) {
|
||
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
|
||
const gen = ++fetchGen
|
||
loading.value = true
|
||
loadError.value = ''
|
||
liveThumbs.value = new Set()
|
||
try {
|
||
const first = await fetchLibraryPage(null)
|
||
if (gen !== fetchGen) return
|
||
applyLibraryPage(first, true)
|
||
loading.value = false
|
||
nextTick(() => observeThumbs())
|
||
// Keep fetching until the folder is exhausted — 50 is a page size, not a cap
|
||
let cursor = first?.nextCursor || null
|
||
while (cursor && gen === fetchGen) {
|
||
const more = await fetchLibraryPage(cursor)
|
||
if (gen !== fetchGen) return
|
||
applyLibraryPage(more, false)
|
||
cursor = more?.nextCursor || null
|
||
nextTick(() => observeThumbs())
|
||
}
|
||
} catch (e: any) {
|
||
if (gen !== fetchGen) return
|
||
loadError.value = e?.data?.statusMessage || e?.message || 'Could not load library'
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function observeThumbs() {
|
||
if (!import.meta.client) return
|
||
const root = gridScrollEl.value
|
||
if (!root) return
|
||
if (thumbObserver) thumbObserver.disconnect()
|
||
thumbObserver = new IntersectionObserver((entries) => {
|
||
let changed = false
|
||
const next = new Set(liveThumbs.value)
|
||
for (const entry of entries) {
|
||
if (!entry.isIntersecting) continue
|
||
const key = (entry.target as HTMLElement).dataset.thumbKey
|
||
if (!key || next.has(key)) continue
|
||
next.add(key)
|
||
changed = true
|
||
}
|
||
if (changed) liveThumbs.value = next
|
||
}, { root, rootMargin: '200px 0px', threshold: 0.01 })
|
||
root.querySelectorAll<HTMLElement>('[data-thumb-key]').forEach((el) => thumbObserver!.observe(el))
|
||
}
|
||
|
||
function onGridScroll() {
|
||
const el = gridScrollEl.value
|
||
if (!el || !nextCursor.value || loading.value) return
|
||
if (el.scrollTop + el.clientHeight < el.scrollHeight - 240) return
|
||
void fetchMore()
|
||
}
|
||
|
||
async function fetchMore() {
|
||
if (!nextCursor.value || loading.value) return
|
||
loading.value = true
|
||
try {
|
||
const more = await fetchLibraryPage(nextCursor.value)
|
||
applyLibraryPage(more, false)
|
||
nextTick(() => observeThumbs())
|
||
} catch { /* keep what we have */ }
|
||
finally { loading.value = false }
|
||
}
|
||
|
||
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'
|
||
void fetchLibrary(true)
|
||
nextTick(() => {
|
||
dialogEl.value?.focus()
|
||
if (gridScrollEl.value) {
|
||
gridScrollEl.value.scrollTop = 0
|
||
if (!scrollLoadBound) {
|
||
gridScrollEl.value.addEventListener('scroll', onGridScroll, { passive: true })
|
||
scrollLoadBound = true
|
||
}
|
||
observeThumbs()
|
||
}
|
||
})
|
||
} else {
|
||
document.body.style.overflow = ''
|
||
thumbObserver?.disconnect()
|
||
thumbObserver = null
|
||
}
|
||
})
|
||
|
||
watch(entries, () => nextTick(() => observeThumbs()))
|
||
|
||
watch([filter, activeFolderId], () => {
|
||
if (!props.open) return
|
||
void fetchLibrary(true)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
if (import.meta.client) {
|
||
document.body.style.overflow = ''
|
||
gridScrollEl.value?.removeEventListener('scroll', onGridScroll)
|
||
thumbObserver?.disconnect()
|
||
}
|
||
})
|
||
</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);
|
||
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-preview-all {
|
||
border: 1px solid #2a2a2e;
|
||
border-radius: 8px;
|
||
background: transparent;
|
||
color: inherit;
|
||
padding: 8px 10px;
|
||
font: inherit;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
}
|
||
.lib-modal-preview-all:hover {
|
||
border-color: #f5a524;
|
||
color: #f5a524;
|
||
}
|
||
.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;
|
||
overflow: hidden;
|
||
}
|
||
@media (max-width: 767px) {
|
||
.lib-modal-body { flex-direction: column; }
|
||
}
|
||
.lib-modal-grid {
|
||
display: grid;
|
||
flex: 1;
|
||
min-width: 0;
|
||
min-height: 0;
|
||
height: 100%;
|
||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||
gap: 8px;
|
||
align-content: start;
|
||
overflow: auto;
|
||
padding: 14px 16px;
|
||
}
|
||
.lib-modal-tile {
|
||
display: flex;
|
||
min-height: 120px;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
border: 2px solid #2a2a2e;
|
||
border-radius: 8px;
|
||
background: #0b0b0d;
|
||
cursor: pointer;
|
||
}
|
||
.lib-modal-tile[data-active='true'] {
|
||
border-color: #f5a524;
|
||
box-shadow: 0 0 0 2px #f5a524;
|
||
}
|
||
.lib-modal-media {
|
||
position: relative;
|
||
aspect-ratio: 1 / 1;
|
||
min-height: 120px;
|
||
width: 100%;
|
||
overflow: hidden;
|
||
background: #18181b;
|
||
}
|
||
.lib-modal-media > img {
|
||
display: block;
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
.lib-modal-fallback {
|
||
display: block;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: #27272a;
|
||
}
|
||
.lib-modal-track {
|
||
display: flex;
|
||
height: 100%;
|
||
width: 100%;
|
||
align-items: flex-end;
|
||
gap: 2px;
|
||
padding: 16px;
|
||
}
|
||
.lib-modal-bar {
|
||
flex: 1;
|
||
border-radius: 999px;
|
||
background: rgba(245, 165, 36, 0.7);
|
||
}
|
||
.lib-modal-kind {
|
||
position: absolute;
|
||
top: 8px;
|
||
left: 8px;
|
||
border-radius: 4px;
|
||
background: #000a;
|
||
padding: 2px 6px;
|
||
color: #e4e4e7;
|
||
font-size: 10px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
.lib-modal-caption {
|
||
padding: 6px 8px 8px;
|
||
}
|
||
.lib-modal-name {
|
||
margin: 0;
|
||
overflow: hidden;
|
||
color: #f4f4f5;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.lib-modal-meta {
|
||
margin: 2px 0 0;
|
||
overflow: hidden;
|
||
color: #71717a;
|
||
font-size: 11px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.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;
|
||
min-height: 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>
|