First commit
This commit is contained in:
229
frontend/app/components/SettingsModal.vue
Normal file
229
frontend/app/components/SettingsModal.vue
Normal 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>
|
||||
Reference in New Issue
Block a user