Files
aigen/components/ImagePickerModal.vue
T

449 lines
18 KiB
Vue

<template>
<Teleport to="body">
<div v-if="open" 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">{{ pickerHint }}</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
type="button"
class="rounded-full border px-3 py-1 text-xs font-medium"
:class="tab === 'input' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400'"
@click="tab = 'input'"
>
Input
</button>
<button
type="button"
class="rounded-full border px-3 py-1 text-xs font-medium"
:class="tab === 'output' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400'"
@click="tab = 'output'"
>
Output
</button>
</div>
<div class="mt-3 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
v-if="tab === 'input'"
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">{{ emptyLabel }}</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="library-thumb cursor-pointer overflow-hidden rounded-2xl border border-white/10 bg-zinc-950"
@click="pickStill(still)"
>
<div class="relative h-28 overflow-hidden bg-zinc-900">
<img
:src="`/api/library/stills/${still.id}?thumb=1`"
alt=""
loading="lazy"
decoding="async"
class="h-full w-full object-cover"
:class="thumbBlurred(still) ? 'scale-110 blur-xl' : ''"
>
<span v-if="thumbBlurred(still)" class="sensitive-glow pointer-events-none absolute inset-0 opacity-80" />
<div class="absolute inset-x-0 top-0 z-10 flex items-start justify-between p-2">
<button
v-if="privacy"
type="button"
class="rounded-full bg-zinc-950/80 p-1.5 text-amber-100 hover:bg-zinc-900 disabled:opacity-50"
:title="still.hideInput ? 'Show thumbnail' : 'Hide thumbnail'"
@click.stop="toggleHidden(still)"
>
<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="!still.hideInput">
<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 />
<div class="flex items-center gap-1">
<button
v-if="tab === 'output'"
type="button"
class="rounded-full bg-zinc-950/80 p-1.5 text-amber-100 hover:bg-zinc-900 disabled:opacity-50"
title="Save as input"
:disabled="keepingId === still.id"
@click.stop="keepAsInput(still)"
>
<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 stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v9m0 0l-3.5-3.5M12 13.5l3.5-3.5M5 19.5h14" />
</svg>
</button>
<button
type="button"
class="rounded-full bg-amber-400 p-1.5 text-zinc-950 hover:bg-amber-300"
title="Use as input"
@click.stop="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>
<div class="flex items-center justify-between gap-2 px-3 py-2">
<input
v-if="renamingId === still.id"
ref="renameInput"
v-model="renameDraft"
class="min-w-0 flex-1 rounded-md border border-amber-300/40 bg-zinc-950 px-2 py-1 text-xs outline-none ring-amber-300/40 focus:ring-2"
maxlength="80"
aria-label="Still name"
@click.stop
@keydown.enter.prevent="saveRename(still)"
@keydown.escape.prevent="cancelRename"
@blur="saveRename(still)"
>
<span v-else class="min-w-0 flex-1 truncate text-xs text-zinc-400">{{ stillLabel(still) }}</span>
<div class="flex shrink-0 items-center gap-1">
<button
v-if="renamingId !== still.id"
type="button"
class="rounded-full p-1 text-amber-200 hover:bg-white/5 hover:text-amber-100"
title="Rename"
@click.stop="startRename(still)"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-3.5 w-3.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 7.125L16.862 4.487" />
</svg>
</button>
<button type="button" class="text-[10px] text-red-300 hover:text-red-200" @click.stop="removeStill(still)">Delete</button>
</div>
</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
role?: 'input' | 'output'
name?: string
}
const props = withDefaults(defineProps<{
folders: PickerFolder[]
stills: PickerStill[]
folderId: string
hideInputPreview?: boolean
privacy?: boolean
heading?: string
stillOnly?: boolean
open?: boolean
}>(), {
open: true,
privacy: false
})
const emit = defineEmits<{
close: []
updated: [payload: unknown]
error: [message: string]
select: [file: File, hidden: boolean, stillId?: string]
pick: [still: { id: string; filename: string; hideInput?: boolean }]
}>()
const fileInput = ref<HTMLInputElement | null>(null)
const dragging = ref(false)
const password = ref('')
const activeFolderId = ref(props.folderId)
const tab = ref<'input' | 'output'>('input')
const revealAll = ref(false)
const revealedIds = ref<string[]>([])
const forceHiddenIds = ref<string[]>([])
const picking = ref(false)
const keepingId = ref('')
const hidingId = ref('')
const renamingId = ref('')
const renameDraft = ref('')
const renameInput = ref<HTMLInputElement | null>(null)
const renameSaving = ref(false)
function onKey(event: KeyboardEvent) {
if (!props.open) return
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) => {
if (still.folderId !== (current.value?.id || activeFolderId.value)) return false
const role = still.role === 'output' ? 'output' : 'input'
return role === tab.value
}))
const emptyLabel = computed(() => tab.value === 'input'
? 'No uploaded stills in this folder yet.'
: 'No generated stills in this folder yet.')
const pickerHint = computed(() => {
if (props.stillOnly) return 'Pick a still, then type a short name on the strip (or pick a GLOBAL LOCKS key). That name is what the shot prompt inherits.'
return tab.value === 'input'
? 'Uploaded stills only. Generated frames and edits live under Output.'
: 'Generated stills. Checkmark loads the current frame. Arrow copies the image into Input for later. Video frames stay with their clips.'
})
const hasHiddenItems = computed(() => visibleStills.value.some(still => stillHidden(still)))
function stillHidden(still: PickerStill) {
return still.hideInput === true
}
function isRevealed(id: string) {
return revealAll.value || revealedIds.value.includes(id)
}
function thumbBlurred(still: PickerStill) {
if (forceHiddenIds.value.includes(still.id)) return true
return stillHidden(still) && !isRevealed(still.id)
}
async function toggleHidden(still: PickerStill) {
if (hidingId.value === still.id) return
const nextHide = still.hideInput !== true
const previous = still.hideInput === true
still.hideInput = nextHide
if (nextHide) {
revealedIds.value = revealedIds.value.filter(id => id !== still.id)
if (revealAll.value && !forceHiddenIds.value.includes(still.id)) {
forceHiddenIds.value = [...forceHiddenIds.value, still.id]
}
} else {
forceHiddenIds.value = forceHiddenIds.value.filter(id => id !== still.id)
}
hidingId.value = still.id
try {
emit('updated', await $fetch(`/api/library/stills/${still.id}`, {
method: 'PATCH',
body: { hideInput: nextHide }
}))
} catch (error: any) {
still.hideInput = previous
forceHiddenIds.value = forceHiddenIds.value.filter(id => id !== still.id)
emit('error', error?.data?.statusMessage || 'Could not update thumbnail')
} finally {
if (hidingId.value === still.id) hidingId.value = ''
}
}
function toggleRevealAll() {
revealAll.value = !revealAll.value
revealedIds.value = []
forceHiddenIds.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 {
const result = await $fetch<{ still?: { id: string; filename: string; hideInput?: boolean } }>('/api/library/stills', { method: 'POST', body })
emit('updated', result)
if (props.stillOnly) {
if (result.still?.id) emit('pick', result.still)
return
}
emit('select', file, props.hideInputPreview === true, result.still?.id)
} 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)
}
function stillLabel(still: PickerStill) {
return (still.name || still.filename.replace(/\.[a-z0-9]+$/i, '') || 'Untitled still').trim()
}
function startRename(still: PickerStill) {
renamingId.value = still.id
renameDraft.value = stillLabel(still)
nextTick(() => {
renameInput.value?.focus()
renameInput.value?.select()
})
}
function cancelRename() {
renamingId.value = ''
}
async function saveRename(still: PickerStill) {
if (renamingId.value !== still.id || renameSaving.value) return
const name = renameDraft.value.trim().slice(0, 80)
if (!name) {
emit('error', 'A name is required')
return
}
if (name === stillLabel(still)) {
cancelRename()
return
}
renameSaving.value = true
try {
emit('updated', await $fetch(`/api/library/stills/${still.id}`, { method: 'PATCH', body: { name } }))
cancelRename()
} catch (error: any) {
emit('error', error?.data?.statusMessage || 'Could not rename still')
} finally {
renameSaving.value = false
}
}
async function keepAsInput(still: PickerStill) {
if (keepingId.value) return
keepingId.value = still.id
try {
emit('updated', await $fetch(`/api/library/stills/${still.id}/keep-input`, { method: 'POST' }))
tab.value = 'input'
} catch (error: any) {
emit('error', error?.data?.statusMessage || 'Could not save that still as input')
} finally {
keepingId.value = ''
}
}
async function pickStill(still: PickerStill) {
if (picking.value) return
picking.value = true
try {
if (props.stillOnly) {
emit('pick', { id: still.id, filename: still.filename, hideInput: still.hideInput })
return
}
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, still.id)
} catch (error: any) {
emit('error', error?.data?.statusMessage || 'Could not load that still')
} finally {
picking.value = false
}
}
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>