Collapsed collection actions now move, rename, delete, and rerun every part so chains stay together. Identity mode follows the v2u3 Ref2VA picture wiring. Co-authored-by: Cursor <cursoragent@cursor.com>
272 lines
11 KiB
Vue
272 lines
11 KiB
Vue
<template>
|
|
<Teleport to="body">
|
|
<div class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/70 p-4 sm:p-8" @click.self="emit('close')">
|
|
<div class="glass w-full max-w-3xl rounded-3xl p-5 sm:p-6">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div>
|
|
<h2 class="font-display text-2xl font-bold">{{ heading || 'Choose an image' }}</h2>
|
|
<p class="mt-1 text-sm text-zinc-400">Pick a still from this folder or upload a new one.</p>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
v-if="hasHiddenItems"
|
|
type="button"
|
|
class="rounded-full border px-3 py-1 text-sm"
|
|
:class="revealAll ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
|
|
:title="revealAll ? 'Hide flagged thumbnails' : 'Reveal all hidden thumbnails'"
|
|
@click="toggleRevealAll"
|
|
>
|
|
<span class="inline-flex items-center gap-1.5">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-4 w-4">
|
|
<path v-if="revealAll" stroke-linecap="round" d="M3 3l18 18M10.6 10.6a2 2 0 102.8 2.8M9.9 5.1A9.8 9.8 0 0121 12a9.8 9.8 0 01-1.7 2.9M6.1 6.1A9.8 9.8 0 003 12a9.8 9.8 0 0012.9 6.1" />
|
|
<template v-else>
|
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z" />
|
|
<circle cx="12" cy="12" r="3" />
|
|
</template>
|
|
</svg>
|
|
{{ revealAll ? 'Hide all' : 'Reveal all' }}
|
|
</span>
|
|
</button>
|
|
<button type="button" class="rounded-full border border-white/10 px-3 py-1 text-sm text-zinc-400 hover:text-white" @click="emit('close')">Close</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-5 flex flex-wrap gap-2">
|
|
<button
|
|
v-for="folder in folders"
|
|
:key="folder.id"
|
|
type="button"
|
|
class="rounded-full border px-3 py-1 text-xs"
|
|
:class="activeFolderId === folder.id ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400'"
|
|
@click="activeFolderId = folder.id"
|
|
>
|
|
{{ folder.name }}
|
|
<span v-if="folder.protected" class="ml-1">{{ folder.unlocked ? '· unlocked' : '· locked' }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<form v-if="current && current.protected && !current.unlocked" class="mt-6 space-y-3" @submit.prevent="unlock">
|
|
<p class="text-sm text-zinc-400">This folder is locked. You can still upload a new still to generate into it; unlock to browse saved images.</p>
|
|
<input v-model="password" type="password" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="Folder password">
|
|
<button type="submit" class="rounded-xl bg-amber-400 px-4 py-2 text-sm font-semibold text-zinc-950">Unlock folder</button>
|
|
</form>
|
|
|
|
<div
|
|
class="mt-5 cursor-pointer rounded-2xl border border-dashed border-white/15 bg-zinc-950/40 p-6 text-center transition hover:border-amber-300/50"
|
|
:class="{ 'border-amber-300/70 bg-amber-400/5': dragging }"
|
|
@dragover.prevent="dragging = true"
|
|
@dragleave.prevent="dragging = false"
|
|
@drop.prevent="onDrop"
|
|
@click="fileInput?.click()"
|
|
>
|
|
<p class="font-medium">Upload a new still</p>
|
|
<p class="mt-1 text-sm text-zinc-500">PNG, JPG, or WEBP · saved into {{ current?.name || 'this folder' }}</p>
|
|
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="onFile">
|
|
</div>
|
|
|
|
<template v-if="current && (!current.protected || current.unlocked)">
|
|
<p v-if="!visibleStills.length" class="mt-6 text-sm text-zinc-500">No uploaded stills in this folder yet.</p>
|
|
<div v-if="visibleStills.length" class="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
|
<article
|
|
v-for="still in visibleStills"
|
|
:key="still.id"
|
|
class="overflow-hidden rounded-2xl border border-white/10 bg-zinc-950"
|
|
>
|
|
<div class="relative h-28 overflow-hidden bg-zinc-900">
|
|
<img
|
|
:src="`/api/library/stills/${still.id}`"
|
|
alt=""
|
|
class="h-full w-full object-cover"
|
|
:class="stillHidden(still) && !isRevealed(still.id) ? 'scale-110 blur-xl' : ''"
|
|
>
|
|
<span v-if="stillHidden(still) && !isRevealed(still.id)" class="sensitive-glow pointer-events-none absolute inset-0 opacity-80" />
|
|
<div class="absolute inset-x-0 top-0 flex items-start justify-between p-2">
|
|
<button
|
|
v-if="stillHidden(still)"
|
|
type="button"
|
|
class="rounded-full bg-zinc-950/80 p-1.5 text-amber-100 hover:bg-zinc-900"
|
|
:title="isRevealed(still.id) ? 'Hide thumbnail' : 'Reveal thumbnail'"
|
|
@click="toggleReveal(still.id)"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-4 w-4">
|
|
<template v-if="isRevealed(still.id)">
|
|
<path stroke-linecap="round" d="M3 3l18 18M10.6 10.6a2 2 0 102.8 2.8M9.9 5.1A9.8 9.8 0 0121 12a9.8 9.8 0 01-1.7 2.9M6.1 6.1A9.8 9.8 0 003 12a9.8 9.8 0 0012.9 6.1" />
|
|
</template>
|
|
<template v-else>
|
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z" />
|
|
<circle cx="12" cy="12" r="3" />
|
|
</template>
|
|
</svg>
|
|
</button>
|
|
<span v-else />
|
|
<button
|
|
type="button"
|
|
class="rounded-full bg-amber-400 p-1.5 text-zinc-950 hover:bg-amber-300"
|
|
title="Use as input"
|
|
@click="pickStill(still)"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="h-4 w-4">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center justify-between gap-2 px-3 py-2">
|
|
<span class="truncate text-xs text-zinc-400">{{ still.filename }}</span>
|
|
<button type="button" class="text-[10px] text-red-300 hover:text-red-200" @click="removeStill(still)">Delete</button>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
export interface PickerFolder {
|
|
id: string
|
|
name: string
|
|
protected: boolean
|
|
unlocked: boolean
|
|
thumbnailDefault: boolean
|
|
}
|
|
|
|
export interface PickerStill {
|
|
id: string
|
|
folderId: string
|
|
filename: string
|
|
width: number
|
|
height: number
|
|
createdAt: number
|
|
hideInput?: boolean
|
|
}
|
|
|
|
const props = defineProps<{
|
|
folders: PickerFolder[]
|
|
stills: PickerStill[]
|
|
folderId: string
|
|
hideInputPreview?: boolean
|
|
heading?: string
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
close: []
|
|
updated: [payload: unknown]
|
|
error: [message: string]
|
|
select: [file: File, hidden: boolean]
|
|
}>()
|
|
|
|
const fileInput = ref<HTMLInputElement | null>(null)
|
|
const dragging = ref(false)
|
|
const password = ref('')
|
|
const activeFolderId = ref(props.folderId)
|
|
const revealAll = ref(false)
|
|
const revealedIds = ref<string[]>([])
|
|
|
|
function onKey(event: KeyboardEvent) {
|
|
if (event.key === 'Escape') emit('close')
|
|
}
|
|
|
|
onMounted(() => window.addEventListener('keydown', onKey))
|
|
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
|
|
|
watch(() => props.folderId, (id) => {
|
|
if (id && !props.folders.some(folder => folder.id === activeFolderId.value)) {
|
|
activeFolderId.value = id
|
|
}
|
|
})
|
|
|
|
const current = computed(() => props.folders.find(folder => folder.id === activeFolderId.value) || props.folders[0])
|
|
const visibleStills = computed(() => props.stills.filter(still => still.folderId === (current.value?.id || activeFolderId.value)))
|
|
const hasHiddenItems = computed(() => visibleStills.value.some(still => stillHidden(still)))
|
|
|
|
function stillHidden(still: PickerStill) {
|
|
return still.hideInput === true || props.hideInputPreview === true
|
|
}
|
|
|
|
function isRevealed(id: string) {
|
|
return revealAll.value || revealedIds.value.includes(id)
|
|
}
|
|
|
|
function toggleReveal(id: string) {
|
|
if (revealAll.value) {
|
|
revealAll.value = false
|
|
revealedIds.value = visibleHiddenIds().filter(item => item !== id)
|
|
return
|
|
}
|
|
if (revealedIds.value.includes(id)) {
|
|
revealedIds.value = revealedIds.value.filter(item => item !== id)
|
|
} else {
|
|
revealedIds.value = [...revealedIds.value, id]
|
|
}
|
|
}
|
|
|
|
function visibleHiddenIds() {
|
|
return visibleStills.value.filter(still => stillHidden(still)).map(still => still.id)
|
|
}
|
|
|
|
function toggleRevealAll() {
|
|
revealAll.value = !revealAll.value
|
|
revealedIds.value = []
|
|
}
|
|
|
|
async function unlock() {
|
|
try {
|
|
emit('updated', await $fetch('/api/library/unlock', {
|
|
method: 'POST',
|
|
body: { password: password.value, folderId: current.value?.id }
|
|
}))
|
|
password.value = ''
|
|
} catch (error: any) {
|
|
emit('error', error?.data?.statusMessage || 'Unlock failed')
|
|
}
|
|
}
|
|
|
|
async function ingest(file: File) {
|
|
if (/last[\s._-]*frame/i.test(file.name || '')) return
|
|
const body = new FormData()
|
|
body.append('image', file)
|
|
body.append('folderId', current.value?.id || activeFolderId.value)
|
|
body.append('hideInput', String(props.hideInputPreview === true))
|
|
try {
|
|
emit('updated', await $fetch('/api/library/stills', { method: 'POST', body }))
|
|
emit('select', file, props.hideInputPreview === true)
|
|
} catch (error: any) {
|
|
emit('error', error?.data?.statusMessage || 'Could not save image')
|
|
}
|
|
}
|
|
|
|
function onFile(event: Event) {
|
|
const input = event.target as HTMLInputElement
|
|
const next = input.files?.[0]
|
|
if (next) void ingest(next)
|
|
input.value = ''
|
|
}
|
|
|
|
function onDrop(event: DragEvent) {
|
|
dragging.value = false
|
|
const next = event.dataTransfer?.files?.[0]
|
|
if (next) void ingest(next)
|
|
}
|
|
|
|
async function pickStill(still: PickerStill) {
|
|
try {
|
|
const blob = await $fetch<Blob>(`/api/library/stills/${still.id}`, { responseType: 'blob' })
|
|
const file = new File([blob], still.filename, { type: blob.type || 'image/png' })
|
|
emit('select', file, still.hideInput === true)
|
|
} catch (error: any) {
|
|
emit('error', error?.data?.statusMessage || 'Could not load that still')
|
|
}
|
|
}
|
|
|
|
async function removeStill(still: PickerStill) {
|
|
if (!confirm(`Remove “${still.filename}” from this folder?`)) return
|
|
try {
|
|
emit('updated', await $fetch(`/api/library/stills/${still.id}`, { method: 'DELETE' }))
|
|
} catch (error: any) {
|
|
emit('error', error?.data?.statusMessage || 'Could not delete image')
|
|
}
|
|
}
|
|
</script>
|