Make library hide/show instant on the grid and skip disk checks on browse.

The details pane keeps the real file so inspection is not delayed.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-30 18:37:29 -05:00
co-authored by Cursor
parent e89b47b5f9
commit 845a037949
5 changed files with 122 additions and 52 deletions
+87 -38
View File
@@ -26,7 +26,7 @@
<label class="min-w-[12rem] flex-1 text-sm">
<span class="mb-1 block text-xs text-zinc-500">Search</span>
<input
v-model="search"
v-model="searchDraft"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
placeholder="Name or prompt"
type="search"
@@ -78,7 +78,7 @@
@drop.prevent="onFolderDrop(folder.id, $event)"
>
{{ folder.name }}
<span class="text-zinc-500">{{ folderOutputCount(folder, clips, stills, filter) }}</span>
<span class="text-zinc-500">{{ folderChipCount(folder) }}</span>
<span v-if="folder.protected && !folder.unlocked"> · lock</span>
</button>
</div>
@@ -166,15 +166,15 @@
type="button"
role="checkbox"
class="absolute bottom-2 left-2 flex h-4 w-4 items-center justify-center rounded-[4px] border shadow-[0_1px_4px_rgba(0,0,0,0.55)]"
:class="selectedKeys.includes(entry.key)
:class="selectedSet.has(entry.key)
? 'border-white bg-amber-400 text-zinc-950'
: 'border-white/30 bg-black/45'"
:aria-checked="selectedKeys.includes(entry.key)"
:aria-label="selectedKeys.includes(entry.key) ? 'Deselect' : 'Select'"
:aria-checked="selectedSet.has(entry.key)"
:aria-label="selectedSet.has(entry.key) ? 'Deselect' : 'Select'"
@click.stop="onSelectClick(entry.key, $event)"
>
<svg
v-if="selectedKeys.includes(entry.key)"
v-if="selectedSet.has(entry.key)"
class="h-3 w-3"
viewBox="0 0 16 16"
fill="none"
@@ -198,9 +198,8 @@
<aside class="glass w-full shrink-0 overflow-y-auto rounded-3xl p-4 sm:p-5 lg:h-full lg:w-[22rem]">
<template v-if="focused">
<div class="relative overflow-hidden rounded-2xl bg-zinc-900">
<span v-if="previewHidden" class="sensitive-glow block aspect-video w-full" />
<video
v-else-if="focused.kind === 'clip' && previewVideo"
v-if="focused.kind === 'clip' && previewVideo"
:src="previewVideo"
class="aspect-video w-full bg-black object-contain"
controls
@@ -224,7 +223,6 @@
class="absolute right-2 top-2 z-10 flex h-8 w-8 items-center justify-center rounded-full border border-white/20 bg-black/60 text-zinc-100 hover:border-amber-300/50 hover:text-amber-100 disabled:opacity-50"
:title="previewHidden ? 'Show in library' : 'Hide in library'"
:aria-label="previewHidden ? 'Show in library' : 'Hide in library'"
:disabled="hideBusy"
@click="togglePreviewHidden"
>
<svg v-if="previewHidden" class="h-4 w-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
@@ -342,7 +340,6 @@
<script setup lang="ts">
import {
buildLibraryEntries,
folderOutputCount,
formatClipDuration,
formatWhen,
type LibraryClip,
@@ -381,6 +378,7 @@ const stills = ref<LibraryStill[]>([])
const folderId = ref('all')
const filter = ref<LibraryFilter>('all')
const sort = ref<LibrarySort>('date-desc')
const searchDraft = ref('')
const search = ref('')
const selectedKeys = ref<string[]>([])
const lastSelectedKey = ref('')
@@ -397,11 +395,14 @@ const hideBusy = ref(false)
const pendingDelete = ref<{ title: string; name: string; detail?: string; consequence: string; keys: string[] } | null>(null)
const toasts = ref<Array<{ id: number; message: string }>>([])
let toastId = 0
let searchTimer: ReturnType<typeof setTimeout> | null = null
const scroller = ref<HTMLElement | null>(null)
const scrollTop = ref(0)
let scrollRaf = 0
const viewportH = ref(800)
const viewportW = ref(800)
const selectedSet = computed(() => new Set(selectedKeys.value))
const browseFolder = computed(() => folders.value.find(folder => folder.id === folderId.value) || null)
const entries = computed(() => buildLibraryEntries({
clips: clips.value,
@@ -411,14 +412,31 @@ const entries = computed(() => buildLibraryEntries({
search: search.value,
sort: sort.value
}))
const allCount = computed(() => buildLibraryEntries({
clips: clips.value,
stills: stills.value,
folderId: 'all',
filter: filter.value,
search: '',
sort: 'date-desc'
}).length)
const allCount = computed(() => {
if (folderId.value === 'all' && !search.value.trim()) return entries.value.length
return buildLibraryEntries({
clips: clips.value,
stills: stills.value,
folderId: 'all',
filter: filter.value,
search: '',
sort: 'date-desc'
}).length
})
const folderChipCounts = computed(() => {
const map: Record<string, { videos: number; images: number }> = {}
for (const clip of clips.value) {
if (clip.hasVideo === false) continue
const row = map[clip.folderId] || (map[clip.folderId] = { videos: 0, images: 0 })
row.videos += 1
}
for (const still of stills.value) {
if (still.role !== 'output') continue
const row = map[still.folderId] || (map[still.folderId] = { videos: 0, images: 0 })
row.images += 1
}
return map
})
const moveFolders = computed(() => folders.value.filter(folder => folder.id !== folderId.value))
const focused = computed(() => entries.value.find(entry => entry.key === focusedKey.value) || null)
const emptyLabel = computed(() => {
@@ -448,18 +466,10 @@ const previewStillSrc = computed(() => {
const id = focusedPartId.value || focused.value.stillGroup.head.id
return `/api/library/stills/${id}`
})
const focusedPreviewItem = computed(() => {
const entry = focused.value
if (!entry) return null
if (entry.kind === 'clip') {
return entry.group?.parts.find(part => part.id === focusedPartId.value) || entry.group?.head || null
}
return entry.stillGroup?.parts.find(part => part.id === focusedPartId.value) || entry.stillGroup?.head || null
})
const previewHidden = computed(() => {
const item = focusedPreviewItem.value
if (!item) return false
return 'hideThumbnail' in item ? Boolean(item.hideThumbnail) : Boolean(item.hideInput)
const entry = focused.value
if (!entry) return false
return thumbHidden(entry)
})
const focusedParts = computed(() => {
if (focused.value?.kind === 'clip') return focused.value.group?.parts || []
@@ -514,6 +524,15 @@ async function loadLibrary() {
applyLibrary(await $fetch('/api/library?light=1'))
}
function folderChipCount(folder: LibraryFolder) {
if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0)
const row = folderChipCounts.value[folder.id]
if (!row) return 0
if (filter.value === 'videos') return row.videos
if (filter.value === 'images') return row.images
return row.videos + row.images
}
function folderChipClass(id: string) {
if (folderId.value === id) return 'border-amber-300 bg-amber-400/10 text-amber-100'
if (dropFolderId.value === id) return 'border-amber-300/70 bg-amber-400/10 text-amber-100'
@@ -522,7 +541,7 @@ function folderChipClass(id: string) {
function cardClass(entry: LibraryEntry) {
if (focusedKey.value === entry.key) return 'border-amber-300 ring-2 ring-amber-300/70'
if (selectedKeys.value.includes(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'
}
@@ -558,7 +577,11 @@ function focusPart(id: string) {
}
function onScroll() {
scrollTop.value = scroller.value?.scrollTop || 0
if (scrollRaf) return
scrollRaf = requestAnimationFrame(() => {
scrollRaf = 0
scrollTop.value = scroller.value?.scrollTop || 0
})
}
function measure() {
@@ -734,23 +757,43 @@ async function saveRename() {
async function togglePreviewHidden() {
const entry = focused.value
const item = focusedPreviewItem.value
if (!entry || !item || hideBusy.value) return
if (!entry || hideBusy.value) return
const next = !thumbHidden(entry)
applyLocalHide(entry, next)
hideBusy.value = true
const next = !previewHidden.value
try {
if (entry.kind === 'clip') {
applyLibrary(await $fetch(`/api/library/clips/${item.id}`, { method: 'PATCH', body: { hideThumbnail: next } }))
} else {
applyLibrary(await $fetch(`/api/library/stills/${item.id}`, { method: 'PATCH', body: { hideInput: next } }))
if (entry.kind === 'clip' && entry.group) {
await $fetch(`/api/library/clips/${entry.group.head.id}`, {
method: 'PATCH',
body: { hideThumbnail: next, family: true }
})
} else if (entry.stillGroup) {
await $fetch(`/api/library/stills/${entry.stillGroup.head.id}`, {
method: 'PATCH',
body: { hideInput: next, family: true }
})
}
} catch (error: any) {
applyLocalHide(entry, !next)
toast(error?.data?.statusMessage || 'Could not update visibility')
} finally {
hideBusy.value = false
}
}
function applyLocalHide(entry: LibraryEntry, hide: boolean) {
if (entry.kind === 'clip' && entry.group) {
for (const part of entry.group.parts) {
part.hideThumbnail = hide
part.hasThumbnail = !hide
}
return
}
if (entry.stillGroup) {
for (const part of entry.stillGroup.parts) part.hideInput = hide
}
}
function askRemoveFocused() {
if (!focused.value) return
askRemoveKeys([focused.value.key])
@@ -829,6 +872,12 @@ function onKey(event: KeyboardEvent) {
if (event.key === 'Delete' && selectedKeys.value.length && !renaming.value) askRemoveSelected()
}
watch(searchDraft, (value) => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
search.value = value
}, 160)
})
watch([folderId, filter], () => {
clearSelection()
if (scroller.value) scroller.value.scrollTop = 0