Files
aigen/components/SettingsModal.vue
T
TowstyandCursor f0f80b5873 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>
2026-08-25 18:31:04 -05:00

227 lines
9.3 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-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>