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:
Towsty
2026-09-02 20:35:11 -05:00
co-authored by Cursor
parent 369eb595d7
commit 8d236250e4
2 changed files with 136 additions and 50 deletions
+118 -42
View File
@@ -78,6 +78,15 @@
<template v-else> <template v-else>
<div class="mt-3 flex flex-wrap items-center gap-2 text-xs"> <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> <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()"> <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' }} {{ selectedKeys.length && selectedKeys.length === entries.length ? 'Clear selection' : 'Select all' }}
</button> </button>
@@ -203,12 +212,12 @@
<aside <aside
class="glass overflow-y-auto p-4 sm:p-5" class="glass overflow-y-auto p-4 sm:p-5"
:class="focused :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]'" : 'hidden w-full shrink-0 rounded-3xl lg:block lg:h-full lg:w-[22rem]'"
> >
<template v-if="focused"> <template v-if="focused">
<div class="mb-3 flex items-center justify-between lg:hidden"> <div class="mb-3 flex items-center justify-between">
<p class="text-sm text-zinc-400">Preview</p> <p class="text-sm text-zinc-400">{{ selectMode ? 'Selecting' : 'Details' }}</p>
<button <button
type="button" type="button"
class="rounded-full border border-white/20 bg-zinc-950 px-3 py-1.5 text-sm text-amber-100" 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> </button>
</div> </div>
<div class="relative overflow-hidden rounded-2xl bg-zinc-900"> <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 <video
v-else-if="focused.kind === 'clip' && previewVideo" v-else-if="focused.kind === 'clip' && previewVideo"
:src="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 controls
playsinline playsinline
/> />
<img <button
v-else-if="focused.kind === 'still' && previewStillSrc" v-else-if="focused.kind === 'still' && previewStillSrc"
type="button"
class="block w-full"
title="View larger"
@click="lightboxOpen = true"
>
<img
:src="previewStillSrc" :src="previewStillSrc"
alt="" alt=""
decoding="async" 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 <img
v-else-if="thumbSrc(focused)"
:src="thumbSrc(focused)" :src="thumbSrc(focused)"
alt="" alt=""
class="aspect-video w-full object-cover" class="max-h-[min(52vh,32rem)] w-full object-contain"
> >
</button>
<FamilyNav <FamilyNav
:count="focusedNavParts.length" :count="focusedNavParts.length"
:index="focusedNavIndex" :index="focusedNavIndex"
@@ -264,21 +287,19 @@
</svg> </svg>
</button> </button>
</div> </div>
<input <div v-if="focusedParts.length > 1" class="mt-3 grid grid-cols-5 gap-1.5">
v-if="renaming" <button
ref="renameInput" v-for="part in focusedParts"
v-model="renameDraft" :key="part.id"
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" type="button"
maxlength="80" class="overflow-hidden rounded-lg border border-white/10"
@keydown.enter.prevent="saveRename" :class="part.id === focusedPartId ? 'ring-2 ring-amber-300' : ''"
@keydown.escape.prevent="cancelRename" @click="focusPart(part.id)"
@blur="saveRename"
> >
<h2 v-else class="mt-3 font-display text-lg font-bold leading-snug">{{ focused.name }}</h2> <img :src="partThumb(part)" alt="" class="aspect-square w-full object-cover" loading="lazy">
<p class="mt-1 text-xs text-zinc-500">{{ detailMeta }}</p> </button>
<p v-if="focusedBranchHint" class="mt-1 text-xs text-amber-200/80">{{ focusedBranchHint }}</p> </div>
<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-3 flex flex-wrap gap-2">
<div class="mt-4 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-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 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 <button
@@ -301,6 +322,12 @@
:download="focused.name" :download="focused.name"
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white" class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white"
>Download</a> >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> <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 <select
v-if="folders.length > 1" v-if="folders.length > 1"
@@ -313,24 +340,59 @@
</select> </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> <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>
<div v-if="focusedParts.length > 1" class="mt-4 grid grid-cols-4 gap-1.5"> <input
<button v-if="renaming"
v-for="part in focusedParts" ref="renameInput"
:key="part.id" v-model="renameDraft"
type="button" 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"
class="overflow-hidden rounded-lg border border-white/10" maxlength="80"
:class="part.id === focusedPartId ? 'ring-2 ring-amber-300' : ''" @keydown.enter.prevent="saveRename"
@click="focusPart(part.id)" @keydown.escape.prevent="cancelRename"
@blur="saveRename"
> >
<img :src="partThumb(part)" alt="" class="aspect-square w-full object-cover" loading="lazy"> <h2 v-else class="mt-4 font-display text-lg font-bold leading-snug">{{ focused.name }}</h2>
</button> <p class="mt-1 text-xs text-zinc-500">{{ detailMeta }}</p>
</div> <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> </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> </aside>
</main> </main>
<Teleport to="body"> <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 <div
v-if="pendingDelete" v-if="pendingDelete"
class="fixed inset-0 z-[70] flex items-center justify-center bg-black/70 p-4" class="fixed inset-0 z-[70] flex items-center justify-center bg-black/70 p-4"
@@ -387,7 +449,7 @@ import {
type LibraryStill type LibraryStill
} from '~/utils/libraryBrowse' } from '~/utils/libraryBrowse'
import { isXaigenStudio } from '~/utils/videoModels' import { isXaigenStudio } from '~/utils/videoModels'
import { formatPromptParts } from '~/utils/promptParts' import { clipSettingsRows, stillSettingsRows } from '~/utils/libraryGeneration'
const CARD_MIN = 156 const CARD_MIN = 156
const GAP = 10 const GAP = 10
@@ -418,6 +480,8 @@ const searchDraft = ref('')
const search = ref('') const search = ref('')
const selectedKeys = ref<string[]>([]) const selectedKeys = ref<string[]>([])
const lastSelectedKey = ref('') const lastSelectedKey = ref('')
const selectMode = ref(false)
const lightboxOpen = ref(false)
const focusedKey = ref('') const focusedKey = ref('')
const focusedPartId = ref('') const focusedPartId = ref('')
const unlockPassword = ref('') const unlockPassword = ref('')
@@ -532,13 +596,13 @@ const focusedNavIndex = computed(() => {
const index = focusedNavParts.value.findIndex(part => part.id === id) const index = focusedNavParts.value.findIndex(part => part.id === id)
return index < 0 ? 0 : index return index < 0 ? 0 : index
}) })
const detailPrompt = computed(() => { const focusedSettingsRows = computed(() => {
if (focused.value?.kind === 'clip' && focused.value.group) { if (focused.value?.kind === 'clip' && focused.value.group) {
const item = focused.value.group.parts.find(part => part.id === focusedPartId.value) || focused.value.group.head 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 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 detailMeta = computed(() => {
const entry = focused.value const entry = focused.value
@@ -619,9 +683,10 @@ function folderChipClass(id: string) {
} }
function cardClass(entry: LibraryEntry) { 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 (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' 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) { function itemHidden(entry: LibraryEntry) {
@@ -738,9 +803,15 @@ function clearFocus() {
focusedKey.value = '' focusedKey.value = ''
focusedPartId.value = '' focusedPartId.value = ''
detailsHidden.value = false detailsHidden.value = false
lightboxOpen.value = false
cancelRename() cancelRename()
} }
function toggleSelectMode() {
selectMode.value = !selectMode.value
if (selectMode.value) clearFocus()
}
function selectAll() { function selectAll() {
selectedKeys.value = entries.value.map(entry => entry.key) selectedKeys.value = entries.value.map(entry => entry.key)
lastSelectedKey.value = selectedKeys.value.at(-1) || '' lastSelectedKey.value = selectedKeys.value.at(-1) || ''
@@ -765,7 +836,7 @@ function onSelectClick(key: string, event: MouseEvent) {
function onCardClick(entry: LibraryEntry, event: MouseEvent) { function onCardClick(entry: LibraryEntry, event: MouseEvent) {
if ((event.target as HTMLElement | null)?.closest('input, select, textarea, a, button, label')) return 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) onSelectClick(entry.key, event)
return return
} }
@@ -999,8 +1070,10 @@ function onKey(event: KeyboardEvent) {
const typing = Boolean(target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable)) const typing = Boolean(target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable))
if (event.key === 'Escape') { if (event.key === 'Escape') {
if (pendingDelete.value) pendingDelete.value = null if (pendingDelete.value) pendingDelete.value = null
else if (lightboxOpen.value) lightboxOpen.value = false
else if (renaming.value) cancelRename() else if (renaming.value) cancelRename()
else if (focusedKey.value) clearFocus() else if (focusedKey.value) clearFocus()
else if (selectMode.value) selectMode.value = false
else clearSelection() else clearSelection()
} }
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a' && !renaming.value) { if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a' && !renaming.value) {
@@ -1028,7 +1101,10 @@ watch(search, () => {
if (scroller.value) scroller.value.scrollTop = 0 if (scroller.value) scroller.value.scrollTop = 0
}) })
watch(focusedKey, (key) => { watch(focusedKey, (key) => {
if (!key) detailsHidden.value = false if (!key) {
detailsHidden.value = false
lightboxOpen.value = false
}
}) })
watch(entries, async () => { watch(entries, async () => {
if (focusedKey.value && !entries.value.some(entry => entry.key === focusedKey.value)) { if (focusedKey.value && !entries.value.some(entry => entry.key === focusedKey.value)) {
+11 -1
View File
@@ -1,3 +1,6 @@
import type { StillGenerationSettings } from '~/utils/libraryGeneration'
import type { LoraStackItem } from '~/utils/loras'
export type LibraryFilter = 'all' | 'images' | 'videos' export type LibraryFilter = 'all' | 'images' | 'videos'
export type LibrarySort = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'type' export type LibrarySort = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'type'
@@ -28,7 +31,7 @@ export type LibraryStill = {
familyId?: string familyId?: string
parentStillId?: string parentStillId?: string
chainIndex?: number chainIndex?: number
settings?: { kind?: string; mode?: string; engine?: string } settings?: StillGenerationSettings
} }
export type LibraryClip = { export type LibraryClip = {
@@ -54,6 +57,13 @@ export type LibraryClip = {
chainIndex?: number chainIndex?: number
workflow?: string workflow?: string
stillId?: 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[] } export type ClipGroup = { key: string; head: LibraryClip; parts: LibraryClip[] }