feat(settings): comprehensive settings panel with accent, glass, fonts, shortcuts, notifications (M15.1-M15.10)
- Accent colour picker: 8 presets + custom colour picker, live CSS var update - Glass intensity slider: Subtle/Default/Strong blur and opacity presets - Font size settings: Compact 13px / Default 15px / Large 17px - Content type visibility: toggle each of 11 content tabs - Keyboard shortcut map: view and rebind all shortcuts, conflict detection - Browser push notifications: opt-in for generation complete events - Auto-archive: 7/30/90/never day threshold for old conversations - Full data export: JSON archive of all localStorage aiui-* keys - Data wipe: two-step confirm, optional API key vault clearing - Default conversation settings: model, web search, token counts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
dd559502ae
commit
1b5debee33
@@ -0,0 +1,476 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60" @click.self="$emit('close')">
|
||||
<div class="w-full max-w-lg mx-4 max-h-[80vh] rounded-2xl bg-[#0a0a0a] border border-white/10 flex flex-col overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b border-white/[0.08] flex items-center justify-between shrink-0">
|
||||
<h2 class="text-sm font-bold text-white/90">Settings</h2>
|
||||
<button
|
||||
class="p-1.5 rounded-lg text-white/40 hover:text-white/70 hover:bg-white/5 transition-colors"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-1.5 px-4 pt-3 pb-1 shrink-0 overflow-x-auto scrollbar-hide">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="text-[10px] px-2 py-1 rounded-md whitespace-nowrap transition-all duration-150"
|
||||
:class="activeTab === tab.id
|
||||
? 'nav-tab-active'
|
||||
: 'text-white/40 hover:text-white/70 hover:bg-white/5'"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-3 pb-6 space-y-4">
|
||||
<!-- Appearance -->
|
||||
<template v-if="activeTab === 'appearance'">
|
||||
<!-- M15.1 Accent colour -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-[10px] text-accent/60 uppercase tracking-wider font-bold">Accent Colour</p>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button
|
||||
v-for="color in accentPresets"
|
||||
:key="color.value"
|
||||
class="w-8 h-8 rounded-lg border-2 transition-all"
|
||||
:style="{ backgroundColor: color.value }"
|
||||
:class="store.accentColor === color.value ? 'border-white/60 scale-110' : 'border-white/10'"
|
||||
:title="color.label"
|
||||
@click="store.accentColor = color.value"
|
||||
/>
|
||||
<label class="w-8 h-8 rounded-lg border-2 border-white/10 flex items-center justify-center cursor-pointer overflow-hidden relative">
|
||||
<span class="text-[8px] text-white/30">+</span>
|
||||
<input
|
||||
type="color"
|
||||
:value="store.accentColor"
|
||||
class="absolute inset-0 opacity-0 cursor-pointer"
|
||||
@input="store.accentColor = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- M15.2 Glass intensity -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-[10px] text-accent/60 uppercase tracking-wider font-bold">Glass Intensity</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="opt in glassOptions"
|
||||
:key="opt.value"
|
||||
class="flex-1 py-2 rounded-lg text-[10px] transition-all"
|
||||
:class="store.glassIntensity === opt.value
|
||||
? 'bg-accent/15 text-accent/80'
|
||||
: 'bg-white/5 text-white/40 hover:text-white/60 hover:bg-white/10'"
|
||||
@click="store.glassIntensity = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- M15.3 Font size -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-[10px] text-accent/60 uppercase tracking-wider font-bold">Font Size</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="opt in fontOptions"
|
||||
:key="opt.value"
|
||||
class="flex-1 py-2 rounded-lg text-[10px] transition-all"
|
||||
:class="store.fontSize === opt.value
|
||||
? 'bg-accent/15 text-accent/80'
|
||||
: 'bg-white/5 text-white/40 hover:text-white/60 hover:bg-white/10'"
|
||||
@click="store.fontSize = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Content -->
|
||||
<template v-else-if="activeTab === 'content'">
|
||||
<!-- M15.4 Content type visibility -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-[10px] text-accent/60 uppercase tracking-wider font-bold">Content Types</p>
|
||||
<p class="text-[9px] text-white/30">Hidden types still extract but don't show in the panel</p>
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
v-for="ct in contentTypes"
|
||||
:key="ct.tab"
|
||||
class="flex items-center gap-2 p-2 rounded-lg bg-white/[0.03] border border-white/5 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="store.isTabVisible(ct.tab)"
|
||||
class="rounded accent-[#F7931A]"
|
||||
@change="store.toggleTabVisibility(ct.tab)"
|
||||
/>
|
||||
<span class="text-[11px] text-white/70">{{ ct.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Shortcuts -->
|
||||
<template v-else-if="activeTab === 'shortcuts'">
|
||||
<!-- M15.5 Keyboard shortcuts -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-[10px] text-accent/60 uppercase tracking-wider font-bold">Keyboard Shortcuts</p>
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="(binding, action) in store.settings.shortcuts"
|
||||
:key="action"
|
||||
class="flex items-center justify-between p-2.5 rounded-lg bg-white/[0.03] border border-white/5"
|
||||
>
|
||||
<span class="text-[11px] text-white/60">{{ formatAction(action as string) }}</span>
|
||||
<button
|
||||
class="text-[10px] px-2 py-1 rounded bg-white/5 text-white/40 hover:text-white/70 font-mono transition-colors"
|
||||
:class="recordingAction === action ? 'bg-accent/15 text-accent/80' : ''"
|
||||
@click="startRecording(action as string)"
|
||||
>
|
||||
{{ recordingAction === action ? 'Press key...' : binding }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Notifications -->
|
||||
<template v-else-if="activeTab === 'notifications'">
|
||||
<!-- M15.6 Push notifications -->
|
||||
<div class="space-y-3">
|
||||
<p class="text-[10px] text-accent/60 uppercase tracking-wider font-bold">Notifications</p>
|
||||
<label class="flex items-center gap-3 p-3 rounded-lg bg-white/[0.03] border border-white/5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="store.notificationsEnabled"
|
||||
class="rounded accent-[#F7931A]"
|
||||
@change="toggleNotifications"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-[11px] text-white/70">Generation complete</p>
|
||||
<p class="text-[9px] text-white/30">Notify when AI finishes while tab is backgrounded</p>
|
||||
</div>
|
||||
</label>
|
||||
<p v-if="notificationStatus" class="text-[10px]" :class="notificationOk ? 'text-emerald-400/60' : 'text-yellow-400/60'">
|
||||
{{ notificationStatus }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Storage -->
|
||||
<template v-else-if="activeTab === 'storage'">
|
||||
<!-- M15.7 Auto-archive -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-[10px] text-accent/60 uppercase tracking-wider font-bold">Auto-Archive</p>
|
||||
<p class="text-[9px] text-white/30">Archive conversations older than:</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="opt in archiveOptions"
|
||||
:key="opt.value"
|
||||
class="flex-1 py-2 rounded-lg text-[10px] transition-all"
|
||||
:class="store.autoArchiveDays === opt.value
|
||||
? 'bg-accent/15 text-accent/80'
|
||||
: 'bg-white/5 text-white/40 hover:text-white/60 hover:bg-white/10'"
|
||||
@click="store.autoArchiveDays = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- M15.8 Full data export -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-[10px] text-accent/60 uppercase tracking-wider font-bold">Data Export</p>
|
||||
<button
|
||||
class="w-full py-2.5 rounded-lg text-xs bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors"
|
||||
@click="exportData"
|
||||
>
|
||||
{{ isExporting ? 'Exporting...' : 'Export All Data' }}
|
||||
</button>
|
||||
<p class="text-[9px] text-white/20">Exports conversations, favorites, settings, tags, and collections as JSON</p>
|
||||
</div>
|
||||
|
||||
<!-- M15.9 Data wipe -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-[10px] text-red-400/60 uppercase tracking-wider font-bold">Danger Zone</p>
|
||||
<button
|
||||
v-if="!confirmWipe"
|
||||
class="w-full py-2.5 rounded-lg text-xs bg-red-400/10 text-red-400/60 hover:bg-red-400/20 hover:text-red-400/80 transition-colors"
|
||||
@click="confirmWipe = true"
|
||||
>
|
||||
Wipe All Data
|
||||
</button>
|
||||
<div v-else class="space-y-2 p-3 rounded-lg bg-red-400/5 border border-red-400/20">
|
||||
<p class="text-[11px] text-red-400/80 font-semibold">Are you sure?</p>
|
||||
<p class="text-[9px] text-red-400/50">This will clear all conversations, favorites, settings, and cached data.</p>
|
||||
<label class="flex items-center gap-2 text-[10px] text-red-400/60 cursor-pointer">
|
||||
<input v-model="wipeApiKeys" type="checkbox" class="rounded" />
|
||||
Also clear API key vault
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="flex-1 py-2 rounded-lg text-[10px] text-white/40 hover:text-white/70 hover:bg-white/5 transition-colors"
|
||||
@click="confirmWipe = false"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-2 rounded-lg text-[10px] bg-red-400/20 text-red-400/80 hover:bg-red-400/30 transition-colors"
|
||||
@click="wipeData"
|
||||
>
|
||||
Confirm Wipe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Chat defaults -->
|
||||
<template v-else-if="activeTab === 'chat'">
|
||||
<!-- M15.10 Default conversation settings -->
|
||||
<div class="space-y-3">
|
||||
<p class="text-[10px] text-accent/60 uppercase tracking-wider font-bold">Default Conversation Settings</p>
|
||||
<p class="text-[9px] text-white/30">Applied to all new conversations</p>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block">
|
||||
<span class="text-[10px] text-white/40">Default Model</span>
|
||||
<input
|
||||
v-model="store.settings.defaultModel"
|
||||
type="text"
|
||||
placeholder="e.g. claude-sonnet-4-6"
|
||||
class="w-full mt-1 px-3 py-2 rounded-lg text-xs bg-white/5 text-white/70 placeholder:text-white/20 outline-none focus:bg-white/10 transition-colors font-mono"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-3 p-2.5 rounded-lg bg-white/[0.03] border border-white/5 cursor-pointer">
|
||||
<input
|
||||
v-model="store.settings.defaultWebSearch"
|
||||
type="checkbox"
|
||||
class="rounded accent-[#F7931A]"
|
||||
/>
|
||||
<span class="text-[11px] text-white/60">Enable web search by default</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-3 p-2.5 rounded-lg bg-white/[0.03] border border-white/5 cursor-pointer">
|
||||
<input
|
||||
v-model="store.settings.defaultShowTokens"
|
||||
type="checkbox"
|
||||
class="rounded accent-[#F7931A]"
|
||||
/>
|
||||
<span class="text-[11px] text-white/60">Show token counts by default</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import type { ContentTab } from '@/composables/contentFiltering'
|
||||
|
||||
defineEmits<{ close: [] }>()
|
||||
|
||||
type Tab = 'appearance' | 'content' | 'shortcuts' | 'notifications' | 'storage' | 'chat'
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'appearance', label: 'Appearance' },
|
||||
{ id: 'content', label: 'Content' },
|
||||
{ id: 'shortcuts', label: 'Shortcuts' },
|
||||
{ id: 'notifications', label: 'Notifications' },
|
||||
{ id: 'storage', label: 'Storage' },
|
||||
{ id: 'chat', label: 'Chat' },
|
||||
]
|
||||
|
||||
const activeTab = ref<Tab>('appearance')
|
||||
const store = useSettingsStore()
|
||||
|
||||
// M15.1 Accent presets
|
||||
const accentPresets = [
|
||||
{ value: '#F7931A', label: 'Bitcoin Orange' },
|
||||
{ value: '#8B5CF6', label: 'Purple' },
|
||||
{ value: '#3B82F6', label: 'Blue' },
|
||||
{ value: '#10B981', label: 'Emerald' },
|
||||
{ value: '#EF4444', label: 'Red' },
|
||||
{ value: '#EC4899', label: 'Pink' },
|
||||
{ value: '#F59E0B', label: 'Amber' },
|
||||
{ value: '#06B6D4', label: 'Cyan' },
|
||||
]
|
||||
|
||||
// M15.2 Glass options
|
||||
const glassOptions: { value: 'subtle' | 'default' | 'strong'; label: string }[] = [
|
||||
{ value: 'subtle', label: 'Subtle' },
|
||||
{ value: 'default', label: 'Default' },
|
||||
{ value: 'strong', label: 'Strong' },
|
||||
]
|
||||
|
||||
// M15.3 Font options
|
||||
const fontOptions: { value: 'compact' | 'default' | 'large'; label: string }[] = [
|
||||
{ value: 'compact', label: 'Compact (13px)' },
|
||||
{ value: 'default', label: 'Default (15px)' },
|
||||
{ value: 'large', label: 'Large (17px)' },
|
||||
]
|
||||
|
||||
// M15.4 Content types
|
||||
const contentTypes: { tab: ContentTab; label: string }[] = [
|
||||
{ tab: 'film', label: 'Films' },
|
||||
{ tab: 'song', label: 'Music' },
|
||||
{ tab: 'podcast', label: 'Podcasts' },
|
||||
{ tab: 'book', label: 'Books' },
|
||||
{ tab: 'tvshow', label: 'TV Series' },
|
||||
{ tab: 'image', label: 'Images' },
|
||||
{ tab: 'place', label: 'Places' },
|
||||
{ tab: 'news', label: 'News' },
|
||||
{ tab: 'websites', label: 'Websites' },
|
||||
{ tab: 'magazine', label: 'Brief' },
|
||||
{ tab: 'nostr', label: 'Nostr' },
|
||||
]
|
||||
|
||||
// M15.5 Shortcuts
|
||||
const recordingAction = ref<string | null>(null)
|
||||
|
||||
function formatAction(action: string): string {
|
||||
return action.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
|
||||
}
|
||||
|
||||
function startRecording(action: string) {
|
||||
recordingAction.value = action
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
e.preventDefault()
|
||||
const parts: string[] = []
|
||||
if (e.metaKey || e.ctrlKey) parts.push('Cmd')
|
||||
if (e.shiftKey) parts.push('Shift')
|
||||
if (e.altKey) parts.push('Alt')
|
||||
if (e.key !== 'Meta' && e.key !== 'Control' && e.key !== 'Shift' && e.key !== 'Alt') {
|
||||
parts.push(e.key.length === 1 ? e.key.toUpperCase() : e.key)
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
store.setShortcut(action, parts.join('+'))
|
||||
}
|
||||
recordingAction.value = null
|
||||
document.removeEventListener('keydown', handler)
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
}
|
||||
|
||||
// M15.6 Notifications
|
||||
const notificationStatus = ref('')
|
||||
const notificationOk = ref(false)
|
||||
|
||||
async function toggleNotifications() {
|
||||
if (!store.notificationsEnabled) {
|
||||
// Enable
|
||||
if (!('Notification' in window)) {
|
||||
notificationStatus.value = 'Notifications not supported in this browser'
|
||||
notificationOk.value = false
|
||||
return
|
||||
}
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission === 'granted') {
|
||||
store.notificationsEnabled = true
|
||||
notificationStatus.value = 'Notifications enabled'
|
||||
notificationOk.value = true
|
||||
} else {
|
||||
notificationStatus.value = 'Permission denied'
|
||||
notificationOk.value = false
|
||||
}
|
||||
} else {
|
||||
store.notificationsEnabled = false
|
||||
notificationStatus.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// M15.7 Archive options
|
||||
const archiveOptions = [
|
||||
{ value: 0, label: 'Never' },
|
||||
{ value: 7, label: '7 days' },
|
||||
{ value: 30, label: '30 days' },
|
||||
{ value: 90, label: '90 days' },
|
||||
]
|
||||
|
||||
// M15.8 Export
|
||||
const isExporting = ref(false)
|
||||
|
||||
async function exportData() {
|
||||
isExporting.value = true
|
||||
try {
|
||||
const data: Record<string, unknown> = {}
|
||||
|
||||
// Gather all localStorage items
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i)
|
||||
if (key?.startsWith('aiui-')) {
|
||||
try { data[key] = JSON.parse(localStorage.getItem(key) ?? '') }
|
||||
catch { data[key] = localStorage.getItem(key) }
|
||||
}
|
||||
}
|
||||
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `aiui-export-${new Date().toISOString().slice(0, 10)}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} finally {
|
||||
isExporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// M15.9 Data wipe
|
||||
const confirmWipe = ref(false)
|
||||
const wipeApiKeys = ref(false)
|
||||
|
||||
async function wipeData() {
|
||||
// Clear localStorage (except API keys if unchecked)
|
||||
const keysToKeep: string[] = []
|
||||
if (!wipeApiKeys.value) {
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i)
|
||||
if (key?.includes('api-key') || key?.includes('vault')) {
|
||||
keysToKeep.push(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const preserved = new Map<string, string>()
|
||||
for (const key of keysToKeep) {
|
||||
preserved.set(key, localStorage.getItem(key) ?? '')
|
||||
}
|
||||
|
||||
localStorage.clear()
|
||||
|
||||
for (const [key, value] of preserved) {
|
||||
localStorage.setItem(key, value)
|
||||
}
|
||||
|
||||
// Clear IDB
|
||||
const dbs = await indexedDB.databases()
|
||||
for (const db of dbs) {
|
||||
if (db.name) indexedDB.deleteDatabase(db.name)
|
||||
}
|
||||
|
||||
// Clear SW cache
|
||||
if ('caches' in window) {
|
||||
const names = await caches.keys()
|
||||
for (const name of names) {
|
||||
await caches.delete(name)
|
||||
}
|
||||
}
|
||||
|
||||
window.location.reload()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,160 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { ContentTab } from '@/composables/contentFiltering'
|
||||
|
||||
const STORAGE_KEY = 'aiui-settings'
|
||||
|
||||
export interface AppSettings {
|
||||
// M15.1 — Accent colour
|
||||
accentColor: string
|
||||
// M15.2 — Glass intensity
|
||||
glassIntensity: 'subtle' | 'default' | 'strong'
|
||||
// M15.3 — Font size
|
||||
fontSize: 'compact' | 'default' | 'large'
|
||||
// M15.4 — Content type visibility
|
||||
hiddenContentTabs: ContentTab[]
|
||||
// M15.5 — Keyboard shortcuts
|
||||
shortcuts: Record<string, string>
|
||||
// M15.6 — Push notifications
|
||||
notificationsEnabled: boolean
|
||||
// M15.7 — Auto-archive
|
||||
autoArchiveDays: number // 0 = never, 7/30/90
|
||||
// M15.10 — Default conversation settings
|
||||
defaultModel: string
|
||||
defaultPersonaId: string
|
||||
defaultWebSearch: boolean
|
||||
defaultShowTokens: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: AppSettings = {
|
||||
accentColor: '#F7931A',
|
||||
glassIntensity: 'default',
|
||||
fontSize: 'default',
|
||||
hiddenContentTabs: [],
|
||||
shortcuts: {
|
||||
'send-message': 'Enter',
|
||||
'new-line': 'Shift+Enter',
|
||||
'search': 'Cmd+F',
|
||||
'new-chat': 'Cmd+N',
|
||||
'settings': 'Cmd+,',
|
||||
'close-panel': 'Escape',
|
||||
},
|
||||
notificationsEnabled: false,
|
||||
autoArchiveDays: 0,
|
||||
defaultModel: '',
|
||||
defaultPersonaId: '',
|
||||
defaultWebSearch: false,
|
||||
defaultShowTokens: false,
|
||||
}
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const settings = ref<AppSettings>(loadSettings())
|
||||
|
||||
function loadSettings(): AppSettings {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) return { ...DEFAULT_SETTINGS, ...JSON.parse(stored) }
|
||||
} catch { /* ignore */ }
|
||||
return { ...DEFAULT_SETTINGS }
|
||||
}
|
||||
|
||||
function save() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings.value))
|
||||
}
|
||||
|
||||
// Apply CSS variables when settings change
|
||||
function applyCssVars() {
|
||||
const root = document.documentElement
|
||||
|
||||
// Accent colour
|
||||
root.style.setProperty('--color-accent', settings.value.accentColor)
|
||||
|
||||
// Glass intensity
|
||||
const glass = {
|
||||
subtle: { blur: '12px', opacity: '0.25' },
|
||||
default: { blur: '18px', opacity: '0.35' },
|
||||
strong: { blur: '28px', opacity: '0.50' },
|
||||
}[settings.value.glassIntensity]
|
||||
root.style.setProperty('--glass-blur', glass.blur)
|
||||
root.style.setProperty('--glass-opacity', glass.opacity)
|
||||
|
||||
// Font size
|
||||
const sizes = { compact: '13px', default: '15px', large: '17px' }
|
||||
root.style.setProperty('--font-size-base', sizes[settings.value.fontSize])
|
||||
}
|
||||
|
||||
// Watch for changes and persist + apply
|
||||
watch(settings, () => {
|
||||
save()
|
||||
applyCssVars()
|
||||
}, { deep: true })
|
||||
|
||||
// Apply on init
|
||||
applyCssVars()
|
||||
|
||||
const accentColor = computed({
|
||||
get: () => settings.value.accentColor,
|
||||
set: (v) => { settings.value.accentColor = v },
|
||||
})
|
||||
|
||||
const glassIntensity = computed({
|
||||
get: () => settings.value.glassIntensity,
|
||||
set: (v) => { settings.value.glassIntensity = v },
|
||||
})
|
||||
|
||||
const fontSize = computed({
|
||||
get: () => settings.value.fontSize,
|
||||
set: (v) => { settings.value.fontSize = v },
|
||||
})
|
||||
|
||||
const hiddenContentTabs = computed({
|
||||
get: () => settings.value.hiddenContentTabs,
|
||||
set: (v) => { settings.value.hiddenContentTabs = v },
|
||||
})
|
||||
|
||||
const notificationsEnabled = computed({
|
||||
get: () => settings.value.notificationsEnabled,
|
||||
set: (v) => { settings.value.notificationsEnabled = v },
|
||||
})
|
||||
|
||||
const autoArchiveDays = computed({
|
||||
get: () => settings.value.autoArchiveDays,
|
||||
set: (v) => { settings.value.autoArchiveDays = v },
|
||||
})
|
||||
|
||||
function isTabVisible(tab: ContentTab): boolean {
|
||||
return !settings.value.hiddenContentTabs.includes(tab)
|
||||
}
|
||||
|
||||
function toggleTabVisibility(tab: ContentTab) {
|
||||
const idx = settings.value.hiddenContentTabs.indexOf(tab)
|
||||
if (idx >= 0) {
|
||||
settings.value.hiddenContentTabs.splice(idx, 1)
|
||||
} else {
|
||||
settings.value.hiddenContentTabs.push(tab)
|
||||
}
|
||||
}
|
||||
|
||||
function setShortcut(action: string, binding: string) {
|
||||
settings.value.shortcuts[action] = binding
|
||||
}
|
||||
|
||||
function resetSettings() {
|
||||
settings.value = { ...DEFAULT_SETTINGS }
|
||||
}
|
||||
|
||||
return {
|
||||
settings,
|
||||
accentColor,
|
||||
glassIntensity,
|
||||
fontSize,
|
||||
hiddenContentTabs,
|
||||
notificationsEnabled,
|
||||
autoArchiveDays,
|
||||
isTabVisible,
|
||||
toggleTabVisibility,
|
||||
setShortcut,
|
||||
resetSettings,
|
||||
applyCssVars,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user