Add per-user library settings, folder passwords, and a still picker.

Settings manages folders, thumbnail defaults, and passwords for the signed-in account, and choosing an image now browses saved stills instead of only the desktop file dialog.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-25 18:31:04 -05:00
co-authored by Cursor
parent c4e6af9f77
commit f0f80b5873
20 changed files with 1063 additions and 236 deletions
+207
View File
@@ -0,0 +1,207 @@
<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">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>
<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 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 password protected.</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>
<template v-else>
<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>
<p v-if="!visibleStills.length && !visibleClipThumbs.length" class="mt-6 text-sm text-zinc-500">No uploaded stills in this folder yet.</p>
<div v-if="visibleStills.length || visibleClipThumbs.length" class="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
<button
v-for="still in visibleStills"
:key="still.id"
type="button"
class="group overflow-hidden rounded-2xl border border-white/10 bg-zinc-950 text-left hover:border-amber-300/50"
@click="pickStill(still)"
>
<img :src="`/api/library/stills/${still.id}`" alt="" class="h-28 w-full object-cover">
<div class="flex items-center justify-between gap-2 px-3 py-2">
<span class="truncate text-xs text-zinc-400">{{ still.filename }}</span>
<span class="text-[10px] text-red-300 opacity-0 group-hover:opacity-100" @click.stop="removeStill(still)">Delete</span>
</div>
</button>
<button
v-for="clip in visibleClipThumbs"
:key="'clip-' + clip.id"
type="button"
class="overflow-hidden rounded-2xl border border-white/10 bg-zinc-950 text-left hover:border-amber-300/50"
@click="pickClip(clip)"
>
<img :src="`/api/library/clips/${clip.id}/thumb`" alt="" class="h-28 w-full object-cover">
<div class="px-3 py-2">
<span class="truncate text-xs text-zinc-400">From a clip</span>
</div>
</button>
</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
}
export interface PickerClip {
id: string
folderId: string
hideThumbnail: boolean
prompt: string
}
const props = defineProps<{
folders: PickerFolder[]
stills: PickerStill[]
clips: PickerClip[]
folderId: string
}>()
const emit = defineEmits<{
close: []
updated: [payload: unknown]
error: [message: string]
select: [file: File]
}>()
const fileInput = ref<HTMLInputElement | null>(null)
const dragging = ref(false)
const password = ref('')
const activeFolderId = ref(props.folderId)
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 visibleClipThumbs = computed(() => props.clips.filter(clip => clip.folderId === (current.value?.id || activeFolderId.value) && !clip.hideThumbnail))
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) {
const body = new FormData()
body.append('image', file)
body.append('folderId', current.value?.id || activeFolderId.value)
try {
emit('updated', await $fetch('/api/library/stills', { method: 'POST', body }))
emit('select', file)
} 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 pickClip(clip: PickerClip) {
try {
const blob = await $fetch<Blob>(`/api/library/clips/${clip.id}/thumb`, { responseType: 'blob' })
const file = new File([blob], `${clip.id}.png`, { type: blob.type || 'image/png' })
emit('select', file)
} catch (error: any) {
emit('error', error?.data?.statusMessage || 'Could not load that still')
}
}
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)
} 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>