Show one settings panel at a time and add a LoRA filter.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-31 21:31:04 -05:00
co-authored by Cursor
parent 1dba62bf54
commit 85dc378712
2 changed files with 104 additions and 31 deletions
+103 -30
View File
@@ -1,24 +1,28 @@
<template> <template>
<div class="lg:grid lg:grid-cols-[13rem_minmax(0,1fr)] lg:items-start lg:gap-10"> <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"> <nav class="mb-6 flex flex-wrap gap-2 lg:sticky lg:top-24 lg:mb-0 lg:flex-col lg:gap-1">
<a <button
v-for="item in navItems" v-for="item in navItems"
:key="item.id" :key="item.id"
:href="`#${item.id}`" type="button"
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" class="rounded-full border px-3 py-1.5 text-left text-xs lg:rounded-xl lg:text-sm"
>{{ item.label }}</a> :class="activePanel === item.id
? 'border-amber-300/50 bg-amber-400/10 text-amber-100'
: 'border-white/10 text-zinc-400 hover:border-amber-300/40 hover:text-amber-100'"
@click="activePanel = item.id"
>{{ item.label }}</button>
</nav> </nav>
<div class="space-y-10"> <div class="min-w-0 space-y-4">
<p <p
v-if="notice" v-if="notice"
class="rounded-2xl border px-4 py-3 text-sm" 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'" :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> >{{ notice }}</p>
<section id="settings-gpu" class="scroll-mt-24"> <section v-if="activePanel === 'gpu'" class="glass rounded-3xl p-5 sm:p-6">
<h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">GPU</h3> <h2 class="font-display text-xl font-bold">GPU</h2>
<div class="mt-2 space-y-3 rounded-2xl border border-white/10 bg-zinc-950/50 px-4 py-3"> <div class="mt-4 space-y-3 rounded-2xl border border-white/10 bg-zinc-950/50 px-4 py-3">
<p class="text-sm text-zinc-400"> <p class="text-sm text-zinc-400">
Stops Comfy on the desktop GPU so it drops out of VRAM. The PC stays on. Generate or Wake GPU will start it again. Stops Comfy on the desktop GPU so it drops out of VRAM. The PC stays on. Generate or Wake GPU will start it again.
</p> </p>
@@ -46,9 +50,9 @@
</div> </div>
</section> </section>
<section id="settings-storage" class="scroll-mt-24"> <section v-if="activePanel === 'storage'" class="glass rounded-3xl p-5 sm:p-6">
<h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">Storage</h3> <h2 class="font-display text-xl font-bold">Storage</h2>
<div class="mt-2 rounded-2xl border border-white/10 bg-zinc-950/50 px-4 py-3"> <div class="mt-4 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> <p v-if="storageLoading" class="text-sm text-zinc-500">Measuring…</p>
<p v-else-if="storageError" class="text-sm text-zinc-500">{{ storageError }}</p> <p v-else-if="storageError" class="text-sm text-zinc-500">{{ storageError }}</p>
<template v-else-if="storage"> <template v-else-if="storage">
@@ -59,8 +63,8 @@
</div> </div>
</section> </section>
<section v-if="authMode === 'password'" id="settings-login" class="scroll-mt-24 space-y-3"> <section v-if="activePanel === 'login' && authMode === 'password'" class="glass space-y-3 rounded-3xl p-5 sm:p-6">
<h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">Site login</h3> <h2 class="font-display text-xl font-bold">Site login</h2>
<p class="text-sm text-zinc-400">Username and password are stored encrypted on this server. Changing them does not move the library.</p> <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"> <form class="grid gap-2 sm:grid-cols-2" @submit.prevent="saveLogin">
<input v-model="loginUser" class="rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="Username" autocomplete="username"> <input v-model="loginUser" class="rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="Username" autocomplete="username">
@@ -70,9 +74,9 @@
</form> </form>
</section> </section>
<section id="settings-folders" class="scroll-mt-24 space-y-3"> <section v-if="activePanel === 'folders'" class="glass space-y-3 rounded-3xl p-5 sm:p-6">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">Folders</h3> <h2 class="font-display text-xl font-bold">Folders</h2>
<span class="text-xs text-zinc-500">{{ folders.length }} total</span> <span class="text-xs text-zinc-500">{{ folders.length }} total</span>
</div> </div>
@@ -159,19 +163,41 @@
</form> </form>
</section> </section>
<section id="settings-loras" class="scroll-mt-24 space-y-3"> <section v-if="activePanel === 'loras'" class="glass space-y-4 rounded-3xl p-5 sm:p-6">
<div> <div>
<h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">LoRA filename keys</h3> <h2 class="font-display text-xl font-bold">LoRA filename keys</h2>
<p class="mt-1 text-sm text-zinc-400"> <p class="mt-1 text-sm text-zinc-400">
Short names used in auto-generated output titles. Leave blank to use the file stem Short names used in auto-generated output titles. Leave blank to use the file stem
(<span class="text-zinc-300">XAIgen-cline</span> becomes <span class="text-zinc-300">cline</span>). (<span class="text-zinc-300">XAIgen-cline</span> becomes <span class="text-zinc-300">cline</span>).
Activation is the word or phrase the <span class="text-zinc-300">i</span> on a LoRA slider inserts into the prompt. Activation is the word or phrase the <span class="text-zinc-300">i</span> on a LoRA slider inserts into the prompt.
</p> </p>
</div> </div>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<input
v-model="loraQuery"
type="search"
class="min-w-0 flex-1 rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
placeholder="Filter by file, key, or activation…"
>
<div class="inline-flex shrink-0 rounded-full border border-white/10 p-0.5">
<button
v-for="option in loraKindOptions"
:key="option.id"
type="button"
class="rounded-full px-2.5 py-1.5 text-[11px]"
:class="loraKind === option.id ? 'bg-amber-400/15 text-amber-100' : 'text-zinc-500 hover:text-zinc-300'"
@click="loraKind = option.id"
>{{ option.label }}</button>
</div>
</div>
<p v-if="loraRowsLoading" class="text-sm text-zinc-500">Loading LoRAs…</p> <p v-if="loraRowsLoading" class="text-sm text-zinc-500">Loading LoRAs…</p>
<p v-else-if="!loraRows.length" class="text-sm text-zinc-500">No LoRA files found on Comfy yet.</p> <p v-else-if="!loraRows.length" class="text-sm text-zinc-500">No LoRA files found on Comfy yet.</p>
<p v-else class="text-xs text-zinc-500">
Showing {{ filteredLoraRows.length }} of {{ loraRows.length }}
</p>
<p v-if="!loraRowsLoading && loraRows.length && !filteredLoraRows.length" class="text-sm text-zinc-500">No LoRAs match that filter.</p>
<div <div
v-for="row in loraRows" v-for="row in filteredLoraRows"
:key="row.key" :key="row.key"
class="flex flex-col gap-2 rounded-2xl border border-white/10 bg-zinc-950/50 px-4 py-3 sm:flex-row sm:items-center" class="flex flex-col gap-2 rounded-2xl border border-white/10 bg-zinc-950/50 px-4 py-3 sm:flex-row sm:items-center"
> >
@@ -198,9 +224,9 @@
</div> </div>
</section> </section>
<section id="settings-keeps" class="scroll-mt-24 space-y-4"> <section v-if="activePanel === 'keeps'" class="glass space-y-4 rounded-3xl p-5 sm:p-6">
<div> <div>
<h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">Keep prompts</h3> <h2 class="font-display text-xl font-bold">Keep prompts</h2>
<p class="mt-1 text-sm text-zinc-400"> <p class="mt-1 text-sm text-zinc-400">
Saved snippets for the + button next to the studio prompt. Video and image lists are separate. Saved snippets for the + button next to the studio prompt. Video and image lists are separate.
</p> </p>
@@ -255,9 +281,9 @@
</div> </div>
</section> </section>
<section id="settings-prompts" class="scroll-mt-24 space-y-3"> <section v-if="activePanel === 'prompts'" class="glass space-y-3 rounded-3xl p-5 sm:p-6">
<div> <div>
<h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">Prompt helper</h3> <h2 class="font-display text-xl font-bold">Prompt helper</h2>
<p class="mt-1 text-sm text-zinc-400"> <p class="mt-1 text-sm text-zinc-400">
Instruction sets sent to Beast Qwen VL with your still and draft. Use these later for multi-shot video scripts. Instruction sets sent to Beast Qwen VL with your still and draft. Use these later for multi-shot video scripts.
</p> </p>
@@ -342,6 +368,12 @@ export interface SettingsFolder {
unlocked: boolean unlocked: boolean
} }
type SettingsPanel = 'gpu' | 'storage' | 'login' | 'folders' | 'loras' | 'keeps' | 'prompts'
type LoraKindFilter = 'all' | 'image' | 'video'
const route = useRoute()
const router = useRouter()
const folders = ref<SettingsFolder[]>([]) const folders = ref<SettingsFolder[]>([])
const authMode = ref('') const authMode = ref('')
const userName = ref('') const userName = ref('')
@@ -349,17 +381,36 @@ const notice = ref('')
const noticeError = ref(false) const noticeError = ref(false)
let noticeTimer: ReturnType<typeof setTimeout> | null = null let noticeTimer: ReturnType<typeof setTimeout> | null = null
const panelIds: SettingsPanel[] = ['gpu', 'storage', 'login', 'folders', 'loras', 'keeps', 'prompts']
function parsePanel(raw: unknown): SettingsPanel {
const value = String(raw || '')
return panelIds.includes(value as SettingsPanel) ? value as SettingsPanel : 'gpu'
}
const activePanel = computed({
get() {
const id = parsePanel(route.query.panel)
if (id === 'login' && authMode.value && authMode.value !== 'password') return 'gpu'
return id
},
set(id: SettingsPanel) {
const query = { ...route.query, panel: id }
void router.replace({ query })
}
})
const navItems = computed(() => { const navItems = computed(() => {
const items = [ const items: Array<{ id: SettingsPanel; label: string }> = [
{ id: 'settings-gpu', label: 'GPU' }, { id: 'gpu', label: 'GPU' },
{ id: 'settings-storage', label: 'Storage' } { id: 'storage', label: 'Storage' }
] ]
if (authMode.value === 'password') items.push({ id: 'settings-login', label: 'Site login' }) if (authMode.value === 'password') items.push({ id: 'login', label: 'Site login' })
items.push( items.push(
{ id: 'settings-folders', label: 'Folders' }, { id: 'folders', label: 'Folders' },
{ id: 'settings-loras', label: 'LoRA keys' }, { id: 'loras', label: 'LoRA keys' },
{ id: 'settings-keeps', label: 'Keep prompts' }, { id: 'keeps', label: 'Keep prompts' },
{ id: 'settings-prompts', label: 'Prompt helper' } { id: 'prompts', label: 'Prompt helper' }
) )
return items return items
}) })
@@ -379,6 +430,23 @@ const newKeep = reactive<Record<KeepPromptKind, { label: string; text: string }>
const promptPresets = ref<Array<{ id: string; name: string; description?: string; builtin?: boolean; systemPrompt?: string }>>([]) const promptPresets = ref<Array<{ id: string; name: string; description?: string; builtin?: boolean; systemPrompt?: string }>>([])
const loraRowsLoading = ref(true) const loraRowsLoading = ref(true)
const loraRows = ref<Array<{ key: string; file: string; kind: 'image' | 'video' | 'both'; draft: string; trigger: string; placeholder: string }>>([]) const loraRows = ref<Array<{ key: string; file: string; kind: 'image' | 'video' | 'both'; draft: string; trigger: string; placeholder: string }>>([])
const loraQuery = ref('')
const loraKind = ref<LoraKindFilter>('all')
const loraKindOptions: Array<{ id: LoraKindFilter; label: string }> = [
{ id: 'all', label: 'All' },
{ id: 'image', label: 'Image' },
{ id: 'video', label: 'Video' }
]
const filteredLoraRows = computed(() => {
const q = loraQuery.value.trim().toLowerCase()
return loraRows.value.filter((row) => {
if (loraKind.value === 'image' && row.kind === 'video') return false
if (loraKind.value === 'video' && row.kind === 'image') return false
if (!q) return true
const hay = `${row.file} ${row.draft} ${row.trigger} ${row.key} ${row.placeholder}`.toLowerCase()
return hay.includes(q)
})
})
const loginUser = ref('') const loginUser = ref('')
const loginCurrent = ref('') const loginCurrent = ref('')
const loginNext = ref('') const loginNext = ref('')
@@ -468,6 +536,11 @@ onMounted(() => {
void loadLoraKeys() void loadLoraKeys()
}) })
watch(activePanel, () => {
if (typeof window === 'undefined') return
window.scrollTo({ top: 0 })
})
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (noticeTimer) clearTimeout(noticeTimer) if (noticeTimer) clearTimeout(noticeTimer)
}) })
+1 -1
View File
@@ -6,7 +6,7 @@
<div class="mb-8"> <div class="mb-8">
<h1 class="font-display text-2xl font-bold">Settings</h1> <h1 class="font-display text-2xl font-bold">Settings</h1>
<p class="mt-1 max-w-2xl text-sm text-zinc-400"> <p class="mt-1 max-w-2xl text-sm text-zinc-400">
GPU, storage, folders, LoRA keys, keep-prompt snippets, and prompt-helper instruction sets for this account. Pick a panel on the left. LoRA keys can be filtered by name, alias, or activation word.
</p> </p>
</div> </div>
<SettingsForm /> <SettingsForm />