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:
+125
-49
@@ -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"
|
||||||
:src="previewStillSrc"
|
type="button"
|
||||||
alt=""
|
class="block w-full"
|
||||||
decoding="async"
|
title="View larger"
|
||||||
class="aspect-video w-full object-contain"
|
@click="lightboxOpen = true"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
|
:src="previewStillSrc"
|
||||||
|
alt=""
|
||||||
|
decoding="async"
|
||||||
|
class="max-h-[min(52vh,32rem)] w-full object-contain"
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
v-else-if="thumbSrc(focused)"
|
v-else-if="thumbSrc(focused)"
|
||||||
:src="thumbSrc(focused)"
|
type="button"
|
||||||
alt=""
|
class="block w-full"
|
||||||
class="aspect-video w-full object-cover"
|
title="View larger"
|
||||||
|
@click="lightboxOpen = true"
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
:src="thumbSrc(focused)"
|
||||||
|
alt=""
|
||||||
|
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"
|
>
|
||||||
>
|
<img :src="partThumb(part)" alt="" class="aspect-square w-full object-cover" loading="lazy">
|
||||||
<h2 v-else class="mt-3 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>
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
<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">
|
|
||||||
<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">
|
>
|
||||||
</button>
|
<h2 v-else class="mt-4 font-display text-lg font-bold leading-snug">{{ focused.name }}</h2>
|
||||||
</div>
|
<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>
|
</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
@@ -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[] }
|
||||||
|
|||||||
Reference in New Issue
Block a user