- 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>
477 lines
18 KiB
Vue
477 lines
18 KiB
Vue
<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>
|