185 lines
7.3 KiB
Vue
185 lines
7.3 KiB
Vue
<script setup lang="ts">
|
|
/* Centered settings popup, opened by the sidebar cog (and closed with esc,
|
|
a backdrop click, or navigation). It holds the preferences — the light /
|
|
dark theme switch and the accent colour — which drive the data-theme and
|
|
data-accent attributes that the pre-paint script in nuxt.config.ts
|
|
restores from localStorage. The interface language lives in the profile
|
|
popup instead, since it follows the person across devices. */
|
|
const { open, hide } = useSettings()
|
|
const { hide: hideSearch } = useSearch()
|
|
const { hide: hideSignIn } = useSignInPopup()
|
|
const { hide: hideProfile } = useProfileMenu()
|
|
const { t } = useI18n()
|
|
const route = useRoute()
|
|
|
|
const theme = ref<'light' | 'dark'>('light')
|
|
const accent = ref('violet')
|
|
const panelEl = ref<HTMLElement | null>(null)
|
|
|
|
const themes = [
|
|
{ value: 'light', labelKey: 'themeLight' },
|
|
{ value: 'dark', labelKey: 'themeDark' },
|
|
] as const
|
|
|
|
/* swatch previews use the light-mode pair; the real tokens come from the
|
|
[data-accent] blocks in main.scss (violet = default, no attribute) */
|
|
const accents = [
|
|
{ value: 'violet', labelKey: 'accentViolet', from: '#4f5fe8', to: '#0ea5c9' },
|
|
{ value: 'purple', labelKey: 'accentPurple', from: '#7c3aed', to: '#c026d3' },
|
|
{ value: 'ocean', labelKey: 'accentOcean', from: '#2563eb', to: '#06b6d4' },
|
|
{ value: 'teal', labelKey: 'accentTeal', from: '#0d9488', to: '#10b981' },
|
|
{ value: 'forest', labelKey: 'accentForest', from: '#16a34a', to: '#84cc16' },
|
|
{ value: 'amber', labelKey: 'accentAmber', from: '#d97706', to: '#f97316' },
|
|
{ value: 'rose', labelKey: 'accentRose', from: '#e11d48', to: '#ec4899' },
|
|
{ value: 'slate', labelKey: 'accentSlate', from: '#334155', to: '#64748b' },
|
|
] as const
|
|
|
|
/* read the live theme + accent whenever the popup opens so the
|
|
switches are in sync */
|
|
watch(
|
|
open,
|
|
async (isOpen) => {
|
|
if (!isOpen) return
|
|
// opening settings shouldn't leave another popup stacked behind it
|
|
hideSearch()
|
|
hideSignIn()
|
|
hideProfile()
|
|
theme.value =
|
|
(document.documentElement.getAttribute('data-theme') as 'light' | 'dark') || 'light'
|
|
accent.value = document.documentElement.getAttribute('data-accent') || 'violet'
|
|
await nextTick()
|
|
panelEl.value?.focus()
|
|
},
|
|
{ flush: 'post' }
|
|
)
|
|
|
|
function setTheme(value: 'light' | 'dark') {
|
|
theme.value = value
|
|
document.documentElement.setAttribute('data-theme', value)
|
|
try {
|
|
localStorage.setItem('mathew-theme', value)
|
|
} catch {
|
|
/* storage unavailable */
|
|
}
|
|
}
|
|
|
|
function setAccent(value: string) {
|
|
accent.value = value
|
|
if (value === 'violet') document.documentElement.removeAttribute('data-accent')
|
|
else document.documentElement.setAttribute('data-accent', value)
|
|
try {
|
|
localStorage.setItem('mathew-accent', value)
|
|
} catch {
|
|
/* storage unavailable */
|
|
}
|
|
}
|
|
|
|
/* --- esc closes, navigation closes --- */
|
|
function onGlobalKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Escape' && open.value) hide()
|
|
}
|
|
onMounted(() => window.addEventListener('keydown', onGlobalKeydown))
|
|
onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
|
|
|
|
watch(
|
|
() => route.fullPath,
|
|
() => {
|
|
if (open.value) hide()
|
|
}
|
|
)
|
|
</script>
|
|
|
|
<template>
|
|
<Teleport to="body">
|
|
<Transition name="settings-popup">
|
|
<div v-if="open" class="settings-overlay" @click.self="hide()">
|
|
<div
|
|
ref="panelEl"
|
|
class="settings-modal"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
:aria-label="t('settings.title')"
|
|
tabindex="-1"
|
|
>
|
|
<div class="settings-modal-head">
|
|
<h2 class="settings-title">{{ t('settings.title') }}</h2>
|
|
<button
|
|
type="button"
|
|
class="panel-icon-btn"
|
|
:aria-label="t('settings.closeAria')"
|
|
:title="t('common.close')"
|
|
@click="hide()"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
|
|
<path d="M6 6l12 12M18 6L6 18" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="settings-modal-body">
|
|
<section class="settings-group" aria-labelledby="settings-appearance">
|
|
<div class="settings-group-head">
|
|
<span id="settings-appearance" class="settings-group-title">{{ t('settings.appearance') }}</span>
|
|
<span class="settings-group-hint">{{ t('settings.appearanceHint') }}</span>
|
|
</div>
|
|
|
|
<div
|
|
class="theme-switch"
|
|
role="radiogroup"
|
|
:aria-label="t('settings.themeAria')"
|
|
>
|
|
<button
|
|
v-for="opt in themes"
|
|
:key="opt.value"
|
|
type="button"
|
|
role="radio"
|
|
class="theme-option"
|
|
:class="{ active: theme === opt.value }"
|
|
:aria-checked="theme === opt.value"
|
|
@click="setTheme(opt.value)"
|
|
>
|
|
<svg v-if="opt.value === 'light'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
|
<circle cx="12" cy="12" r="4.5" />
|
|
<path d="M12 2v2.5M12 19.5V22M2 12h2.5M19.5 12H22M4.9 4.9l1.8 1.8M17.3 17.3l1.8 1.8M19.1 4.9l-1.8 1.8M6.7 17.3l-1.8 1.8" />
|
|
</svg>
|
|
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z" />
|
|
</svg>
|
|
<span>{{ t(`settings.${opt.labelKey}`) }}</span>
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="settings-group" aria-labelledby="settings-accent">
|
|
<div class="settings-group-head">
|
|
<span id="settings-accent" class="settings-group-title">{{ t('settings.accent') }}</span>
|
|
<span class="settings-group-hint">{{ t('settings.accentHint') }}</span>
|
|
</div>
|
|
|
|
<div class="accent-swatches" role="radiogroup" :aria-label="t('settings.accent')">
|
|
<button
|
|
v-for="a in accents"
|
|
:key="a.value"
|
|
type="button"
|
|
role="radio"
|
|
class="accent-swatch"
|
|
:class="{ active: accent === a.value }"
|
|
:aria-checked="accent === a.value"
|
|
:aria-label="t('settings.accentName', { name: t(`settings.${a.labelKey}`) })"
|
|
:title="t('settings.accentName', { name: t(`settings.${a.labelKey}`) })"
|
|
:style="{ '--swatch': a.from, '--swatch-2': a.to }"
|
|
@click="setAccent(a.value)"
|
|
>
|
|
<svg v-if="accent === a.value" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M5 12.5l4.5 4.5L19 7.5" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|