Files
TREK/client/src/components/Settings/AppearanceSettingsTab.tsx
T
Maurice 3b4f17643c refactor(settings): share the appearance editor between desktop and phone
The live preview, the debounced save with its flush on leave, the colour mode save and the widget toggles live in one useAppearanceEditor hook. The contrast helpers and widget groups move to appearanceModel.
2026-10-09 15:19:48 +02:00

375 lines
18 KiB
TypeScript

import React, { type ReactNode } from 'react'
import { Paintbrush, Eye, LayoutDashboard, Sun, Moon, Monitor, RotateCcw } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { DialogSection, fs } from '../shared/DialogShell'
import { Segmented } from '../shared/dialogParts'
import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
import { SETTINGS_BUTTON, SettingRow, SettingRows, StatusPill } from './settingsKit'
import { APPEARANCE_SCHEMES, CUSTOM_ACCENT_PRESETS } from '../../theme/schemes'
import { APPEARANCE_SCALE_MIN, APPEARANCE_SCALE_MAX } from '@trek/shared'
import { DESKTOP_GROUPS, MOBILE_GROUPS, isHex } from './appearanceModel'
import { useAppearanceEditor } from './useAppearanceEditor'
const WIDGET_LABELS: Record<string, string> = {
sidebar: 'Right sidebar',
currency: 'Currency',
collections: 'Collections',
timezones: 'Timezones',
upcomingReservations: 'Upcoming reservations',
atlas: 'Atlas / countries',
tripsTotal: 'Trips total',
daysTraveled: 'Days traveled',
distanceFlown: 'Distance flown',
}
/** A tile of the scheme grid: a colour dot and a name, raised and outlined while chosen. */
const SWATCH = 'flex min-w-0 items-center gap-2 rounded-[12px] border bg-surface-card px-3 py-2.5 text-start font-medium text-content transition-colors'
const swatchLook = (active: boolean) => active
? 'border-[color:var(--text-primary)] shadow-sm'
: 'border-edge hover:border-content-faint'
/** The round colour sample in a swatch tile. */
const DOT = 'h-4 w-4 flex-none rounded-full shadow-[inset_0_0_0_1px_var(--border-faint)]'
export default function AppearanceSettingsTab(): React.ReactElement {
const { t } = useTranslation()
const tr = (key: string, fallback: string) => t(key) || fallback
const { cfg, darkMode, isDark, update, setMode, setWidget, resetAll, accentLight, accentDark, customRatio } =
useAppearanceEditor()
// The stored mode, read the way the old boolean values meant it.
const cur = darkMode
const mode = cur === true ? 'dark' : cur === false ? 'light' : String(cur ?? '')
return (
<>
{/* ── Theme ───────────────────────────────────────────────── */}
<Section title={tr('settings.appearance.theme', 'Theme')} icon={Paintbrush}>
<SettingRows>
{/* Color mode */}
<SettingRow
label={tr('settings.colorMode', 'Color mode')}
control={
<Segmented
label={tr('settings.colorMode', 'Color mode')}
value={mode}
onChange={setMode}
options={[
{ value: 'light', label: tr('settings.light', 'Light'), icon: <Sun size={14} strokeWidth={2} /> },
{ value: 'dark', label: tr('settings.dark', 'Dark'), icon: <Moon size={14} strokeWidth={2} /> },
{
value: 'auto',
icon: <Monitor size={14} strokeWidth={2} />,
label: (
<>
<span className="hidden sm:inline">{tr('settings.auto', 'Auto')}</span>
<span className="sm:hidden">Auto</span>
</>
),
},
]}
/>
}
/>
{/* Color scheme swatches */}
<SettingRow label={tr('settings.appearance.scheme', 'Color scheme')} stacked>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{APPEARANCE_SCHEMES.map((s) => {
const active = cfg.schemeId === s.id
const dot = isDark ? s.swatch.dark : s.swatch.light
return (
<button type="button" key={s.id} aria-pressed={active}
onClick={() => update({ schemeId: s.id })}
className={`${SWATCH} ${swatchLook(active)}`} style={fs(12.5, 'body')}>
<span className={DOT} style={{ background: dot }} />
<span className="min-w-0 truncate">{tr(`settings.appearance.scheme.${s.id}`, schemeFallback(s.id))}</span>
</button>
)
})}
{/* Custom */}
<button type="button" aria-pressed={cfg.schemeId === 'custom'}
onClick={() => update({ schemeId: 'custom', accent: cfg.accent ?? { light: accentLight, dark: accentDark } })}
className={`${SWATCH} ${swatchLook(cfg.schemeId === 'custom')}`} style={fs(12.5, 'body')}>
<span className={DOT} style={{ background: 'conic-gradient(#ef4444,#f59e0b,#22c55e,#3b82f6,#8b5cf6,#ef4444)' }} /> {/* theme-lint-disable: the rainbow that stands for "any colour" */}
<span className="min-w-0 truncate">{tr('settings.appearance.scheme.custom', 'Custom')}</span>
</button>
</div>
</SettingRow>
{/* Custom accent picker */}
{cfg.schemeId === 'custom' && (
<SettingRow label={tr('settings.appearance.customAccent', 'Custom accent')} stacked>
<div className="flex flex-col gap-3">
<div className="flex flex-wrap gap-2">
{CUSTOM_ACCENT_PRESETS.map((c) => {
const picked = accentLight === c && accentDark === c
return (
<button type="button" key={c} aria-label={c}
onClick={() => update({ accent: { light: c, dark: c } })}
className={`h-7 w-7 flex-none rounded-full ring-offset-2 ring-offset-[color:var(--bg-card)] transition-shadow ${picked ? 'ring-2 ring-[color:var(--text-primary)]' : 'shadow-[inset_0_0_0_1px_var(--border-faint)] hover:ring-2 hover:ring-edge'}`}
style={{ background: c }} />
)
})}
</div>
<div className="flex flex-wrap items-center gap-3">
<ColorField label={tr('settings.light', 'Light')} value={isHex(accentLight) ? accentLight : '#4f46e5'}
onChange={(v) => update({ accent: { light: v, dark: accentDark } })} />
<ColorField label={tr('settings.dark', 'Dark')} value={isHex(accentDark) ? accentDark : '#6366f1'}
onChange={(v) => update({ accent: { light: accentLight, dark: v } })} />
<StatusPill tone={customRatio >= 4.5 ? 'success' : 'warning'}>
{customRatio >= 4.5
? `${tr('settings.appearance.contrastOk', 'Good contrast')} (${customRatio.toFixed(1)}:1)`
: `${tr('settings.appearance.contrastLow', 'Low contrast')} (${customRatio.toFixed(1)}:1)`}
</StatusPill>
</div>
</div>
</SettingRow>
)}
</SettingRows>
</Section>
{/* ── Readability ─────────────────────────────────────────── */}
<Section
title={tr('settings.appearance.readability', 'Readability')}
icon={Eye}
badge={<StatusPill tone="warning">{tr('settings.appearance.experimental', 'Experimental')}</StatusPill>}
>
<SettingRows>
<ToggleRow
label={tr('settings.appearance.transparency', 'Transparency')}
hint={tr('settings.appearance.transparencyHint', 'Glassy translucent surfaces. Turn off for solid, higher-contrast backgrounds.')}
on={cfg.transparency}
onToggle={() => update({ transparency: !cfg.transparency })}
/>
<ToggleRow
label={tr('settings.appearance.reduceMotion', 'Reduce motion')}
hint={tr('settings.appearance.reduceMotionHint', 'Minimize animations and transitions.')}
on={cfg.reduceMotion}
onToggle={() => update({ reduceMotion: !cfg.reduceMotion })}
/>
{/* Density */}
<SettingRow
label={tr('settings.appearance.density', 'Density')}
hint={tr('settings.appearance.densityHint', 'Compact tightens spacing and padding for a denser layout that fits more on screen.')}
control={
<Segmented
label={tr('settings.appearance.density', 'Density')}
value={cfg.density}
onChange={(value) => update({ density: value })}
options={[
{ value: 'comfortable', label: tr('settings.appearance.comfortable', 'Comfortable') },
{ value: 'compact', label: tr('settings.appearance.compact', 'Compact') },
]}
/>
}
/>
{/* Text size — global, plus an always-visible row per size class with a
live sample and an example of what each size affects. */}
<SettingRow label={tr('settings.appearance.textSize', 'Text size')} stacked>
<SliderRow
label={tr('settings.appearance.textSizeAll', 'Everything')}
value={cfg.fontScale}
onChange={(v) => update({ fontScale: v })}
/>
<div className="mt-4 flex flex-col gap-3 rounded-[12px] border border-edge-faint bg-surface-secondary p-3">
<SizeRow
sampleClass="text-title font-bold"
name={tr('settings.appearance.size.large', 'Large')}
example={tr('settings.appearance.example.large', 'Headings, big numbers')}
sample={tr('settings.appearance.preview.large', 'Large heading')}
value={cfg.typeScale.title}
onChange={(v) => update({ typeScale: { ...cfg.typeScale, title: v } })}
/>
<SizeRow
sampleClass="text-subtitle font-semibold"
name={tr('settings.appearance.size.medium', 'Medium')}
example={tr('settings.appearance.example.medium', 'Sub-headings')}
sample={tr('settings.appearance.preview.medium', 'Medium subtitle')}
value={cfg.typeScale.subtitle}
onChange={(v) => update({ typeScale: { ...cfg.typeScale, subtitle: v } })}
/>
<SizeRow
sampleClass="text-body"
name={tr('settings.appearance.size.normal', 'Normal')}
example={tr('settings.appearance.example.normal', 'Place names, descriptions')}
sample={tr('settings.appearance.preview.normal', 'Normal body text')}
value={cfg.typeScale.body}
onChange={(v) => update({ typeScale: { ...cfg.typeScale, body: v } })}
/>
<SizeRow
sampleClass="text-caption"
name={tr('settings.appearance.size.small', 'Small')}
example={tr('settings.appearance.example.small', 'Addresses, labels')}
sample={tr('settings.appearance.preview.small', 'Small caption / address')}
value={cfg.typeScale.caption}
onChange={(v) => update({ typeScale: { ...cfg.typeScale, caption: v } })}
/>
</div>
</SettingRow>
</SettingRows>
</Section>
{/* ── Dashboard widgets ───────────────────────────────────── */}
<Section
title={tr('settings.appearance.dashboardWidgets', 'Dashboard widgets')}
icon={LayoutDashboard}
hint={tr('settings.appearance.dashboardWidgetsHint', 'Choose which widgets appear on the dashboard — independently for desktop and mobile.')}
>
<DialogSection label={tr('settings.appearance.desktop', 'Desktop')}>
<div className="flex flex-col gap-3">
{DESKTOP_GROUPS.map((g) => {
const masterOn = g.master ? cfg.dashboard.desktop[g.master] : true
const rows = g.keys.map((k) => (
<ToggleRow
key={k}
label={tr(`settings.appearance.widget.${k}`, WIDGET_LABELS[k])}
on={cfg.dashboard.desktop[k]}
onToggle={() => setWidget('desktop', k, !cfg.dashboard.desktop[k])}
/>
))
if (!g.master) {
return <WidgetGroup key={g.id} caption={tr(`settings.appearance.group.${g.id}`, g.fallback)}>{rows}</WidgetGroup>
}
return (
<SettingRows key={g.id}>
<SettingRow
label={tr(`settings.appearance.widget.${g.master}`, WIDGET_LABELS[g.master])}
hint={tr('settings.appearance.sidebarHint', 'The whole right column. Turn off and the dashboard centers.')}
control={<ToggleSwitch on={masterOn} onToggle={() => setWidget('desktop', g.master as string, !masterOn)}
label={tr(`settings.appearance.widget.${g.master}`, WIDGET_LABELS[g.master])} />}
>
{/* Its widgets only matter while the column is shown: dimmed, not hidden, while it is off. */}
<SettingRows className={`bg-surface-secondary transition-opacity ${masterOn ? '' : 'pointer-events-none opacity-40'}`}>
{rows}
</SettingRows>
</SettingRow>
</SettingRows>
)
})}
</div>
</DialogSection>
<DialogSection label={tr('settings.appearance.mobile', 'Mobile')}>
<div className="flex flex-col gap-3">
{MOBILE_GROUPS.map((g) => (
<WidgetGroup key={g.id} caption={tr(`settings.appearance.group.${g.id}`, g.fallback)}>
{g.keys.map((k) => (
<ToggleRow
key={k}
label={tr(`settings.appearance.widget.${k}`, WIDGET_LABELS[k])}
on={cfg.dashboard.mobile[k]}
onToggle={() => setWidget('mobile', k, !cfg.dashboard.mobile[k])}
/>
))}
</WidgetGroup>
))}
</div>
</DialogSection>
</Section>
<div className="mb-6 flex justify-end">
<button type="button" onClick={resetAll} className={SETTINGS_BUTTON} style={fs(13, 'body')}>
<RotateCcw size={14} strokeWidth={2} />
{tr('settings.appearance.reset', 'Reset to defaults')}
</button>
</div>
</>
)
}
function schemeFallback(id: string): string {
const map: Record<string, string> = {
default: 'Default',
highContrast: 'High contrast',
indigo: 'Indigo',
teal: 'Teal',
rose: 'Rose',
amber: 'Amber',
violet: 'Violet',
}
return map[id] || id
}
function ToggleRow({ label, hint, on, onToggle }: { label: string; hint?: string; on: boolean; onToggle: () => void }) {
return <SettingRow label={label} hint={hint} control={<ToggleSwitch on={on} onToggle={onToggle} label={label} />} />
}
/** Where a set of widgets sits on the dashboard: a caption over its box of switches. */
function WidgetGroup({ caption, children }: { caption: string; children: ReactNode }) {
return (
<div>
<div className="mb-1.5 px-0.5 font-medium text-content-muted" style={fs(12, 'body')}>{caption}</div>
<SettingRows>{children}</SettingRows>
</div>
)
}
/** A colour input with its name, in the box look of the fields. */
function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
return (
<label className="inline-flex items-center gap-2 rounded-[10px] border border-edge bg-surface-input py-1 ps-3 pe-1 font-medium text-content-secondary" style={fs(12.5, 'body')}>
{label}
<input type="color" value={value} onChange={(e) => onChange(e.target.value)}
className="h-7 w-9 cursor-pointer rounded-[7px] border-0 bg-transparent p-0" />
</label>
)
}
const fill = (value: number) => ({ '--fill': `${((value - APPEARANCE_SCALE_MIN) / (APPEARANCE_SCALE_MAX - APPEARANCE_SCALE_MIN)) * 100}%` } as React.CSSProperties)
/** A percentage in the corner of a slider. */
function Percent({ value }: { value: number }) {
return <span className="flex-none font-geist font-semibold tabular-nums text-content-muted" style={fs(11.5)}>{Math.round(value * 100)}%</span>
}
function SliderRow({ label, value, onChange }: { label: string; value: number; onChange: (v: number) => void }) {
return (
<div>
<div className="mb-1.5 flex items-center justify-between gap-3">
<span className="font-medium text-content-secondary" style={fs(12.5, 'body')}>{label}</span>
<Percent value={value} />
</div>
<input
type="range"
min={APPEARANCE_SCALE_MIN}
max={APPEARANCE_SCALE_MAX}
step={0.05}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="trek-range"
style={fill(value)}
/>
</div>
)
}
function SizeRow({ sampleClass, name, example, sample, value, onChange }: { sampleClass: string; name: string; example: string; sample: string; value: number; onChange: (v: number) => void }) {
return (
<div>
<div className="mb-1.5 flex items-end justify-between gap-3">
<div className="min-w-0 flex-1">
<div className={`${sampleClass} truncate leading-tight text-content`}>{sample}</div>
<div className="mt-0.5 truncate text-content-faint" style={fs(11.5)}>
<span className="font-medium text-content-muted">{name}</span> · {example}
</div>
</div>
<Percent value={value} />
</div>
<input
type="range"
min={APPEARANCE_SCALE_MIN}
max={APPEARANCE_SCALE_MAX}
step={0.05}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="trek-range"
style={fill(value)}
/>
</div>
)
}