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