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:
@@ -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>
|
||||
@@ -0,0 +1,226 @@
|
||||
<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-2xl 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">Settings</h2>
|
||||
<p class="mt-1 text-sm text-zinc-400">Library folders, passwords, and thumbnail defaults for this account.</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>
|
||||
|
||||
<section class="mt-6 space-y-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">Folders</h3>
|
||||
<span class="text-xs text-zinc-500">{{ folders.length }} total</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="folder in folders"
|
||||
:key="folder.id"
|
||||
class="rounded-2xl border border-white/10 bg-zinc-950/50 p-4 space-y-3"
|
||||
>
|
||||
<template v-if="drafts[folder.id]">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
v-model="drafts[folder.id].name"
|
||||
class="min-w-[10rem] flex-1 rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
|
||||
@keydown.enter.prevent="rename(folder)"
|
||||
>
|
||||
<button type="button" class="rounded-xl border border-white/15 px-3 py-2 text-sm hover:border-amber-300/50" @click="rename(folder)">Rename</button>
|
||||
<button type="button" class="rounded-xl border border-red-400/30 px-3 py-2 text-sm text-red-300 hover:border-red-300" @click="remove(folder)">Delete</button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<label class="flex cursor-pointer items-center justify-between gap-3 rounded-xl border border-white/10 px-3 py-2 text-sm">
|
||||
<span>Thumbnail default</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="peer sr-only"
|
||||
:checked="folder.thumbnailDefault"
|
||||
@change="setThumbnail(folder, ($event.target as HTMLInputElement).checked)"
|
||||
>
|
||||
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
|
||||
<span class="absolute inset-0 rounded-full bg-zinc-700 transition" :class="folder.thumbnailDefault ? 'bg-amber-400' : 'bg-zinc-700'" />
|
||||
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition" :class="folder.thumbnailDefault ? 'translate-x-5' : ''" />
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="flex cursor-pointer items-center justify-between gap-3 rounded-xl border border-white/10 px-3 py-2 text-sm">
|
||||
<span>{{ drafts[folder.id].protect ? 'Password on' : 'Password off' }}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="peer sr-only"
|
||||
:checked="drafts[folder.id].protect"
|
||||
@change="toggleProtect(folder, ($event.target as HTMLInputElement).checked)"
|
||||
>
|
||||
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
|
||||
<span class="absolute inset-0 rounded-full transition" :class="drafts[folder.id].protect ? 'bg-amber-400' : 'bg-zinc-700'" />
|
||||
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition" :class="drafts[folder.id].protect ? 'translate-x-5' : ''" />
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<form v-if="drafts[folder.id].protect || folder.protected" class="grid gap-2 sm:grid-cols-2" @submit.prevent="savePassword(folder)">
|
||||
<p v-if="folder.protected && !drafts[folder.id].protect" class="sm:col-span-2 text-sm text-zinc-400">
|
||||
Enter the current password to turn protection off.
|
||||
</p>
|
||||
<input
|
||||
v-if="folder.protected"
|
||||
v-model="drafts[folder.id].currentPassword"
|
||||
type="password"
|
||||
class="rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
|
||||
placeholder="Current password"
|
||||
>
|
||||
<input
|
||||
v-if="drafts[folder.id].protect"
|
||||
v-model="drafts[folder.id].password"
|
||||
type="password"
|
||||
class="rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
|
||||
:placeholder="folder.protected ? 'New password (blank to keep)' : 'Set a password'"
|
||||
>
|
||||
<button type="submit" class="rounded-xl border border-white/15 px-3 py-2 text-sm hover:border-amber-300/50 sm:col-span-2">
|
||||
{{ folder.protected && !drafts[folder.id].protect ? 'Remove password' : folder.protected ? 'Update folder password' : 'Protect folder' }}
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="folder.protected && folder.unlocked" class="text-xs text-emerald-300">Unlocked in this session.</p>
|
||||
<p v-else-if="folder.protected" class="text-xs text-amber-200">Locked · clips and stills stay hidden until you unlock it.</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<form class="flex flex-wrap gap-2" @submit.prevent="addFolder">
|
||||
<input
|
||||
v-model="newName"
|
||||
class="min-w-[10rem] flex-1 rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
|
||||
placeholder="New folder name"
|
||||
>
|
||||
<button type="submit" class="rounded-xl bg-amber-400 px-4 py-2 text-sm font-semibold text-zinc-950">Add folder</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
export interface SettingsFolder {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: number
|
||||
thumbnailDefault: boolean
|
||||
protected: boolean
|
||||
unlocked: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
folders: SettingsFolder[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
updated: [payload: unknown]
|
||||
error: [message: string]
|
||||
}>()
|
||||
|
||||
const newName = ref('')
|
||||
const drafts = reactive<Record<string, { name: string; protect: boolean; password: string; currentPassword: string }>>({})
|
||||
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') emit('close')
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', onKey))
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
||||
|
||||
watch(() => props.folders, (folders) => {
|
||||
for (const folder of folders) {
|
||||
if (!drafts[folder.id]) {
|
||||
drafts[folder.id] = {
|
||||
name: folder.name,
|
||||
protect: folder.protected,
|
||||
password: '',
|
||||
currentPassword: ''
|
||||
}
|
||||
} else {
|
||||
drafts[folder.id].name = folder.name
|
||||
}
|
||||
}
|
||||
}, { immediate: true, deep: true })
|
||||
|
||||
async function addFolder() {
|
||||
const name = newName.value.trim()
|
||||
if (!name) return
|
||||
try {
|
||||
await $fetch('/api/library/folders', { method: 'POST', body: { name } })
|
||||
newName.value = ''
|
||||
emit('updated', await $fetch('/api/library'))
|
||||
} catch (error: any) {
|
||||
emit('error', error?.data?.statusMessage || error?.statusMessage || 'Could not create folder')
|
||||
}
|
||||
}
|
||||
|
||||
async function rename(folder: SettingsFolder) {
|
||||
const name = drafts[folder.id]?.name?.trim()
|
||||
if (!name || name === folder.name) return
|
||||
try {
|
||||
emit('updated', await $fetch(`/api/library/folders/${folder.id}`, { method: 'PATCH', body: { name } }))
|
||||
} catch (error: any) {
|
||||
emit('error', error?.data?.statusMessage || 'Could not rename folder')
|
||||
}
|
||||
}
|
||||
|
||||
async function setThumbnail(folder: SettingsFolder, thumbnailDefault: boolean) {
|
||||
try {
|
||||
emit('updated', await $fetch(`/api/library/folders/${folder.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { thumbnailDefault }
|
||||
}))
|
||||
} catch (error: any) {
|
||||
emit('error', error?.data?.statusMessage || 'Could not update thumbnail default')
|
||||
}
|
||||
}
|
||||
|
||||
function toggleProtect(folder: SettingsFolder, on: boolean) {
|
||||
drafts[folder.id].protect = on
|
||||
if (!on && folder.protected) {
|
||||
drafts[folder.id].password = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function savePassword(folder: SettingsFolder) {
|
||||
const draft = drafts[folder.id]
|
||||
try {
|
||||
if (!draft.protect && folder.protected) {
|
||||
emit('updated', await $fetch(`/api/library/folders/${folder.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { password: '', currentPassword: draft.currentPassword }
|
||||
}))
|
||||
} else if (draft.protect) {
|
||||
if (!folder.protected && !draft.password.trim()) {
|
||||
emit('error', 'Set a password with at least 4 characters.')
|
||||
return
|
||||
}
|
||||
if (!folder.protected || draft.password.trim()) {
|
||||
emit('updated', await $fetch(`/api/library/folders/${folder.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { password: draft.password, currentPassword: draft.currentPassword }
|
||||
}))
|
||||
}
|
||||
}
|
||||
draft.password = ''
|
||||
draft.currentPassword = ''
|
||||
} catch (error: any) {
|
||||
emit('error', error?.data?.statusMessage || 'Could not update folder password')
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(folder: SettingsFolder) {
|
||||
if (!confirm(`Delete “${folder.name}” and everything inside it?`)) return
|
||||
try {
|
||||
emit('updated', await $fetch(`/api/library/folders/${folder.id}`, { method: 'DELETE' }))
|
||||
} catch (error: any) {
|
||||
emit('error', error?.data?.statusMessage || 'Could not delete folder')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user