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:
Dorian
2026-03-04 00:52:29 +00:00
co-authored by Claude Opus 4.6
parent dd559502ae
commit 1b5debee33
2 changed files with 636 additions and 0 deletions
@@ -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>
+160
View File
@@ -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,
}
})