mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
feat(settings): a settings kit in the planner's language; cards, navigation and header
This commit is contained in:
@@ -49,15 +49,12 @@ export default function PageSidebar({
|
||||
}, [mobileOpen])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl overflow-hidden flex flex-col lg:flex-row relative bg-surface-card border border-edge"
|
||||
style={{
|
||||
minHeight: 'min(820px, calc(100vh - var(--nav-h) - 120px))',
|
||||
}}
|
||||
>
|
||||
// The planner's layout (#2541): a navigation card on the left, the panel's
|
||||
// own cards on the page beside it, no frame around the two.
|
||||
<div className="relative flex flex-col items-start gap-5 lg:flex-row">
|
||||
{/* Mobile top bar with hamburger */}
|
||||
<div
|
||||
className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-edge"
|
||||
className="lg:hidden flex w-full items-center justify-between rounded-[14px] border border-edge-faint bg-surface-secondary px-3 py-2"
|
||||
>
|
||||
<button type="button"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
@@ -74,11 +71,8 @@ export default function PageSidebar({
|
||||
|
||||
{/* Desktop sidebar (always visible on lg) */}
|
||||
<aside
|
||||
className="hidden lg:flex flex-col shrink-0 relative bg-surface-secondary border-r border-edge"
|
||||
style={{
|
||||
width: 260,
|
||||
padding: '24px 14px',
|
||||
}}
|
||||
className="hidden lg:flex sticky flex-col shrink-0 self-start rounded-[18px] border border-edge-faint bg-surface-secondary p-2.5"
|
||||
style={{ width: 248, top: 'calc(var(--nav-h, 56px) + 16px)', maxHeight: 'calc(100vh - var(--nav-h, 56px) - 32px)', overflowY: 'auto' }}
|
||||
>
|
||||
<SidebarInner
|
||||
sidebarLabel={sidebarLabel}
|
||||
@@ -134,7 +128,7 @@ export default function PageSidebar({
|
||||
)}
|
||||
|
||||
{/* Panel */}
|
||||
<div className="flex-1 min-w-0" style={{ padding: '26px 28px' }}>
|
||||
<div className="w-full min-w-0 flex-1">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,13 +151,11 @@ function SidebarInner({
|
||||
return (
|
||||
<>
|
||||
{sidebarLabel && (
|
||||
<div
|
||||
className="text-[11px] font-bold tracking-widest uppercase mb-3 px-3 text-content-muted"
|
||||
>
|
||||
<div className="mb-1.5 mt-1 px-2.5 font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={{ fontSize: 'calc(9.5px * var(--fs-scale-caption, 1))' }}>
|
||||
{sidebarLabel}
|
||||
</div>
|
||||
)}
|
||||
<nav className="flex flex-col gap-1 flex-1">
|
||||
<nav className="flex flex-col gap-0.5 flex-1">
|
||||
{(() => {
|
||||
let lastGroup: string | undefined
|
||||
return tabs.map((tab) => {
|
||||
@@ -174,24 +166,19 @@ function SidebarInner({
|
||||
return (
|
||||
<React.Fragment key={tab.id}>
|
||||
{showHeader && (
|
||||
<div className="text-[10px] font-bold tracking-widest uppercase text-content-faint px-3 mt-3 mb-0.5 first:mt-0">
|
||||
<div className="mt-3 mb-1 px-2.5 font-geist font-bold uppercase tracking-[.08em] text-content-faint first:mt-0" style={{ fontSize: 'calc(9.5px * var(--fs-scale-caption, 1))' }}>
|
||||
{tab.group}
|
||||
</div>
|
||||
)}
|
||||
<button type="button"
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-left transition-colors ${active ? 'text-content font-semibold' : 'text-content-secondary font-medium'}`}
|
||||
style={{
|
||||
background: active ? 'var(--bg-hover)' : 'transparent',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!active) e.currentTarget.style.background = 'var(--bg-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!active) e.currentTarget.style.background = 'transparent'
|
||||
}}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={`flex items-center gap-2.5 rounded-[11px] px-2 py-1.5 text-left transition-colors ${active ? 'bg-surface-card font-semibold text-content shadow-sm ring-1 ring-edge-faint' : 'font-medium text-content-secondary hover:bg-surface-hover hover:text-content'}`}
|
||||
style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}
|
||||
>
|
||||
<Icon size={16} className="shrink-0" />
|
||||
<span className={`grid h-7 w-7 shrink-0 place-items-center rounded-[8px] ${active ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-muted'}`}>
|
||||
<Icon size={14} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="truncate">{tab.label}</span>
|
||||
</button>
|
||||
</React.Fragment>
|
||||
@@ -201,7 +188,8 @@ function SidebarInner({
|
||||
</nav>
|
||||
{footer && (
|
||||
<div
|
||||
className="mt-4 pt-3 px-3 text-[10px] tracking-wide text-content-faint border-t border-edge"
|
||||
className="mt-3 border-t border-edge-faint px-2.5 pt-2.5 font-geist tabular-nums text-content-faint"
|
||||
style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))' }}
|
||||
>
|
||||
{footer}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { SettingsCard } from './settingsKit'
|
||||
|
||||
interface SectionProps {
|
||||
title: string
|
||||
@@ -9,20 +10,18 @@ interface SectionProps {
|
||||
*/
|
||||
icon: LucideIcon | React.ComponentType<{ className?: string }>
|
||||
badge?: React.ReactNode
|
||||
/** One line under the title in the head band. */
|
||||
hint?: React.ReactNode
|
||||
/** A control on the right of the head band. */
|
||||
action?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function Section({ title, icon: Icon, badge, children }: SectionProps): React.ReactElement {
|
||||
/** A settings group: the planner's card with a head band (see settingsKit). */
|
||||
export default function Section({ title, icon, badge, hint, action, children }: SectionProps): React.ReactElement {
|
||||
return (
|
||||
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge" style={{ marginBottom: 24 }}>
|
||||
<div className="px-6 py-4 border-b flex items-center gap-2 border-edge-secondary">
|
||||
<Icon className="w-5 h-5 text-content-secondary" />
|
||||
<h2 className="font-semibold text-content">{title}</h2>
|
||||
{badge}
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
<SettingsCard icon={icon} title={title} badge={badge} hint={hint} action={action}>
|
||||
{children}
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
|
||||
/*
|
||||
* The settings and admin pages in the planner's language (#2541): a tinted bar
|
||||
* on top, cards with a head band, white rows inside them, eyebrow labels over
|
||||
* fields. One kit for both pages, so a toggle, a hint or a danger zone reads the
|
||||
* same wherever it sits. Fields themselves come from shared/dialogParts (INPUT,
|
||||
* EditorField, Segmented) and shared/CustomSelect, like in the editors.
|
||||
*/
|
||||
|
||||
type IconLike = LucideIcon | React.ComponentType<{ className?: string; size?: number; strokeWidth?: number }>
|
||||
|
||||
/** The page's own bar: icon tile, title and subtitle on the left, actions on the right. */
|
||||
export function SettingsHeader({ icon: Icon, title, subtitle, actions }: {
|
||||
icon: IconLike
|
||||
title: ReactNode
|
||||
subtitle?: ReactNode
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-5 flex flex-wrap items-center gap-3 rounded-[18px] bg-surface-tertiary py-3 pl-3 pr-3">
|
||||
<span className="grid h-10 w-10 flex-none place-items-center rounded-[12px] bg-surface-card text-content-secondary shadow-sm">
|
||||
<Icon size={18} strokeWidth={1.9} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="m-0 text-subtitle font-semibold tracking-[-0.01em] text-content">{title}</h1>
|
||||
{subtitle && <p className="m-0 text-content-muted" style={fs(12.5, 'body')}>{subtitle}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A group of settings: a card with a head band (icon tile, title, an optional
|
||||
* badge and an action on the right) and its body below. `tone` tints the band:
|
||||
* neutral for ordinary groups, danger for the destructive ones.
|
||||
*/
|
||||
export function SettingsCard({ icon: Icon, title, hint, badge, action, tone = 'neutral', children, id, bodyClassName }: {
|
||||
icon?: IconLike
|
||||
title: ReactNode
|
||||
/** One line under the title in the band. */
|
||||
hint?: ReactNode
|
||||
badge?: ReactNode
|
||||
action?: ReactNode
|
||||
tone?: 'neutral' | 'danger'
|
||||
children?: ReactNode
|
||||
id?: string
|
||||
/** Replaces the body's padded column. */
|
||||
bodyClassName?: string
|
||||
}) {
|
||||
const tint = tone === 'danger' ? 'color-mix(in srgb, var(--danger) 9%, transparent)' : NEUTRAL_TINT
|
||||
return (
|
||||
<section id={id} className="mb-5 overflow-hidden rounded-2xl border border-edge-faint bg-surface-secondary">
|
||||
<div className="flex items-center gap-3 border-b border-edge-faint px-4 py-3" style={{ background: tint }}>
|
||||
{Icon && (
|
||||
<span className={`grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-card shadow-sm ${tone === 'danger' ? 'text-danger' : 'text-content-secondary'}`}>
|
||||
<Icon size={15} strokeWidth={2} />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h2 className="m-0 truncate font-bold text-content" style={fs(14, 'body')}>{title}</h2>
|
||||
{badge}
|
||||
</div>
|
||||
{hint && <p className="m-0 mt-0.5 text-content-faint" style={fs(11.5)}>{hint}</p>}
|
||||
</div>
|
||||
{action && <div className="flex flex-none items-center gap-1.5">{action}</div>}
|
||||
</div>
|
||||
{children != null && <div className={bodyClassName ?? 'flex flex-col gap-4 p-4'}>{children}</div>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** A white box of rows separated by hairlines, the way the bag sidebar lists its bags. */
|
||||
export function SettingRows({ children, className = '' }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={`divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One setting as a row: what it is (and a hint) on the left, the control on the
|
||||
* right. `stacked` puts the control under the text for wide controls (a select,
|
||||
* a segmented row) on narrow windows and always for a `full` control.
|
||||
*/
|
||||
export function SettingRow({ label, hint, control, children, stacked = false, dimmed = false, htmlFor }: {
|
||||
label: ReactNode
|
||||
hint?: ReactNode
|
||||
/** The control on the right. */
|
||||
control?: ReactNode
|
||||
/** Extra content under the row (an expanded editor, a list). */
|
||||
children?: ReactNode
|
||||
stacked?: boolean
|
||||
/** While a save is on its way. */
|
||||
dimmed?: boolean
|
||||
htmlFor?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={`px-3.5 py-3 ${dimmed ? 'opacity-60' : ''}`}>
|
||||
<div className={stacked ? 'flex flex-col gap-2.5' : 'flex flex-wrap items-center gap-x-4 gap-y-2'}>
|
||||
<div className="min-w-0 flex-1 basis-56">
|
||||
<label htmlFor={htmlFor} className="block font-medium text-content" style={fs(13, 'body')}>{label}</label>
|
||||
{hint && <p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11.5)}>{hint}</p>}
|
||||
</div>
|
||||
{control && <div className={stacked ? 'min-w-0' : 'flex flex-none items-center gap-2'}>{control}</div>}
|
||||
</div>
|
||||
{children && <div className="mt-3">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A quiet line of explanation inside a card body. */
|
||||
export function SettingsHint({ children, className = '' }: { children: ReactNode; className?: string }) {
|
||||
return <p className={`m-0 leading-normal text-content-faint ${className}`} style={fs(11.5)}>{children}</p>
|
||||
}
|
||||
|
||||
/** A row of pills to pick one (or several) from, for choices too many for a segmented track. */
|
||||
export function ChoiceChips<T extends string>({ value, options, onChange, label, multi }: {
|
||||
value: T | T[]
|
||||
options: readonly { value: T; label: ReactNode; title?: string }[]
|
||||
onChange: (value: T) => void
|
||||
label: string
|
||||
multi?: boolean
|
||||
}) {
|
||||
const isOn = (v: T) => (Array.isArray(value) ? value.includes(v) : value === v)
|
||||
return (
|
||||
<div role="group" aria-label={label} className="flex flex-wrap gap-1.5">
|
||||
{options.map(o => {
|
||||
const on = isOn(o.value)
|
||||
return (
|
||||
<button key={o.value} type="button" onClick={() => onChange(o.value)} aria-pressed={on} title={o.title}
|
||||
{...(multi ? { role: 'checkbox', 'aria-checked': on } : {})}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-[5px] font-medium transition-colors ${on ? 'border-[color:var(--text-primary)] bg-surface-card text-content shadow-sm' : 'border-edge bg-surface-card text-content-muted hover:text-content'}`}
|
||||
style={fs(12.5, 'body')}>
|
||||
{o.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A small status pill for a card's band or a row: neutral, success, warning or danger. */
|
||||
export function StatusPill({ tone = 'neutral', children, icon }: { tone?: 'neutral' | 'success' | 'warning' | 'danger' | 'accent'; children: ReactNode; icon?: ReactNode }) {
|
||||
const look = {
|
||||
neutral: 'border border-edge-faint bg-surface-card text-content-secondary',
|
||||
success: 'bg-success-soft text-success',
|
||||
warning: 'bg-warning-soft text-warning',
|
||||
danger: 'bg-danger-soft text-danger',
|
||||
accent: 'bg-accent text-accent-text',
|
||||
}[tone]
|
||||
return (
|
||||
<span className={`inline-flex flex-none items-center gap-1 whitespace-nowrap rounded-full px-2 py-[2px] font-geist font-semibold tabular-nums ${look}`} style={fs(11)}>
|
||||
{icon}{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** The buttons of a settings card, in the dialogs' shapes. */
|
||||
export const SETTINGS_BUTTON = 'inline-flex items-center justify-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:cursor-default disabled:opacity-50'
|
||||
export const SETTINGS_BUTTON_PRIMARY = 'inline-flex items-center justify-center gap-1.5 rounded-[10px] bg-accent px-4 py-2 font-medium text-accent-text hover:opacity-90 disabled:cursor-default disabled:opacity-50'
|
||||
export const SETTINGS_BUTTON_DANGER = 'inline-flex items-center justify-center gap-1.5 rounded-[10px] bg-danger-soft px-3.5 py-2 font-medium text-danger hover:opacity-90 disabled:cursor-default disabled:opacity-50'
|
||||
/** A round icon action in a band or a row, named by its tooltip. */
|
||||
export const SETTINGS_ICON_BUTTON = 'grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-card text-content-muted shadow-sm ring-1 ring-edge-faint hover:text-content disabled:cursor-default disabled:opacity-50'
|
||||
@@ -3,6 +3,7 @@ import { Settings, SlidersHorizontal, Paintbrush, Map, Bell, Plug, CloudOff, Use
|
||||
import { useTranslation } from '../i18n'
|
||||
import PageShell from '../components/Layout/PageShell'
|
||||
import PageSidebar, { type PageSidebarTab } from '../components/Layout/PageSidebar'
|
||||
import { SettingsHeader } from '../components/Settings/settingsKit'
|
||||
import DisplaySettingsTab from '../components/Settings/DisplaySettingsTab'
|
||||
import AppearanceSettingsTab from '../components/Settings/AppearanceSettingsTab'
|
||||
import MapSettingsTab from '../components/Settings/MapSettingsTab'
|
||||
@@ -55,18 +56,10 @@ function SettingsPageDesktop(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<PageShell background="var(--bg-secondary)">
|
||||
<div className="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="w-full px-4 sm:px-6 lg:px-8 py-6">
|
||||
{/* Header */}
|
||||
<HelpAnchor id={helpId} />
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center bg-surface-tertiary">
|
||||
<Settings className="w-5 h-5 text-content-secondary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-content">{t('settings.title')}</h1>
|
||||
<p className="text-sm text-content-muted">{t('settings.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsHeader icon={Settings} title={t('settings.title')} subtitle={t('settings.subtitle')} />
|
||||
|
||||
{/* Sidebar layout */}
|
||||
<PageSidebar
|
||||
|
||||
Reference in New Issue
Block a user