Add library select mode, studio-style details, and a larger preview than the old scroll box.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+118
-42
@@ -78,6 +78,15 @@
|
||||
<template v-else>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2 text-xs">
|
||||
<p class="text-zinc-500">{{ entries.length }} item{{ entries.length === 1 ? '' : 's' }}</p>
|
||||
<button
|
||||
v-if="entries.length"
|
||||
type="button"
|
||||
class="rounded-full border px-2.5 py-1 text-[11px]"
|
||||
:class="selectMode ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
|
||||
@click="toggleSelectMode"
|
||||
>
|
||||
{{ selectMode ? 'Done selecting' : 'Select' }}
|
||||
</button>
|
||||
<button v-if="entries.length" type="button" class="text-zinc-400 hover:text-white" @click="selectedKeys.length === entries.length ? clearSelection() : selectAll()">
|
||||
{{ selectedKeys.length && selectedKeys.length === entries.length ? 'Clear selection' : 'Select all' }}
|
||||
</button>
|
||||
@@ -203,12 +212,12 @@
|
||||
<aside
|
||||
class="glass overflow-y-auto p-4 sm:p-5"
|
||||
:class="focused
|
||||
? 'fixed inset-0 z-[60] rounded-none lg:static lg:z-auto lg:h-full lg:w-[22rem] lg:shrink-0 lg:rounded-3xl'
|
||||
? 'fixed inset-0 z-[60] rounded-none lg:static lg:z-auto lg:h-full lg:w-[min(38rem,42vw)] lg:shrink-0 lg:rounded-3xl'
|
||||
: 'hidden w-full shrink-0 rounded-3xl lg:block lg:h-full lg:w-[22rem]'"
|
||||
>
|
||||
<template v-if="focused">
|
||||
<div class="mb-3 flex items-center justify-between lg:hidden">
|
||||
<p class="text-sm text-zinc-400">Preview</p>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<p class="text-sm text-zinc-400">{{ selectMode ? 'Selecting' : 'Details' }}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-white/20 bg-zinc-950 px-3 py-1.5 text-sm text-amber-100"
|
||||
@@ -218,27 +227,41 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative overflow-hidden rounded-2xl bg-zinc-900">
|
||||
<span v-if="detailsHidden" class="sensitive-glow block aspect-video w-full" />
|
||||
<span v-if="detailsHidden" class="sensitive-glow block aspect-[4/5] w-full" />
|
||||
<video
|
||||
v-else-if="focused.kind === 'clip' && previewVideo"
|
||||
:src="previewVideo"
|
||||
class="aspect-video w-full bg-black object-contain"
|
||||
class="max-h-[min(52vh,32rem)] w-full bg-black object-contain"
|
||||
controls
|
||||
playsinline
|
||||
/>
|
||||
<img
|
||||
<button
|
||||
v-else-if="focused.kind === 'still' && previewStillSrc"
|
||||
type="button"
|
||||
class="block w-full"
|
||||
title="View larger"
|
||||
@click="lightboxOpen = true"
|
||||
>
|
||||
<img
|
||||
:src="previewStillSrc"
|
||||
alt=""
|
||||
decoding="async"
|
||||
class="aspect-video w-full object-contain"
|
||||
class="max-h-[min(52vh,32rem)] w-full object-contain"
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="thumbSrc(focused)"
|
||||
type="button"
|
||||
class="block w-full"
|
||||
title="View larger"
|
||||
@click="lightboxOpen = true"
|
||||
>
|
||||
<img
|
||||
v-else-if="thumbSrc(focused)"
|
||||
:src="thumbSrc(focused)"
|
||||
alt=""
|
||||
class="aspect-video w-full object-cover"
|
||||
class="max-h-[min(52vh,32rem)] w-full object-contain"
|
||||
>
|
||||
</button>
|
||||
<FamilyNav
|
||||
:count="focusedNavParts.length"
|
||||
:index="focusedNavIndex"
|
||||
@@ -264,21 +287,19 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-if="renaming"
|
||||
ref="renameInput"
|
||||
v-model="renameDraft"
|
||||
class="mt-3 w-full rounded-xl border border-amber-300/40 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 focus:ring-2"
|
||||
maxlength="80"
|
||||
@keydown.enter.prevent="saveRename"
|
||||
@keydown.escape.prevent="cancelRename"
|
||||
@blur="saveRename"
|
||||
<div v-if="focusedParts.length > 1" class="mt-3 grid grid-cols-5 gap-1.5">
|
||||
<button
|
||||
v-for="part in focusedParts"
|
||||
:key="part.id"
|
||||
type="button"
|
||||
class="overflow-hidden rounded-lg border border-white/10"
|
||||
:class="part.id === focusedPartId ? 'ring-2 ring-amber-300' : ''"
|
||||
@click="focusPart(part.id)"
|
||||
>
|
||||
<h2 v-else class="mt-3 font-display text-lg font-bold leading-snug">{{ focused.name }}</h2>
|
||||
<p class="mt-1 text-xs text-zinc-500">{{ detailMeta }}</p>
|
||||
<p v-if="focusedBranchHint" class="mt-1 text-xs text-amber-200/80">{{ focusedBranchHint }}</p>
|
||||
<p v-if="detailPrompt" class="mt-3 max-h-32 overflow-y-auto whitespace-pre-wrap text-xs text-zinc-400">{{ detailPrompt }}</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<img :src="partThumb(part)" alt="" class="aspect-square w-full object-cover" loading="lazy">
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<button type="button" class="rounded-full border border-amber-300/40 px-3 py-1.5 text-xs text-amber-100" @click="openInStudio(focused)">Open in Studio</button>
|
||||
<button type="button" class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white" @click="useInStudio(focused)">Use as input</button>
|
||||
<button
|
||||
@@ -301,6 +322,12 @@
|
||||
:download="focused.name"
|
||||
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white"
|
||||
>Download</a>
|
||||
<button
|
||||
v-if="!detailsHidden && (previewVideo || previewStillSrc || thumbSrc(focused))"
|
||||
type="button"
|
||||
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white"
|
||||
@click="lightboxOpen = true"
|
||||
>View larger</button>
|
||||
<button type="button" class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white" @click="startRename">Rename</button>
|
||||
<select
|
||||
v-if="folders.length > 1"
|
||||
@@ -313,24 +340,59 @@
|
||||
</select>
|
||||
<button type="button" class="rounded-full border border-red-400/30 px-3 py-1.5 text-xs text-red-300" @click="askRemoveFocused">Delete</button>
|
||||
</div>
|
||||
<div v-if="focusedParts.length > 1" class="mt-4 grid grid-cols-4 gap-1.5">
|
||||
<button
|
||||
v-for="part in focusedParts"
|
||||
:key="part.id"
|
||||
type="button"
|
||||
class="overflow-hidden rounded-lg border border-white/10"
|
||||
:class="part.id === focusedPartId ? 'ring-2 ring-amber-300' : ''"
|
||||
@click="focusPart(part.id)"
|
||||
<input
|
||||
v-if="renaming"
|
||||
ref="renameInput"
|
||||
v-model="renameDraft"
|
||||
class="mt-4 w-full rounded-xl border border-amber-300/40 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 focus:ring-2"
|
||||
maxlength="80"
|
||||
@keydown.enter.prevent="saveRename"
|
||||
@keydown.escape.prevent="cancelRename"
|
||||
@blur="saveRename"
|
||||
>
|
||||
<img :src="partThumb(part)" alt="" class="aspect-square w-full object-cover" loading="lazy">
|
||||
</button>
|
||||
</div>
|
||||
<h2 v-else class="mt-4 font-display text-lg font-bold leading-snug">{{ focused.name }}</h2>
|
||||
<p class="mt-1 text-xs text-zinc-500">{{ detailMeta }}</p>
|
||||
<p v-if="focusedBranchHint" class="mt-1 text-xs text-amber-200/80">{{ focusedBranchHint }}</p>
|
||||
<LibrarySettingsDetails
|
||||
:rows="focusedSettingsRows"
|
||||
empty="No generator settings were saved with this item."
|
||||
/>
|
||||
</template>
|
||||
<p v-else class="text-sm text-zinc-500">Click an item to preview. Drag onto a folder to move. Shift-click or checkboxes to select several.</p>
|
||||
<p v-else class="text-sm text-zinc-500">Click an item for details. Turn on Select to tap thumbnails into a group. Checkboxes and Shift-click still work.</p>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="lightboxOpen && focused && !detailsHidden"
|
||||
class="fixed inset-0 z-[80] flex items-center justify-center bg-black/85 p-3 sm:p-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Larger preview"
|
||||
@click.self="lightboxOpen = false"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-4 top-4 rounded-full border border-white/20 bg-zinc-950 px-3 py-1.5 text-sm text-amber-100"
|
||||
@click="lightboxOpen = false"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<video
|
||||
v-if="focused.kind === 'clip' && previewVideo"
|
||||
:src="previewVideo"
|
||||
class="max-h-[92dvh] max-w-[96vw] bg-black object-contain"
|
||||
controls
|
||||
autoplay
|
||||
playsinline
|
||||
/>
|
||||
<img
|
||||
v-else-if="previewStillSrc || thumbSrc(focused)"
|
||||
:src="previewStillSrc || thumbSrc(focused)"
|
||||
alt=""
|
||||
class="max-h-[92dvh] max-w-[96vw] object-contain"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="pendingDelete"
|
||||
class="fixed inset-0 z-[70] flex items-center justify-center bg-black/70 p-4"
|
||||
@@ -387,7 +449,7 @@ import {
|
||||
type LibraryStill
|
||||
} from '~/utils/libraryBrowse'
|
||||
import { isXaigenStudio } from '~/utils/videoModels'
|
||||
import { formatPromptParts } from '~/utils/promptParts'
|
||||
import { clipSettingsRows, stillSettingsRows } from '~/utils/libraryGeneration'
|
||||
|
||||
const CARD_MIN = 156
|
||||
const GAP = 10
|
||||
@@ -418,6 +480,8 @@ const searchDraft = ref('')
|
||||
const search = ref('')
|
||||
const selectedKeys = ref<string[]>([])
|
||||
const lastSelectedKey = ref('')
|
||||
const selectMode = ref(false)
|
||||
const lightboxOpen = ref(false)
|
||||
const focusedKey = ref('')
|
||||
const focusedPartId = ref('')
|
||||
const unlockPassword = ref('')
|
||||
@@ -532,13 +596,13 @@ const focusedNavIndex = computed(() => {
|
||||
const index = focusedNavParts.value.findIndex(part => part.id === id)
|
||||
return index < 0 ? 0 : index
|
||||
})
|
||||
const detailPrompt = computed(() => {
|
||||
const focusedSettingsRows = computed(() => {
|
||||
if (focused.value?.kind === 'clip' && focused.value.group) {
|
||||
const item = focused.value.group.parts.find(part => part.id === focusedPartId.value) || focused.value.group.head
|
||||
return formatPromptParts(item)
|
||||
return clipSettingsRows(item)
|
||||
}
|
||||
const still = focused.value?.stillGroup?.parts.find(part => part.id === focusedPartId.value) || focused.value?.stillGroup?.head
|
||||
return formatPromptParts(still)
|
||||
return still ? stillSettingsRows(still) : []
|
||||
})
|
||||
const detailMeta = computed(() => {
|
||||
const entry = focused.value
|
||||
@@ -619,9 +683,10 @@ function folderChipClass(id: string) {
|
||||
}
|
||||
|
||||
function cardClass(entry: LibraryEntry) {
|
||||
if (selectMode.value && selectedSet.value.has(entry.key)) return 'border-amber-300 ring-2 ring-amber-300/70'
|
||||
if (focusedKey.value === entry.key) return 'border-amber-300 ring-2 ring-amber-300/70'
|
||||
if (selectedSet.value.has(entry.key)) return 'border-amber-300/40 bg-amber-400/5'
|
||||
return 'border-white/10'
|
||||
return selectMode.value ? 'border-amber-300/25' : 'border-white/10'
|
||||
}
|
||||
|
||||
function itemHidden(entry: LibraryEntry) {
|
||||
@@ -738,9 +803,15 @@ function clearFocus() {
|
||||
focusedKey.value = ''
|
||||
focusedPartId.value = ''
|
||||
detailsHidden.value = false
|
||||
lightboxOpen.value = false
|
||||
cancelRename()
|
||||
}
|
||||
|
||||
function toggleSelectMode() {
|
||||
selectMode.value = !selectMode.value
|
||||
if (selectMode.value) clearFocus()
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
selectedKeys.value = entries.value.map(entry => entry.key)
|
||||
lastSelectedKey.value = selectedKeys.value.at(-1) || ''
|
||||
@@ -765,7 +836,7 @@ function onSelectClick(key: string, event: MouseEvent) {
|
||||
|
||||
function onCardClick(entry: LibraryEntry, event: MouseEvent) {
|
||||
if ((event.target as HTMLElement | null)?.closest('input, select, textarea, a, button, label')) return
|
||||
if (event.shiftKey || event.ctrlKey || event.metaKey) {
|
||||
if (selectMode.value || event.shiftKey || event.ctrlKey || event.metaKey) {
|
||||
onSelectClick(entry.key, event)
|
||||
return
|
||||
}
|
||||
@@ -999,8 +1070,10 @@ function onKey(event: KeyboardEvent) {
|
||||
const typing = Boolean(target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable))
|
||||
if (event.key === 'Escape') {
|
||||
if (pendingDelete.value) pendingDelete.value = null
|
||||
else if (lightboxOpen.value) lightboxOpen.value = false
|
||||
else if (renaming.value) cancelRename()
|
||||
else if (focusedKey.value) clearFocus()
|
||||
else if (selectMode.value) selectMode.value = false
|
||||
else clearSelection()
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a' && !renaming.value) {
|
||||
@@ -1028,7 +1101,10 @@ watch(search, () => {
|
||||
if (scroller.value) scroller.value.scrollTop = 0
|
||||
})
|
||||
watch(focusedKey, (key) => {
|
||||
if (!key) detailsHidden.value = false
|
||||
if (!key) {
|
||||
detailsHidden.value = false
|
||||
lightboxOpen.value = false
|
||||
}
|
||||
})
|
||||
watch(entries, async () => {
|
||||
if (focusedKey.value && !entries.value.some(entry => entry.key === focusedKey.value)) {
|
||||
|
||||
+11
-1
@@ -1,3 +1,6 @@
|
||||
import type { StillGenerationSettings } from '~/utils/libraryGeneration'
|
||||
import type { LoraStackItem } from '~/utils/loras'
|
||||
|
||||
export type LibraryFilter = 'all' | 'images' | 'videos'
|
||||
export type LibrarySort = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'type'
|
||||
|
||||
@@ -28,7 +31,7 @@ export type LibraryStill = {
|
||||
familyId?: string
|
||||
parentStillId?: string
|
||||
chainIndex?: number
|
||||
settings?: { kind?: string; mode?: string; engine?: string }
|
||||
settings?: StillGenerationSettings
|
||||
}
|
||||
|
||||
export type LibraryClip = {
|
||||
@@ -54,6 +57,13 @@ export type LibraryClip = {
|
||||
chainIndex?: number
|
||||
workflow?: string
|
||||
stillId?: string
|
||||
cfg?: number
|
||||
fps?: number
|
||||
samplerName?: string
|
||||
scheduler?: string
|
||||
sound?: boolean
|
||||
loraName?: string
|
||||
loraStack?: LoraStackItem[]
|
||||
}
|
||||
|
||||
export type ClipGroup = { key: string; head: LibraryClip; parts: LibraryClip[] }
|
||||
|
||||
Reference in New Issue
Block a user