First commit

This commit is contained in:
2026-09-30 01:49:35 +02:00
commit 6edd03aace
109 changed files with 27020 additions and 0 deletions

View File

@@ -0,0 +1,229 @@
<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, the accent colour, and the interface language — which
drive the data-theme and data-accent attributes that the pre-paint script
in nuxt.config.ts restores from localStorage. The language lives in the
mathew-locale cookie and, with an account behind the session, on the
account itself, so it follows the person across devices. */
const { open, hide } = useSettings()
const { hide: hideSearch } = useSearch()
const { hide: hideSignIn } = useSignInPopup()
const { hide: hideProfile } = useProfileMenu()
const { loggedIn, updateLocale } = useAuth()
const { t, locale } = useI18n()
const { choose } = useAppLocale()
const route = useRoute()
const theme = ref<'light' | 'dark'>('light')
const accent = ref('violet')
const language = ref('en')
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 + language 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'
language.value = locale.value
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 */
}
}
/* Turn the interface language. The device's own choice (the cookie setLocale
keeps) lands at once; with an account it is saved there too, so the next
device says the same hello. An account that won't take it — a session gone
quiet — doesn't undo the switch made here. */
async function setLanguage(code: string) {
if (language.value === code) return
language.value = code
await choose(code)
if (loggedIn.value) {
try {
await updateLocale(code)
} catch {
/* the device-level switch stands on its own */
}
}
}
/* --- 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>
<section class="settings-group" aria-labelledby="settings-language">
<div class="settings-group-head">
<span id="settings-language" class="settings-group-title">{{ t('settings.language') }}</span>
<span class="settings-group-hint">{{ t('settings.languageHint') }}</span>
</div>
<div class="theme-switch" role="radiogroup" :aria-label="t('settings.languageAria')">
<button
v-for="l in APP_LANGUAGES"
:key="l.code"
type="button"
role="radio"
class="theme-option"
:class="{ active: language === l.code }"
:aria-checked="language === l.code"
:lang="l.code"
@click="setLanguage(l.code)"
>
<span>{{ l.name }}</span>
</button>
</div>
</section>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>