|
|
|
@@ -1,16 +1,22 @@
|
|
|
|
|
<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 storage, folders, passwords, LoRA filename keys, and prompt-helper instruction sets 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>
|
|
|
|
|
<div class="lg:grid lg:grid-cols-[13rem_minmax(0,1fr)] lg:items-start lg:gap-10">
|
|
|
|
|
<nav class="mb-6 flex flex-wrap gap-2 lg:sticky lg:top-24 lg:mb-0 lg:flex-col lg:gap-1">
|
|
|
|
|
<a
|
|
|
|
|
v-for="item in navItems"
|
|
|
|
|
:key="item.id"
|
|
|
|
|
:href="`#${item.id}`"
|
|
|
|
|
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-400 hover:border-amber-300/40 hover:text-amber-100 lg:rounded-xl lg:text-sm"
|
|
|
|
|
>{{ item.label }}</a>
|
|
|
|
|
</nav>
|
|
|
|
|
|
|
|
|
|
<section class="mt-6">
|
|
|
|
|
<div class="space-y-10">
|
|
|
|
|
<p
|
|
|
|
|
v-if="notice"
|
|
|
|
|
class="rounded-2xl border px-4 py-3 text-sm"
|
|
|
|
|
:class="noticeError ? 'border-red-400/30 bg-red-950/40 text-red-100' : 'border-emerald-400/20 bg-emerald-950/30 text-emerald-100'"
|
|
|
|
|
>{{ notice }}</p>
|
|
|
|
|
|
|
|
|
|
<section id="settings-gpu" class="scroll-mt-24">
|
|
|
|
|
<h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">GPU</h3>
|
|
|
|
|
<div class="mt-2 space-y-3 rounded-2xl border border-white/10 bg-zinc-950/50 px-4 py-3">
|
|
|
|
|
<p class="text-sm text-zinc-400">
|
|
|
|
@@ -40,7 +46,7 @@
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section class="mt-6">
|
|
|
|
|
<section id="settings-storage" class="scroll-mt-24">
|
|
|
|
|
<h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">Storage</h3>
|
|
|
|
|
<div class="mt-2 rounded-2xl border border-white/10 bg-zinc-950/50 px-4 py-3">
|
|
|
|
|
<p v-if="storageLoading" class="text-sm text-zinc-500">Measuring…</p>
|
|
|
|
@@ -53,7 +59,7 @@
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section v-if="authMode === 'password'" class="mt-6 space-y-3">
|
|
|
|
|
<section v-if="authMode === 'password'" id="settings-login" class="scroll-mt-24 space-y-3">
|
|
|
|
|
<h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">Site login</h3>
|
|
|
|
|
<p class="text-sm text-zinc-400">Username and password are stored encrypted on this server. Changing them does not move the library.</p>
|
|
|
|
|
<form class="grid gap-2 sm:grid-cols-2" @submit.prevent="saveLogin">
|
|
|
|
@@ -64,7 +70,7 @@
|
|
|
|
|
</form>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section class="mt-6 space-y-3">
|
|
|
|
|
<section id="settings-folders" class="scroll-mt-24 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>
|
|
|
|
@@ -153,7 +159,7 @@
|
|
|
|
|
</form>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section class="mt-8 space-y-3">
|
|
|
|
|
<section id="settings-loras" class="scroll-mt-24 space-y-3">
|
|
|
|
|
<div>
|
|
|
|
|
<h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">LoRA filename keys</h3>
|
|
|
|
|
<p class="mt-1 text-sm text-zinc-400">
|
|
|
|
@@ -192,7 +198,7 @@
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section class="mt-8 space-y-4">
|
|
|
|
|
<section id="settings-keeps" class="scroll-mt-24 space-y-4">
|
|
|
|
|
<div>
|
|
|
|
|
<h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">Keep prompts</h3>
|
|
|
|
|
<p class="mt-1 text-sm text-zinc-400">
|
|
|
|
@@ -249,7 +255,7 @@
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section class="mt-8 space-y-3">
|
|
|
|
|
<section id="settings-prompts" class="scroll-mt-24 space-y-3">
|
|
|
|
|
<div>
|
|
|
|
|
<h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">Prompt helper</h3>
|
|
|
|
|
<p class="mt-1 text-sm text-zinc-400">
|
|
|
|
@@ -310,9 +316,8 @@
|
|
|
|
|
<button type="submit" class="rounded-xl bg-amber-400 px-4 py-2 text-sm font-semibold text-zinc-950">Add instruction set</button>
|
|
|
|
|
</form>
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Teleport>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
@@ -337,18 +342,27 @@ export interface SettingsFolder {
|
|
|
|
|
unlocked: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
|
folders: SettingsFolder[]
|
|
|
|
|
authMode?: string
|
|
|
|
|
username?: string
|
|
|
|
|
}>()
|
|
|
|
|
const folders = ref<SettingsFolder[]>([])
|
|
|
|
|
const authMode = ref('')
|
|
|
|
|
const userName = ref('')
|
|
|
|
|
const notice = ref('')
|
|
|
|
|
const noticeError = ref(false)
|
|
|
|
|
let noticeTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
close: []
|
|
|
|
|
updated: [payload: unknown]
|
|
|
|
|
error: [message: string]
|
|
|
|
|
renamed: [username: string]
|
|
|
|
|
}>()
|
|
|
|
|
const navItems = computed(() => {
|
|
|
|
|
const items = [
|
|
|
|
|
{ id: 'settings-gpu', label: 'GPU' },
|
|
|
|
|
{ id: 'settings-storage', label: 'Storage' }
|
|
|
|
|
]
|
|
|
|
|
if (authMode.value === 'password') items.push({ id: 'settings-login', label: 'Site login' })
|
|
|
|
|
items.push(
|
|
|
|
|
{ id: 'settings-folders', label: 'Folders' },
|
|
|
|
|
{ id: 'settings-loras', label: 'LoRA keys' },
|
|
|
|
|
{ id: 'settings-keeps', label: 'Keep prompts' },
|
|
|
|
|
{ id: 'settings-prompts', label: 'Prompt helper' }
|
|
|
|
|
)
|
|
|
|
|
return items
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const newName = ref('')
|
|
|
|
|
const newPresetName = ref('')
|
|
|
|
@@ -365,7 +379,7 @@ const newKeep = reactive<Record<KeepPromptKind, { label: string; text: string }>
|
|
|
|
|
const promptPresets = ref<Array<{ id: string; name: string; description?: string; builtin?: boolean; systemPrompt?: string }>>([])
|
|
|
|
|
const loraRowsLoading = ref(true)
|
|
|
|
|
const loraRows = ref<Array<{ key: string; file: string; kind: 'image' | 'video' | 'both'; draft: string; trigger: string; placeholder: string }>>([])
|
|
|
|
|
const loginUser = ref(props.username || '')
|
|
|
|
|
const loginUser = ref('')
|
|
|
|
|
const loginCurrent = ref('')
|
|
|
|
|
const loginNext = ref('')
|
|
|
|
|
const drafts = reactive<Record<string, { name: string; protect: boolean; password: string; currentPassword: string }>>({})
|
|
|
|
@@ -424,20 +438,42 @@ const storageNote = computed(() => {
|
|
|
|
|
: "This account's clips, stills, thumbs, and temp. Volume free space is shared."
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
function onKey(event: KeyboardEvent) {
|
|
|
|
|
if (event.key === 'Escape') emit('close')
|
|
|
|
|
function toast(message: string, error = true) {
|
|
|
|
|
notice.value = message
|
|
|
|
|
noticeError.value = error
|
|
|
|
|
if (noticeTimer) clearTimeout(noticeTimer)
|
|
|
|
|
noticeTimer = setTimeout(() => { notice.value = '' }, 4200)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyLibrary(data: unknown) {
|
|
|
|
|
const payload = (data || {}) as { folders?: SettingsFolder[] }
|
|
|
|
|
if (Array.isArray(payload.folders)) folders.value = payload.folders
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadAccount() {
|
|
|
|
|
const [library, me] = await Promise.all([
|
|
|
|
|
$fetch<{ folders?: SettingsFolder[] }>('/api/library?light=1').catch(() => ({ folders: [] })),
|
|
|
|
|
$fetch<{ user?: { name?: string; email?: string }; authMode?: string }>('/api/auth/me').catch(() => ({}))
|
|
|
|
|
])
|
|
|
|
|
applyLibrary(library)
|
|
|
|
|
userName.value = me.user?.name || me.user?.email || ''
|
|
|
|
|
authMode.value = me.authMode || ''
|
|
|
|
|
if (userName.value) loginUser.value = userName.value
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
window.addEventListener('keydown', onKey)
|
|
|
|
|
void loadAccount()
|
|
|
|
|
void loadPresets()
|
|
|
|
|
void loadStorage()
|
|
|
|
|
void loadLoraKeys()
|
|
|
|
|
})
|
|
|
|
|
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
|
|
|
|
|
|
|
|
|
watch(() => props.folders, (folders) => {
|
|
|
|
|
for (const folder of folders) {
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
if (noticeTimer) clearTimeout(noticeTimer)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
watch(folders, (list) => {
|
|
|
|
|
for (const folder of list) {
|
|
|
|
|
if (!drafts[folder.id]) {
|
|
|
|
|
drafts[folder.id] = {
|
|
|
|
|
name: folder.name,
|
|
|
|
@@ -451,10 +487,6 @@ watch(() => props.folders, (folders) => {
|
|
|
|
|
}
|
|
|
|
|
}, { immediate: true, deep: true })
|
|
|
|
|
|
|
|
|
|
watch(() => props.username, (value) => {
|
|
|
|
|
if (value) loginUser.value = value
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
async function saveLogin() {
|
|
|
|
|
try {
|
|
|
|
|
const data = await $fetch<{ username: string }>('/api/auth/credentials', {
|
|
|
|
@@ -467,9 +499,10 @@ async function saveLogin() {
|
|
|
|
|
})
|
|
|
|
|
loginCurrent.value = ''
|
|
|
|
|
loginNext.value = ''
|
|
|
|
|
emit('renamed', data.username)
|
|
|
|
|
userName.value = data.username
|
|
|
|
|
toast('Login updated', false)
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
emit('error', error?.data?.statusMessage || 'Could not update site login')
|
|
|
|
|
toast(error?.data?.statusMessage || 'Could not update site login')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -479,9 +512,9 @@ async function addFolder() {
|
|
|
|
|
try {
|
|
|
|
|
await $fetch('/api/library/folders', { method: 'POST', body: { name } })
|
|
|
|
|
newName.value = ''
|
|
|
|
|
emit('updated', await $fetch('/api/library'))
|
|
|
|
|
applyLibrary(await $fetch('/api/library'))
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
emit('error', error?.data?.statusMessage || error?.statusMessage || 'Could not create folder')
|
|
|
|
|
toast( error?.data?.statusMessage || error?.statusMessage || 'Could not create folder')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -589,20 +622,20 @@ async function saveKeepKind(kind: KeepPromptKind) {
|
|
|
|
|
body: { kind, items: keepPrompts.value[kind] }
|
|
|
|
|
})
|
|
|
|
|
keepPrompts.value = normalizeKeepPrompts(data.keepPrompts)
|
|
|
|
|
emit('updated', data)
|
|
|
|
|
applyLibrary(data)
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
emit('error', error?.data?.statusMessage || 'Could not save keep prompts')
|
|
|
|
|
toast( error?.data?.statusMessage || 'Could not save keep prompts')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addKeepItem(kind: KeepPromptKind) {
|
|
|
|
|
const text = sanitizeKeepPromptText(newKeep[kind].text)
|
|
|
|
|
if (!text) {
|
|
|
|
|
emit('error', 'Snippet text is required')
|
|
|
|
|
toast( 'Snippet text is required')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (keepPrompts.value[kind].length >= KEEP_PROMPT_MAX) {
|
|
|
|
|
emit('error', `At most ${KEEP_PROMPT_MAX} snippets per list`)
|
|
|
|
|
toast( `At most ${KEEP_PROMPT_MAX} snippets per list`)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
keepPrompts.value[kind] = [
|
|
|
|
@@ -625,23 +658,23 @@ function removeKeepItem(kind: KeepPromptKind, id: string) {
|
|
|
|
|
|
|
|
|
|
async function saveLoraAlias(row: { key: string; draft: string }) {
|
|
|
|
|
try {
|
|
|
|
|
emit('updated', await $fetch('/api/library/lora-aliases', {
|
|
|
|
|
applyLibrary(await $fetch('/api/library/lora-aliases', {
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
body: { key: row.key, alias: row.draft }
|
|
|
|
|
}))
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
emit('error', error?.data?.statusMessage || 'Could not save LoRA filename key')
|
|
|
|
|
toast( error?.data?.statusMessage || 'Could not save LoRA filename key')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveLoraTrigger(row: { key: string; trigger: string }) {
|
|
|
|
|
try {
|
|
|
|
|
emit('updated', await $fetch('/api/library/lora-triggers', {
|
|
|
|
|
applyLibrary(await $fetch('/api/library/lora-triggers', {
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
body: { key: row.key, trigger: row.trigger }
|
|
|
|
|
}))
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
emit('error', error?.data?.statusMessage || 'Could not save LoRA activation word')
|
|
|
|
|
toast( error?.data?.statusMessage || 'Could not save LoRA activation word')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -649,7 +682,7 @@ async function addPreset() {
|
|
|
|
|
const name = newPresetName.value.trim()
|
|
|
|
|
const systemPrompt = newPresetPrompt.value.trim()
|
|
|
|
|
if (!name || !systemPrompt) {
|
|
|
|
|
emit('error', 'Name and instruction text are required')
|
|
|
|
|
toast( 'Name and instruction text are required')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
@@ -661,7 +694,7 @@ async function addPreset() {
|
|
|
|
|
newPresetName.value = ''
|
|
|
|
|
newPresetPrompt.value = ''
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
emit('error', error?.data?.statusMessage || 'Could not save instruction set')
|
|
|
|
|
toast( error?.data?.statusMessage || 'Could not save instruction set')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -674,7 +707,7 @@ async function savePreset(preset: PromptPresetRow) {
|
|
|
|
|
})
|
|
|
|
|
promptPresets.value = data.presets || promptPresets.value
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
emit('error', error?.data?.statusMessage || 'Could not update instruction set')
|
|
|
|
|
toast( error?.data?.statusMessage || 'Could not update instruction set')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -684,7 +717,7 @@ async function removePreset(id: string) {
|
|
|
|
|
const data = await $fetch<{ presets?: PromptPresetRow[] }>(`/api/prompt/presets/${id}`, { method: 'DELETE' })
|
|
|
|
|
promptPresets.value = data.presets || []
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
emit('error', error?.data?.statusMessage || 'Could not delete instruction set')
|
|
|
|
|
toast( error?.data?.statusMessage || 'Could not delete instruction set')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -692,20 +725,20 @@ 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 } }))
|
|
|
|
|
applyLibrary(await $fetch(`/api/library/folders/${folder.id}`, { method: 'PATCH', body: { name } }))
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
emit('error', error?.data?.statusMessage || 'Could not rename folder')
|
|
|
|
|
toast( error?.data?.statusMessage || 'Could not rename folder')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function setThumbnail(folder: SettingsFolder, thumbnailDefault: boolean) {
|
|
|
|
|
try {
|
|
|
|
|
emit('updated', await $fetch(`/api/library/folders/${folder.id}`, {
|
|
|
|
|
applyLibrary(await $fetch(`/api/library/folders/${folder.id}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
body: { thumbnailDefault }
|
|
|
|
|
}))
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
emit('error', error?.data?.statusMessage || 'Could not update thumbnail default')
|
|
|
|
|
toast( error?.data?.statusMessage || 'Could not update thumbnail default')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -720,17 +753,17 @@ 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}`, {
|
|
|
|
|
applyLibrary(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.')
|
|
|
|
|
toast( 'Set a password with at least 4 characters.')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (!folder.protected || draft.password.trim()) {
|
|
|
|
|
emit('updated', await $fetch(`/api/library/folders/${folder.id}`, {
|
|
|
|
|
applyLibrary(await $fetch(`/api/library/folders/${folder.id}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
body: { password: draft.password, currentPassword: draft.currentPassword }
|
|
|
|
|
}))
|
|
|
|
@@ -739,16 +772,16 @@ async function savePassword(folder: SettingsFolder) {
|
|
|
|
|
draft.password = ''
|
|
|
|
|
draft.currentPassword = ''
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
emit('error', error?.data?.statusMessage || 'Could not update folder password')
|
|
|
|
|
toast( 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' }))
|
|
|
|
|
applyLibrary(await $fetch(`/api/library/folders/${folder.id}`, { method: 'DELETE' }))
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
emit('error', error?.data?.statusMessage || 'Could not delete folder')
|
|
|
|
|
toast( error?.data?.statusMessage || 'Could not delete folder')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</script>
|