mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
feat(settings,admin): every tab and popup in the planner's design
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState, type ComponentType } from 'react'
|
||||
import { useEffect, useId, useState, type ComponentType } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useIsDark } from '../../hooks/useIsDark'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2 } from 'lucide-react'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
@@ -13,6 +13,10 @@ import AirTrailIcon from '../shared/AirTrailIcon'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../shared/DocumentProviderIcons'
|
||||
import AddonTile from './AddonTile'
|
||||
import AddonSubRow from './AddonSubRow'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { EditorField, INPUT, Segmented } from '../shared/dialogParts'
|
||||
import { SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY } from '../Settings/settingsKit'
|
||||
|
||||
// Keys are the `icon` column from the addons table (see server seeds.ts); anything
|
||||
// unknown falls back to Puzzle. Users/Sparkles cover collab and llm_parsing, which
|
||||
@@ -183,9 +187,11 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="grid place-items-center py-16">
|
||||
<Loader2 size={22} className="animate-spin text-content-faint" />
|
||||
</div>
|
||||
<SettingsCard icon={Puzzle} title={t('admin.addons.title')}>
|
||||
<div className="grid place-items-center py-14">
|
||||
<Loader2 size={22} className="animate-spin text-content-faint" />
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -278,9 +284,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
}
|
||||
|
||||
/* One column per type, side by side, instead of three stacked sections. Stacked, each
|
||||
section opened its own set of columns and the tall tiles (Collab 261px, Journey
|
||||
185px) set a section's height while its neighbours ran out early. Side by side the
|
||||
column heading carries the type, so the tiles need no badge of their own. */
|
||||
section opened its own set of columns and the tall tiles (Collab, Journey) set a
|
||||
section's height while its neighbours ran out early. Side by side the column's
|
||||
head carries the type, so the tiles need no badge of their own. */
|
||||
const groups = [
|
||||
{ key: 'trip', addons: tripAddons },
|
||||
{ key: 'global', addons: globalAddons },
|
||||
@@ -289,48 +295,49 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled).length
|
||||
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
|
||||
<div>
|
||||
<h2 className="text-subtitle font-semibold tracking-tight text-content">{t('admin.addons.title')}</h2>
|
||||
<p className="mt-1 flex flex-wrap items-center gap-1 text-caption text-content-muted">
|
||||
{t('admin.addons.subtitleBefore')}
|
||||
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 11, verticalAlign: 'middle', opacity: 0.7 }} />
|
||||
{t('admin.addons.subtitleAfter')}
|
||||
</p>
|
||||
</div>
|
||||
{totalCount > 0 && (
|
||||
<span
|
||||
className="shrink-0 text-caption tabular-nums text-content-faint"
|
||||
title={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}
|
||||
>
|
||||
{enabledCount}/{totalCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
const subtitle = (
|
||||
<span className="inline-flex flex-wrap items-center gap-1">
|
||||
{t('admin.addons.subtitleBefore')}
|
||||
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, verticalAlign: 'middle', opacity: 0.7 }} />
|
||||
{t('admin.addons.subtitleAfter')}
|
||||
</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
icon={Puzzle}
|
||||
title={t('admin.addons.title')}
|
||||
hint={subtitle}
|
||||
badge={totalCount > 0 ? (
|
||||
<Tooltip label={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}>
|
||||
<span className="inline-flex">
|
||||
<StatusPill tone={enabledCount > 0 ? 'success' : 'neutral'}>{enabledCount}/{totalCount}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
>
|
||||
{addons.length === 0 ? (
|
||||
<EmptyState scene="idle" title={t('admin.addons.noAddons')} />
|
||||
<EmptyState scene="idle" title={t('admin.addons.noAddons')} surface="var(--bg-secondary)" />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 items-start gap-x-4 gap-y-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="grid grid-cols-1 items-start gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{groups.map(g => {
|
||||
const meta = TYPE_META[g.key]
|
||||
return (
|
||||
<section key={g.key}>
|
||||
<section key={g.key} className="min-w-0 overflow-hidden rounded-[14px] border border-edge-faint bg-surface-card">
|
||||
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.addons} t={t} />
|
||||
<div className="space-y-3">{g.addons.map(tile)}</div>
|
||||
<div className="divide-y divide-edge-faint">{g.addons.map(tile)}</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
/** The head band of one type's column: icon tile, the type, its count and what it means. */
|
||||
function GroupHead({ icon: Icon, label, hint, addons, t }: {
|
||||
icon: ComponentType<{ size?: number; className?: string }>
|
||||
icon: ComponentType<{ size?: number; className?: string; strokeWidth?: number }>
|
||||
label: string
|
||||
hint: string
|
||||
addons: Addon[]
|
||||
@@ -338,24 +345,29 @@ function GroupHead({ icon: Icon, label, hint, addons, t }: {
|
||||
}) {
|
||||
const enabled = addons.filter(a => a.enabled).length
|
||||
return (
|
||||
<div
|
||||
className="mb-3 rounded-xl border border-edge-secondary bg-surface-secondary px-3 py-2"
|
||||
title={t('admin.addons.group.count', { enabled, total: addons.length })}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon size={13} className="shrink-0 text-content-muted" />
|
||||
<h3 className="min-w-0 flex-1 truncate text-caption font-semibold uppercase tracking-[0.06em] text-content">{label}</h3>
|
||||
<span className="shrink-0 rounded-full bg-surface px-1.5 py-0.5 text-caption font-medium tabular-nums text-content-muted">
|
||||
{enabled}/{addons.length}
|
||||
</span>
|
||||
<div className="flex items-start gap-2.5 border-b border-edge-faint bg-surface-tertiary px-3.5 py-2.5">
|
||||
<span className="mt-px grid h-7 w-7 flex-none place-items-center rounded-[9px] bg-surface-card text-content-secondary shadow-sm">
|
||||
<Icon size={13} strokeWidth={2} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="m-0 min-w-0 flex-1 truncate font-bold text-content" style={fs(13, 'body')}>{label}</h3>
|
||||
<Tooltip label={t('admin.addons.group.count', { enabled, total: addons.length })}>
|
||||
<span className="inline-flex">
|
||||
<StatusPill>{enabled}/{addons.length}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/* Says what the type means: the only place that still explains it. */}
|
||||
<p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11)}>{hint}</p>
|
||||
</div>
|
||||
{/* Says what the type means — the only place that still explains it. */}
|
||||
<p className="mt-0.5 text-caption text-content-faint">{hint}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MASKED = '••••••••'
|
||||
const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint'
|
||||
const SMALL_BUTTON = 'inline-flex flex-none items-center rounded-[9px] px-2.5 py-1 font-medium transition-colors disabled:cursor-default disabled:opacity-60'
|
||||
const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1'
|
||||
|
||||
/** Curated models the local extractor is tuned for, pullable via Ollama. The router drives
|
||||
@@ -381,6 +393,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
const [apiKey, setApiKey] = useState<string>((cfg.apiKey as string) ?? '')
|
||||
const [vision, setVision] = useState<LlmVision>(asLlmVision(cfg.vision))
|
||||
const [saving, setSaving] = useState(false)
|
||||
const fieldId = useId()
|
||||
|
||||
// Local-provider model management.
|
||||
const [installed, setInstalled] = useState<string[]>([])
|
||||
@@ -450,9 +463,6 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
}
|
||||
}
|
||||
|
||||
const fieldCls = 'w-full rounded-lg border border-edge-secondary bg-surface px-2.5 py-1.5 text-caption text-content placeholder:text-content-faint transition-colors focus:border-edge focus:outline-none'
|
||||
const labelCls = 'mb-1 block text-caption font-medium text-content-secondary'
|
||||
|
||||
const providerOptions = [
|
||||
{ value: 'local', label: 'Local · OpenAI-compatible', icon: <Server size={14} />, badge: 'Ollama' },
|
||||
{ value: 'openai', label: 'OpenAI', icon: <Cloud size={14} /> },
|
||||
@@ -460,65 +470,69 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
]
|
||||
const visionOptions = LLM_VISION_MODES.map(value => ({ value, label: t(`admin.addons.llm.vision.${value}`) }))
|
||||
|
||||
/* Lives in the tile's shelf like the collab toggles, so everything is caption-
|
||||
sized and single-column — the band this used to be had a whole page width. */
|
||||
/* Lives in the tile's shelf like the collab toggles, so it is one column of
|
||||
eyebrow fields: the band this used to be had a whole page width. */
|
||||
return (
|
||||
<li className="space-y-2.5 py-1.5">
|
||||
<p className="text-caption text-content-faint">
|
||||
<li className={`flex flex-col gap-3 p-3 ${saving ? 'opacity-60' : ''}`}>
|
||||
<SettingsHint>
|
||||
Instance-wide — applies to all users. Leave blank to let each user configure their own provider.
|
||||
</p>
|
||||
</SettingsHint>
|
||||
|
||||
<div>
|
||||
<span className={labelCls}>Provider</span>
|
||||
<EditorField label="Provider">
|
||||
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
|
||||
</div>
|
||||
</EditorField>
|
||||
{provider !== 'anthropic' && (
|
||||
<label className="block">
|
||||
<span className={labelCls}>Base URL</span>
|
||||
<input type="url" autoComplete="off" className={fieldCls} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
|
||||
</label>
|
||||
<EditorField label="Base URL" htmlFor={`${fieldId}-url`}>
|
||||
<input id={`${fieldId}-url`} type="url" autoComplete="off" className={INPUT} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
|
||||
</EditorField>
|
||||
)}
|
||||
<label className="block">
|
||||
<span className={labelCls}>API key</span>
|
||||
<input type="password" className={fieldCls} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
|
||||
</label>
|
||||
{provider === 'anthropic' && (
|
||||
<p className="text-caption text-content-faint">Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.</p>
|
||||
)}
|
||||
<label className="block">
|
||||
<span className={labelCls}>Model</span>
|
||||
<input autoComplete="off" className={fieldCls} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
|
||||
</label>
|
||||
<EditorField
|
||||
label="API key"
|
||||
htmlFor={`${fieldId}-key`}
|
||||
hint={provider === 'anthropic' ? 'Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.' : undefined}
|
||||
>
|
||||
<input id={`${fieldId}-key`} type="password" className={INPUT} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
|
||||
</EditorField>
|
||||
<EditorField label="Model" htmlFor={`${fieldId}-model`}>
|
||||
<input id={`${fieldId}-model`} autoComplete="off" className={`${INPUT} font-geist`} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
|
||||
</EditorField>
|
||||
|
||||
<div>
|
||||
<span className={labelCls}>{t('settings.aiParsing.multimodal')}</span>
|
||||
<CustomSelect value={vision} onChange={v => setVision(asLlmVision(v))} options={visionOptions} />
|
||||
<p className="mt-1 text-caption text-content-faint">
|
||||
{t(provider === 'local' ? 'admin.addons.llm.vision.hintLocal' : 'admin.addons.llm.vision.hintCloud')}
|
||||
</p>
|
||||
</div>
|
||||
<EditorField label={t('settings.aiParsing.multimodal')} hint={t(provider === 'local' ? 'admin.addons.llm.vision.hintLocal' : 'admin.addons.llm.vision.hintCloud')}>
|
||||
<Segmented<LlmVision>
|
||||
label={t('settings.aiParsing.multimodal')}
|
||||
value={vision}
|
||||
onChange={v => setVision(asLlmVision(v))}
|
||||
options={visionOptions}
|
||||
fill
|
||||
/>
|
||||
</EditorField>
|
||||
|
||||
{/* Local model management (Ollama) */}
|
||||
{provider === 'local' && (
|
||||
<div className="space-y-2 rounded-lg border border-edge-secondary bg-surface p-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-caption font-medium text-content-secondary">Installed on the server</span>
|
||||
<button type="button" onClick={loadModels} disabled={loadingModels} className="text-caption text-content-muted underline disabled:opacity-60">
|
||||
<div className="flex flex-col gap-2.5 rounded-[12px] border border-edge-faint bg-surface-card p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`${EYEBROW} min-w-0 flex-1 truncate`} style={fs(9.5)}>Installed on the server</span>
|
||||
<button type="button" onClick={loadModels} disabled={loadingModels}
|
||||
className="inline-flex flex-none items-center gap-1 rounded-full px-2 py-0.5 font-medium text-content-muted hover:bg-surface-secondary hover:text-content disabled:cursor-default disabled:opacity-60"
|
||||
style={fs(11.5, 'body')}>
|
||||
<RefreshCw size={11} strokeWidth={2.2} className={loadingModels ? 'animate-spin' : undefined} />
|
||||
{loadingModels ? 'Loading…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
{modelsErr && <p className="text-caption text-danger">{modelsErr}</p>}
|
||||
{modelsErr && <p className="m-0 break-words text-danger" style={fs(11.5)}>{modelsErr}</p>}
|
||||
{!modelsErr && installed.length === 0 && !loadingModels && (
|
||||
<p className="text-caption text-content-faint">No models installed yet — pull one below.</p>
|
||||
<SettingsHint>No models installed yet — pull one below.</SettingsHint>
|
||||
)}
|
||||
{installed.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{installed.map(name => (
|
||||
<button type="button"
|
||||
key={name}
|
||||
title={name}
|
||||
onClick={() => setModel(name)}
|
||||
className={`max-w-full truncate rounded-full border px-2 py-0.5 text-caption transition-colors ${model === name ? 'border-transparent bg-accent text-accent-text' : 'border-edge-secondary text-content-secondary hover:border-edge'}`}
|
||||
aria-pressed={model === name}
|
||||
className={`max-w-full truncate rounded-full px-2.5 py-[3px] font-geist font-medium transition-colors ${model === name ? 'bg-accent text-accent-text' : 'border border-edge bg-surface-card text-content-muted hover:text-content'}`}
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
@@ -526,48 +540,44 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-edge-secondary pt-2">
|
||||
<div className="mb-1.5 text-caption font-medium text-content-secondary">Pull a recommended model</div>
|
||||
<div className="space-y-1">
|
||||
{RECOMMENDED_MODELS.map(m => {
|
||||
const installedHere = isInstalled(m.id)
|
||||
const isPulling = pulling === m.id
|
||||
const active = model === m.id
|
||||
return (
|
||||
<div key={m.id} className="min-w-0" title={m.note}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-caption text-content">{m.label}</span>
|
||||
{m.recommended && (
|
||||
<span className="shrink-0 rounded-md bg-success-soft px-1.5 py-px text-caption font-semibold text-success">Recommended</span>
|
||||
)}
|
||||
{installedHere ? (
|
||||
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`shrink-0 rounded-md px-2 py-1 text-caption font-medium transition-colors ${active ? 'bg-surface-tertiary text-content-muted' : 'border border-edge-secondary text-content-secondary hover:border-edge'}`}>
|
||||
{active ? 'Selected' : 'Use'}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => pull(m.id)} disabled={!!pulling} className="shrink-0 rounded-md bg-accent px-2 py-1 text-caption font-medium text-accent-text disabled:opacity-60">
|
||||
{isPulling ? 'Pulling…' : 'Pull'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isPulling && (
|
||||
<div className="mt-1">
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-surface-tertiary">
|
||||
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
|
||||
</div>
|
||||
<div className="mt-0.5 text-caption text-content-faint">{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 border-t border-edge-faint pt-2.5">
|
||||
<span className={EYEBROW} style={fs(9.5)}>Pull a recommended model</span>
|
||||
{RECOMMENDED_MODELS.map(m => {
|
||||
const installedHere = isInstalled(m.id)
|
||||
const isPulling = pulling === m.id
|
||||
const active = model === m.id
|
||||
return (
|
||||
<div key={m.id} className="min-w-0" title={m.note}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>{m.label}</span>
|
||||
{m.recommended && <StatusPill tone="success">Recommended</StatusPill>}
|
||||
{installedHere ? (
|
||||
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`${SMALL_BUTTON} ${active ? 'bg-surface-tertiary text-content-muted' : 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary'}`} style={fs(12, 'body')}>
|
||||
{active ? 'Selected' : 'Use'}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => pull(m.id)} disabled={!!pulling} className={`${SMALL_BUTTON} bg-accent text-accent-text hover:opacity-90`} style={fs(12, 'body')}>
|
||||
{isPulling ? 'Pulling…' : 'Pull'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{isPulling && (
|
||||
<div className="mt-2">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
|
||||
<div className="h-full rounded-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
|
||||
</div>
|
||||
<div className="mt-1 truncate font-geist tabular-nums text-content-faint" style={fs(11)}>{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={save} disabled={saving} className="rounded-lg bg-accent px-3 py-1.5 text-caption font-medium text-accent-text transition-opacity disabled:opacity-60">
|
||||
<button type="button" onClick={save} disabled={saving} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
|
||||
/**
|
||||
* One child row inside an {@link AddonTile}'s sub-shelf: bag tracking under
|
||||
@@ -29,9 +30,11 @@ export default function AddonSubRow({
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<li className="flex min-h-[36px] items-center gap-3">
|
||||
{icon ? <span className="shrink-0 text-content-faint">{icon}</span> : <span className="w-3.5 shrink-0" aria-hidden />}
|
||||
<span className="min-w-0 flex-1 truncate text-caption font-medium text-content-secondary" title={description}>
|
||||
<li className="flex min-h-[44px] items-center gap-2.5 px-3 py-1.5">
|
||||
{icon
|
||||
? <span className={`grid h-6 w-6 flex-none place-items-center ${enabled ? 'text-content-muted' : 'text-content-faint'}`}>{icon}</span>
|
||||
: <span className="w-6 flex-none" aria-hidden />}
|
||||
<span className={`min-w-0 flex-1 truncate font-medium ${enabled ? 'text-content' : 'text-content-muted'}`} style={fs(12.5, 'body')} title={description}>
|
||||
{title}
|
||||
</span>
|
||||
<ToggleSwitch on={enabled} onToggle={onToggle} label={title} />
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
|
||||
/**
|
||||
* One addon in the admin grid: a card whose fill carries the on/off state, with
|
||||
* an optional shelf at the foot holding its sub-features.
|
||||
* One addon in its type's card: a row like the bag sidebar's (#2541), with the
|
||||
* icon tile on the left, name and description beside it and the switch on the
|
||||
* right. Its sub-features sit under it in a grey inset box of hairline rows.
|
||||
*
|
||||
* The fill ladder is `--bg-secondary` (on) against `--bg-card` (the panel it
|
||||
* sits on) — that pair reads in both themes, because secondary is *darker* than
|
||||
* the card in light and *lighter* in dark. The obvious alternative, on =
|
||||
* `bg-surface-card`, inverts in dark and turns the disabled tiles into the loud
|
||||
* ones.
|
||||
* The on/off state rides on the icon tile and the switch: a switched-off addon
|
||||
* greys its tile and its description, but keeps its name at full strength. The
|
||||
* name is the tile's identity, not its state; greying it out reads as "broken"
|
||||
* rather than "not enabled".
|
||||
*
|
||||
* Hover and focus ride on border and fill rather than a shadow: `--shadow-card`
|
||||
* is only declared in `:root`, so the `.dark` palette never overrides it and a
|
||||
* shadow-based affordance would be invisible there.
|
||||
*
|
||||
* The card is not a click target — only the switches are. No lift, no pointer
|
||||
* The row is not a click target, only the switches are. No lift, no pointer
|
||||
* cursor, no `role="button"`.
|
||||
*/
|
||||
export default function AddonTile({
|
||||
@@ -35,46 +32,41 @@ export default function AddonTile({
|
||||
children?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<article
|
||||
className={`flex flex-col overflow-hidden rounded-xl border transition-[background-color,border-color] duration-150 focus-within:ring-2 focus-within:ring-accent ${
|
||||
enabled
|
||||
? 'border-edge bg-surface-secondary'
|
||||
: 'border-edge-secondary bg-surface hover:border-edge hover:bg-surface-secondary'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3 p-4">
|
||||
<article className="px-3.5 py-3 transition-colors duration-150 focus-within:bg-surface-secondary">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
// overflow-hidden so a brand mark that fills the slot keeps the slot's
|
||||
// rounded corners instead of squaring them off.
|
||||
className={`grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-xl ${
|
||||
enabled ? 'border border-accent bg-accent-subtle text-accent-on' : 'bg-surface-tertiary text-content-faint'
|
||||
className={`grid h-9 w-9 flex-none place-items-center overflow-hidden rounded-[10px] transition-colors ${
|
||||
enabled ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'bg-surface-tertiary text-content-faint'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* The name stays full-strength when off: it is the tile's identity, not its
|
||||
state. Greying it out reads as "broken" rather than "not enabled". */}
|
||||
<h4 className="truncate text-body font-semibold text-content" title={name}>
|
||||
<div className="min-w-0 flex-1 pt-px">
|
||||
<h4 className="m-0 truncate font-semibold text-content" style={fs(13, 'body')} title={name}>
|
||||
{name}
|
||||
</h4>
|
||||
<p
|
||||
className={`mt-0.5 line-clamp-2 text-caption ${enabled ? 'text-content-muted' : 'text-content-faint'}`}
|
||||
className={`m-0 mt-0.5 line-clamp-2 leading-snug ${enabled ? 'text-content-muted' : 'text-content-faint'}`}
|
||||
style={fs(11.5)}
|
||||
title={description}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ToggleSwitch on={enabled} onToggle={onToggle} label={name} />
|
||||
<div className="flex-none pt-1.5">
|
||||
<ToggleSwitch on={enabled} onToggle={onToggle} label={name} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{children && (
|
||||
<div className="border-t border-edge-secondary bg-surface-tertiary px-4 py-2.5">
|
||||
<div className="mt-3 overflow-hidden rounded-[12px] border border-edge-faint bg-surface-secondary">
|
||||
{/* Sitting inside the parent on its own fill is enough to say these belong
|
||||
to it — no rail, no indent guides. */}
|
||||
<ul className="space-y-0.5">{children}</ul>
|
||||
to it: no rail, no indent guides. */}
|
||||
<ul className="m-0 list-none divide-y divide-edge-faint p-0">{children}</ul>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('AdminMcpTokensPanel', () => {
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('CI Token');
|
||||
|
||||
const deleteButtons = screen.getAllByTitle('Delete');
|
||||
const deleteButtons = screen.getAllByLabelText('Delete');
|
||||
await user.click(deleteButtons[0]);
|
||||
|
||||
expect(screen.getByText('Delete Token')).toBeInTheDocument();
|
||||
@@ -108,7 +108,7 @@ describe('AdminMcpTokensPanel', () => {
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('CI Token');
|
||||
|
||||
const deleteButtons = screen.getAllByTitle('Delete');
|
||||
const deleteButtons = screen.getAllByLabelText('Delete');
|
||||
await user.click(deleteButtons[0]);
|
||||
expect(screen.getByText('Delete Token')).toBeInTheDocument();
|
||||
|
||||
@@ -129,7 +129,7 @@ describe('AdminMcpTokensPanel', () => {
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('CI Token');
|
||||
|
||||
const deleteButtons = screen.getAllByTitle('Delete');
|
||||
const deleteButtons = screen.getAllByLabelText('Delete');
|
||||
await user.click(deleteButtons[0]);
|
||||
expect(screen.getByText('Delete Token')).toBeInTheDocument();
|
||||
|
||||
@@ -155,7 +155,7 @@ describe('AdminMcpTokensPanel', () => {
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
await screen.findByText('CI Token');
|
||||
|
||||
const deleteButtons = screen.getAllByTitle('Delete');
|
||||
const deleteButtons = screen.getAllByLabelText('Delete');
|
||||
await user.click(deleteButtons[0]);
|
||||
await user.click(screen.getByText('Delete'));
|
||||
|
||||
@@ -180,7 +180,7 @@ describe('AdminMcpTokensPanel', () => {
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
await screen.findByText('CI Token');
|
||||
|
||||
const deleteButtons = screen.getAllByTitle('Delete');
|
||||
const deleteButtons = screen.getAllByLabelText('Delete');
|
||||
await user.click(deleteButtons[0]);
|
||||
await user.click(screen.getByText('Delete'));
|
||||
|
||||
@@ -282,14 +282,14 @@ describe('AdminMcpTokensPanel', () => {
|
||||
await screen.findByText('Revoke Me');
|
||||
|
||||
// Click the revoke (trash) button next to the session
|
||||
const deleteBtn = screen.getAllByTitle('Delete')[0];
|
||||
const deleteBtn = screen.getAllByLabelText('Delete')[0];
|
||||
await user.click(deleteBtn);
|
||||
|
||||
// Confirmation modal opens
|
||||
expect(screen.getByText('Revoke Session')).toBeInTheDocument();
|
||||
// Confirm — find the modal's Delete button (has no title, unlike the trash icon)
|
||||
// Confirm — find the modal's Delete button (has no aria-label, unlike the trash icon)
|
||||
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
|
||||
const confirmBtn = deleteBtns.find(b => !b.title);
|
||||
const confirmBtn = deleteBtns.find(b => !b.hasAttribute('aria-label'));
|
||||
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Revoke Me')).not.toBeInTheDocument();
|
||||
@@ -313,10 +313,10 @@ describe('AdminMcpTokensPanel', () => {
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
await screen.findByText('Error Session');
|
||||
|
||||
const deleteBtn = screen.getAllByTitle('Delete')[0];
|
||||
const deleteBtn = screen.getAllByLabelText('Delete')[0];
|
||||
await user.click(deleteBtn);
|
||||
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
|
||||
const confirmBtn = deleteBtns.find(b => !b.title);
|
||||
const confirmBtn = deleteBtns.find(b => !b.hasAttribute('aria-label'));
|
||||
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
|
||||
expect(await screen.findByText('Failed to revoke session')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, type ReactNode } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Key, Trash2, User, Loader2, Shield } from 'lucide-react'
|
||||
import { Key, Trash2, User, Loader2, Shield, KeyRound, Bot } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { SETTINGS_ICON_BUTTON, SettingRows, SettingsCard, SettingsHint, StatusPill } from '../Settings/settingsKit'
|
||||
|
||||
interface AdminOAuthSession {
|
||||
id: number
|
||||
@@ -28,6 +32,58 @@ interface AdminMcpToken {
|
||||
|
||||
const SCOPES_PREVIEW = 6
|
||||
|
||||
const ROW = 'flex flex-wrap items-center gap-x-4 gap-y-2 px-3.5 py-3'
|
||||
const TILE = 'grid h-9 w-9 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary'
|
||||
const CHIP = 'inline-flex items-center rounded-full border border-edge-faint bg-surface-secondary px-2 py-[1px] font-geist text-content-muted'
|
||||
|
||||
/** The spinner or the empty line a list shows in place of its rows. */
|
||||
function ListState({ loading, icon, text }: { loading: boolean; icon: ReactNode; text: string }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 size={18} className="animate-spin text-content-faint" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-content-faint">
|
||||
{icon}
|
||||
<SettingsHint>{text}</SettingsHint>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One fact on the right of a row: an eyebrow over its value. */
|
||||
function Meta({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-0 text-right">
|
||||
<div className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5)}>{label}</div>
|
||||
<div className="whitespace-nowrap font-geist tabular-nums text-content-muted" style={fs(12, 'body')}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The owner of a session or token, with the person glyph in front. */
|
||||
function Owner({ name }: { name: string }) {
|
||||
return (
|
||||
<span className="inline-flex min-w-0 max-w-[180px] items-center gap-1.5 text-content-secondary" style={fs(12.5, 'body')}>
|
||||
<User size={13} className="flex-none text-content-faint" />
|
||||
<span className="truncate">{name}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** The trash button of a row, named by its tooltip. */
|
||||
function DeleteAction({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<Tooltip label={label} placement="left">
|
||||
<button type="button" onClick={onClick} aria-label={label} className={`${SETTINGS_ICON_BUTTON} hover:text-danger`}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminMcpTokensPanel() {
|
||||
const [sessions, setSessions] = useState<AdminOAuthSession[]>([])
|
||||
const [sessionsLoading, setSessionsLoading] = useState(true)
|
||||
@@ -80,188 +136,107 @@ export default function AdminMcpTokensPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-content">{t('admin.mcpTokens.title')}</h2>
|
||||
<p className="text-sm mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.subtitle')}</p>
|
||||
</div>
|
||||
const date = (iso: string) => new Date(iso).toLocaleDateString(locale)
|
||||
|
||||
{/* OAuth Sessions */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2 text-content-secondary">{t('admin.oauthSessions.sectionTitle')}</h3>
|
||||
<div className="rounded-xl border overflow-hidden border-edge bg-surface-card">
|
||||
{sessionsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<Shield className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.oauthSessions.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-x-6 px-4 py-2.5 text-xs font-medium border-b border-edge bg-surface-secondary"
|
||||
style={{ color: 'var(--text-tertiary)' }}>
|
||||
<span>{t('admin.oauthSessions.clientName')}</span>
|
||||
<span>{t('admin.oauthSessions.owner')}</span>
|
||||
<span className="text-right">{t('admin.oauthSessions.created')}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{sessions.map((session, i) => {
|
||||
const expanded = expandedScopes.has(session.id)
|
||||
const visible = expanded ? session.scopes : session.scopes.slice(0, SCOPES_PREVIEW)
|
||||
const hidden = session.scopes.length - SCOPES_PREVIEW
|
||||
return (
|
||||
<div key={session.id}
|
||||
className={`grid grid-cols-[1fr_auto_auto_auto] items-start gap-x-6 px-4 py-3 ${i < sessions.length - 1 ? 'border-b border-edge' : ''}`}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate text-content">{session.client_name}</p>
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{visible.map(scope => (
|
||||
<span key={scope} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono bg-surface-secondary border border-edge"
|
||||
style={{ color: 'var(--text-tertiary)' }}>
|
||||
{scope}
|
||||
</span>
|
||||
))}
|
||||
{!expanded && hidden > 0 && (
|
||||
<button type="button" onClick={() => toggleScopes(session.id)}
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80 bg-surface-secondary text-content-secondary border border-edge">
|
||||
+{hidden} more
|
||||
</button>
|
||||
)}
|
||||
{expanded && hidden > 0 && (
|
||||
<button type="button" onClick={() => toggleScopes(session.id)}
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80 bg-surface-secondary text-content-secondary border border-edge">
|
||||
show less
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<div>
|
||||
{/* OAuth Sessions. The tab's own subtitle sits in its band: the sidebar
|
||||
already names the tab, so a bare heading over the cards only repeated it. */}
|
||||
<SettingsCard
|
||||
icon={Bot}
|
||||
title={t('admin.oauthSessions.sectionTitle')}
|
||||
hint={t('admin.mcpTokens.subtitle')}
|
||||
badge={!sessionsLoading && sessions.length > 0 ? <StatusPill>{sessions.length}</StatusPill> : undefined}
|
||||
>
|
||||
{sessionsLoading || sessions.length === 0 ? (
|
||||
<ListState loading={sessionsLoading} icon={<Shield size={24} strokeWidth={1.6} />} text={t('admin.oauthSessions.empty')} />
|
||||
) : (
|
||||
<SettingRows>
|
||||
{sessions.map(session => {
|
||||
const expanded = expandedScopes.has(session.id)
|
||||
const visible = expanded ? session.scopes : session.scopes.slice(0, SCOPES_PREVIEW)
|
||||
const hidden = session.scopes.length - SCOPES_PREVIEW
|
||||
return (
|
||||
<div key={session.id} className={`${ROW} items-start`}>
|
||||
<span className={TILE}><Bot size={16} strokeWidth={1.9} /></span>
|
||||
<div className="min-w-0 flex-1 basis-60">
|
||||
<p className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>{session.client_name}</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1" style={fs(11)}>
|
||||
{visible.map(scope => (
|
||||
<span key={scope} className={CHIP}>{scope}</span>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<button type="button" onClick={() => toggleScopes(session.id)}
|
||||
className="inline-flex items-center rounded-full bg-surface-tertiary px-2 py-[1px] font-semibold text-content-secondary hover:text-content">
|
||||
{expanded ? 'show less' : `+${hidden} more`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm pt-0.5 text-content-secondary">
|
||||
<User className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">{session.username}</span>
|
||||
</div>
|
||||
<span className="text-xs whitespace-nowrap text-right pt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{new Date(session.created_at).toLocaleDateString(locale)}
|
||||
</span>
|
||||
<button type="button" onClick={() => setRevokeConfirmId(session.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-none items-center gap-4 pt-0.5">
|
||||
<Owner name={session.username} />
|
||||
<Meta label={t('admin.oauthSessions.created')}>{date(session.created_at)}</Meta>
|
||||
<DeleteAction label={t('common.delete')} onClick={() => setRevokeConfirmId(session.id)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</SettingRows>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
{/* MCP Tokens */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2 text-content-secondary">{t('admin.mcpTokens.sectionTitle')}</h3>
|
||||
<div className="rounded-xl border overflow-hidden border-edge bg-surface-card">
|
||||
{tokensLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</div>
|
||||
) : tokens.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<Key className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b border-edge bg-surface-secondary"
|
||||
style={{ color: 'var(--text-tertiary)' }}>
|
||||
<span>{t('admin.mcpTokens.tokenName')}</span>
|
||||
<span>{t('admin.mcpTokens.owner')}</span>
|
||||
<span className="text-right">{t('admin.mcpTokens.created')}</span>
|
||||
<span className="text-right">{t('admin.mcpTokens.lastUsed')}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{tokens.map((token, i) => (
|
||||
<div key={token.id}
|
||||
className={`grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3 ${i < tokens.length - 1 ? 'border-b border-edge' : ''}`}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate text-content">{token.name}</p>
|
||||
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{token.token_prefix}...</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm text-content-secondary">
|
||||
<User className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">{token.username}</span>
|
||||
</div>
|
||||
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{new Date(token.created_at).toLocaleDateString(locale)}
|
||||
</span>
|
||||
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')}
|
||||
</span>
|
||||
<button type="button" onClick={() => setDeleteConfirmId(token.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<SettingsCard
|
||||
icon={KeyRound}
|
||||
title={t('admin.mcpTokens.sectionTitle')}
|
||||
badge={!tokensLoading && tokens.length > 0 ? <StatusPill>{tokens.length}</StatusPill> : undefined}
|
||||
>
|
||||
{tokensLoading || tokens.length === 0 ? (
|
||||
<ListState loading={tokensLoading} icon={<Key size={24} strokeWidth={1.6} />} text={t('admin.mcpTokens.empty')} />
|
||||
) : (
|
||||
<SettingRows>
|
||||
{tokens.map(token => (
|
||||
<div key={token.id} className={ROW}>
|
||||
<span className={TILE}><KeyRound size={16} strokeWidth={1.9} /></span>
|
||||
<div className="min-w-0 flex-1 basis-48">
|
||||
<p className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>{token.name}</p>
|
||||
<p className="m-0 mt-0.5 truncate font-geist text-content-faint" style={fs(11)}>{token.token_prefix}...</p>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-none items-center gap-4">
|
||||
<Owner name={token.username} />
|
||||
<Meta label={t('admin.mcpTokens.created')}>{date(token.created_at)}</Meta>
|
||||
<Meta label={t('admin.mcpTokens.lastUsed')}>
|
||||
{token.last_used_at ? date(token.last_used_at) : t('admin.mcpTokens.never')}
|
||||
</Meta>
|
||||
<DeleteAction label={t('common.delete')} onClick={() => setDeleteConfirmId(token.id)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</SettingRows>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
{/* Revoke OAuth session modal */}
|
||||
{revokeConfirmId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
role="button" tabIndex={0} aria-label={t('common.close')}
|
||||
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}
|
||||
onKeyDown={e => {
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setRevokeConfirmId(null) }
|
||||
}}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
|
||||
<h3 className="text-base font-semibold text-content">{t('admin.oauthSessions.revokeTitle')}</h3>
|
||||
<p className="text-sm text-content-secondary">{t('admin.oauthSessions.revokeMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button type="button" onClick={() => setRevokeConfirmId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" onClick={() => handleRevoke(revokeConfirmId)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Revoke OAuth session */}
|
||||
<ConfirmDialog
|
||||
isOpen={revokeConfirmId !== null}
|
||||
onClose={() => setRevokeConfirmId(null)}
|
||||
onConfirm={() => { if (revokeConfirmId !== null) void handleRevoke(revokeConfirmId) }}
|
||||
title={t('admin.oauthSessions.revokeTitle')}
|
||||
message={t('admin.oauthSessions.revokeMessage')}
|
||||
confirmLabel={t('common.delete')}
|
||||
danger
|
||||
/>
|
||||
|
||||
{/* Delete MCP token modal */}
|
||||
{deleteConfirmId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
role="button" tabIndex={0} aria-label={t('common.close')}
|
||||
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}
|
||||
onKeyDown={e => {
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setDeleteConfirmId(null) }
|
||||
}}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
|
||||
<h3 className="text-base font-semibold text-content">{t('admin.mcpTokens.deleteTitle')}</h3>
|
||||
<p className="text-sm text-content-secondary">{t('admin.mcpTokens.deleteMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button type="button" onClick={() => setDeleteConfirmId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" onClick={() => handleDelete(deleteConfirmId)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Delete MCP token */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirmId !== null}
|
||||
onClose={() => setDeleteConfirmId(null)}
|
||||
onConfirm={() => { if (deleteConfirmId !== null) void handleDelete(deleteConfirmId) }}
|
||||
title={t('admin.mcpTokens.deleteTitle')}
|
||||
message={t('admin.mcpTokens.deleteMessage')}
|
||||
confirmLabel={t('common.delete')}
|
||||
danger
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,12 @@ function mockList(p: Record<string, unknown>) {
|
||||
|
||||
beforeEach(() => resetAllStores())
|
||||
|
||||
/** A press on a dialog's backdrop: DialogShell closes only when the press both starts and ends there. */
|
||||
function clickBackdrop(el: HTMLElement) {
|
||||
fireEvent.mouseDown(el)
|
||||
fireEvent.click(el)
|
||||
}
|
||||
|
||||
describe('AdminPluginsPanel — allowed-hosts chip', () => {
|
||||
it('FE-COMP-PLUGINS-EGRESS-001: invites the admin to add a host when none is set', async () => {
|
||||
mockList(plugin({ egressHostCount: 0 }))
|
||||
@@ -658,7 +664,7 @@ describe('AdminPluginsPanel — load states and toolbar', () => {
|
||||
withToast()
|
||||
await screen.findByText('Gotify')
|
||||
|
||||
fireEvent.click(screen.getAllByTitle('Rescan')[0])
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Rescan' })[0])
|
||||
|
||||
expect(await screen.findByText('Rescanned the plugins folder')).toBeInTheDocument()
|
||||
expect(refreshFlags).toContain('1')
|
||||
@@ -676,7 +682,7 @@ describe('AdminPluginsPanel — sideloading', () => {
|
||||
|
||||
const input = container.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const clickSpy = vi.spyOn(input, 'click').mockImplementation(() => {})
|
||||
fireEvent.click(screen.getAllByTitle('Upload plugin')[0])
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Upload plugin' })[0])
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
clickSpy.mockRestore()
|
||||
|
||||
@@ -763,7 +769,7 @@ describe('AdminPluginsPanel — row actions', () => {
|
||||
|
||||
/** Every modal in the panel closes by clicking its backdrop. */
|
||||
function closeModal() {
|
||||
fireEvent.click(document.querySelector('.fixed.inset-0.z-50') as HTMLElement)
|
||||
clickBackdrop(document.querySelector('.trek-modal-backdrop') as HTMLElement)
|
||||
}
|
||||
|
||||
it('FE-COMP-PLUGINS-PANEL-014: the error log lists what the runtime recorded', async () => {
|
||||
@@ -888,7 +894,7 @@ describe('AdminPluginsPanel — row actions', () => {
|
||||
const body = (await screen.findByText('gotify.lan')).closest('.overflow-y-auto')
|
||||
expect(body).not.toBeNull()
|
||||
expect(body).toContainElement(screen.getByPlaceholderText('gotify.example.com'))
|
||||
expect(body!.parentElement!.className).toContain('max-h-[88vh]')
|
||||
expect(body!.parentElement!.className).toContain('max-h-full')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1875,7 +1881,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
await user.click(screen.getByRole('button', { name: /view error log/i }))
|
||||
|
||||
const empty = await screen.findByText(/no errors/i)
|
||||
fireEvent.click(empty.closest('.fixed') as HTMLElement)
|
||||
clickBackdrop(empty.closest('.fixed') as HTMLElement)
|
||||
await waitFor(() => expect(screen.queryByText(/no errors/i)).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
@@ -2052,7 +2058,7 @@ describe('AdminPluginsPanel registry cards and dependency chips', () => {
|
||||
fireEvent.keyDown(card, { key: 'Enter' })
|
||||
expect(await screen.findByRole('heading', { name: 'Mapper' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('heading', { name: 'Mapper' }).closest('.fixed') as HTMLElement)
|
||||
clickBackdrop(screen.getByRole('heading', { name: 'Mapper' }).closest('.fixed') as HTMLElement)
|
||||
await waitFor(() => expect(screen.queryByRole('heading', { name: 'Mapper' })).not.toBeInTheDocument())
|
||||
|
||||
fireEvent.keyDown(card, { key: ' ' })
|
||||
@@ -2174,7 +2180,7 @@ describe('AdminPluginsPanel signature fingerprints and dismissals', () => {
|
||||
fireEvent.click(await screen.findByRole('button', { name: /review/i }))
|
||||
const detail = await screen.findByText('only half signed')
|
||||
|
||||
fireEvent.click(detail.closest('.fixed') as HTMLElement)
|
||||
clickBackdrop(detail.closest('.fixed') as HTMLElement)
|
||||
await waitFor(() => expect(screen.queryByText('only half signed')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
@@ -2196,7 +2202,7 @@ describe('AdminPluginsPanel signature fingerprints and dismissals', () => {
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /1 allowed host/i }))
|
||||
const reopened = (await screen.findByText('gotify.mydomain.com')).closest('.fixed') as HTMLElement
|
||||
fireEvent.click(reopened)
|
||||
clickBackdrop(reopened)
|
||||
await waitFor(() => expect(screen.queryByText('gotify.mydomain.com')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
@@ -2355,15 +2361,17 @@ describe('AdminPluginsPanel — instance settings', () => {
|
||||
fireEvent.click(screen.getByText('Instance settings'))
|
||||
|
||||
// Stored values land in the right controls; an unset value renders empty.
|
||||
const select = await screen.findByRole('combobox', {}, { timeout: 5000 })
|
||||
expect(select).toHaveValue('slow')
|
||||
expect(screen.getByRole('checkbox')).toBeChecked()
|
||||
const select = await screen.findByRole('button', { name: 'Mode' }, { timeout: 5000 })
|
||||
expect(select).toHaveTextContent('Slow')
|
||||
const toggle = screen.getByRole('button', { name: 'enabled' })
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByRole('spinbutton')).toHaveValue(null)
|
||||
expect(screen.getByText('Pick one')).toBeInTheDocument() // hint
|
||||
expect(screen.getByText('enabled')).toBeInTheDocument() // label falls back to the key
|
||||
|
||||
fireEvent.change(select, { target: { value: 'fast' } })
|
||||
fireEvent.click(screen.getByRole('checkbox'))
|
||||
fireEvent.click(select)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Fast' }))
|
||||
fireEvent.click(toggle)
|
||||
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '3' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,9 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { RefreshCw, ClipboardList } from 'lucide-react'
|
||||
import { RefreshCw, ClipboardList, Loader2 } from 'lucide-react'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { SETTINGS_BUTTON, SettingsCard, SettingsHint, StatusPill } from '../Settings/settingsKit'
|
||||
|
||||
interface AuditEntry {
|
||||
id: number
|
||||
@@ -19,6 +21,10 @@ interface AuditLogPanelProps {
|
||||
serverTimezone?: string
|
||||
}
|
||||
|
||||
const TH = 'whitespace-nowrap px-3.5 py-2.5 text-left font-geist font-bold uppercase tracking-[.08em] text-content-faint'
|
||||
const TD = 'px-3.5 py-2.5 align-top'
|
||||
const CODE = 'font-geist tabular-nums'
|
||||
|
||||
export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([])
|
||||
@@ -96,74 +102,86 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R
|
||||
return '—'
|
||||
}
|
||||
|
||||
let body: React.ReactNode
|
||||
if (loading && entries.length === 0) {
|
||||
body = (
|
||||
<div className="flex items-center justify-center gap-2 py-10 text-content-faint" style={fs(12.5, 'body')}>
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
<span>{t('common.loading')}</span>
|
||||
</div>
|
||||
)
|
||||
} else if (entries.length === 0) {
|
||||
body = <SettingsHint className="py-6 text-center">{t('admin.audit.empty')}</SettingsHint>
|
||||
} else {
|
||||
body = (
|
||||
<div className={`overflow-x-auto rounded-[12px] border border-edge-faint bg-surface-card transition-opacity ${loading ? 'opacity-60' : ''}`}>
|
||||
<table className="w-full min-w-[720px] border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-edge-faint bg-surface-secondary" style={fs(9.5)}>
|
||||
<th className={TH}>{t('admin.audit.col.time')}</th>
|
||||
<th className={TH}>{t('admin.audit.col.user')}</th>
|
||||
<th className={TH}>{t('admin.audit.col.action')}</th>
|
||||
<th className={TH}>{t('admin.audit.col.resource')}</th>
|
||||
<th className={TH}>{t('admin.audit.col.ip')}</th>
|
||||
<th className={TH}>{t('admin.audit.col.details')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-edge-faint" style={fs(12, 'body')}>
|
||||
{entries.map((e) => (
|
||||
<tr key={e.id} className="hover:bg-surface-secondary">
|
||||
<td className={`${TD} ${CODE} whitespace-nowrap text-content-muted`}>{fmtTime(e.created_at)}</td>
|
||||
<td className={`${TD} font-medium text-content`}>
|
||||
{/* A table cell ignores max-width under auto layout, so the box inside truncates. */}
|
||||
<span className="block max-w-[180px] truncate" title={userLabel(e)}>{userLabel(e)}</span>
|
||||
</td>
|
||||
<td className={`${TD} whitespace-nowrap`}>
|
||||
<span className={`${CODE} inline-block rounded-[6px] bg-surface-tertiary px-1.5 py-[1px] text-content`}>{e.action}</span>
|
||||
</td>
|
||||
<td className={`${TD} ${CODE} max-w-[160px] break-all text-content-muted`}>{e.resource || '—'}</td>
|
||||
<td className={`${TD} ${CODE} whitespace-nowrap text-content-muted`}>{e.ip || '—'}</td>
|
||||
<td className={`${TD} ${CODE} max-w-[300px] break-all text-content-faint`}>{fmtDetails(e.details)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg m-0 flex items-center gap-2 text-content">
|
||||
<ClipboardList size={20} />
|
||||
{t('admin.tabs.audit')}
|
||||
</h2>
|
||||
<p className="text-sm m-0 mt-1 text-content-muted">{t('admin.audit.subtitle')}</p>
|
||||
</div>
|
||||
<SettingsCard
|
||||
icon={ClipboardList}
|
||||
title={t('admin.tabs.audit')}
|
||||
hint={t('admin.audit.subtitle')}
|
||||
badge={<StatusPill>{t('admin.audit.showing', { count: entries.length, total })}</StatusPill>}
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => loadFirstPage()}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-opacity disabled:opacity-50 border-edge text-content bg-surface-card"
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
<RefreshCw size={14} strokeWidth={2.2} className={loading ? 'animate-spin' : ''} />
|
||||
{t('admin.audit.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs m-0 text-content-faint">
|
||||
{t('admin.audit.showing', { count: entries.length, total })}
|
||||
</p>
|
||||
|
||||
{loading && entries.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-content-muted">{t('common.loading')}</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-content-muted">{t('admin.audit.empty')}</div>
|
||||
) : (
|
||||
<div className="rounded-xl border overflow-x-auto border-edge bg-surface-card">
|
||||
<table className="w-full text-sm border-collapse min-w-[720px]">
|
||||
<thead>
|
||||
<tr className="border-b text-left border-edge-secondary">
|
||||
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.time')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.user')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.action')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.resource')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.ip')}</th>
|
||||
<th className="p-3 font-semibold text-content-secondary">{t('admin.audit.col.details')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => (
|
||||
<tr key={e.id} className="border-b align-top border-edge-secondary">
|
||||
<td className="p-3 whitespace-nowrap font-mono text-xs text-content">{fmtTime(e.created_at)}</td>
|
||||
<td className="p-3 text-content">{userLabel(e)}</td>
|
||||
<td className="p-3 font-mono text-xs text-content">{e.action}</td>
|
||||
<td className="p-3 font-mono text-xs break-all max-w-[140px] text-content-muted">{e.resource || '—'}</td>
|
||||
<td className="p-3 font-mono text-xs whitespace-nowrap text-content-muted">{e.ip || '—'}</td>
|
||||
<td className="p-3 font-mono text-xs break-all max-w-[280px] text-content-faint">{fmtDetails(e.details)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
>
|
||||
{body}
|
||||
|
||||
{entries.length < total && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => loadMore()}
|
||||
className="text-sm font-medium underline-offset-2 hover:underline disabled:opacity-50 text-content-secondary"
|
||||
>
|
||||
{t('admin.audit.loadMore')}
|
||||
</button>
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => loadMore()}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{t('admin.audit.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -165,8 +165,8 @@ describe('BackupPanel', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Restore Backup?')).toBeInTheDocument()
|
||||
})
|
||||
// Click the backdrop overlay (the fixed-position div)
|
||||
const backdrop = document.querySelector('[style*="position: fixed"]') as HTMLElement
|
||||
// Click the backdrop overlay (the shared confirm dialog's dimmed layer)
|
||||
const backdrop = document.querySelector('.trek-backdrop-enter') as HTMLElement
|
||||
expect(backdrop).toBeTruthy()
|
||||
fireEvent.click(backdrop!)
|
||||
await waitFor(() => {
|
||||
@@ -215,6 +215,10 @@ describe('BackupPanel', () => {
|
||||
) as HTMLElement
|
||||
expect(trashBtn).toBeTruthy()
|
||||
await user.click(trashBtn!)
|
||||
// The question is the shared confirm dialog now, not window.confirm
|
||||
expect(await screen.findByText('Delete backup "backup-2025-01-15.zip"?')).toBeInTheDocument()
|
||||
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' })
|
||||
await user.click(deleteButtons[deleteButtons.length - 1]!)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Backup deleted')).toBeInTheDocument()
|
||||
})
|
||||
@@ -421,7 +425,6 @@ describe('BackupPanel', () => {
|
||||
return HttpResponse.json({ error: 'file is locked' }, { status: 500 })
|
||||
}),
|
||||
)
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await screen.findByText('backup-2025-01-15.zip')
|
||||
|
||||
@@ -429,11 +432,13 @@ describe('BackupPanel', () => {
|
||||
b => b.querySelector('svg.lucide-trash2'),
|
||||
) as HTMLElement
|
||||
await user.click(trashBtn)
|
||||
await user.click(await screen.findByRole('button', { name: 'Cancel' }))
|
||||
expect(deleteCalls).toBe(0)
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
|
||||
|
||||
confirmSpy.mockReturnValue(true)
|
||||
await user.click(trashBtn)
|
||||
const deleteButtons = await screen.findAllByRole('button', { name: 'Delete' })
|
||||
await user.click(deleteButtons[deleteButtons.length - 1]!)
|
||||
|
||||
expect(await screen.findByText('Failed to delete')).toBeInTheDocument()
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
|
||||
@@ -584,18 +589,24 @@ describe('BackupPanel', () => {
|
||||
expect(await screen.findByText('Download failed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// BKP-029: Confirm button hover styling
|
||||
it('FE-ADMIN-BKP-029: the destructive confirm button darkens on hover', async () => {
|
||||
// BKP-029: Escape takes the restore question back
|
||||
it('FE-ADMIN-BKP-029: Escape dismisses the restore question without restoring', async () => {
|
||||
const user = userEvent.setup()
|
||||
let restoreCalled = false
|
||||
server.use(
|
||||
http.post('/api/backup/restore/:filename', () => {
|
||||
restoreCalled = true
|
||||
return HttpResponse.json({ success: true })
|
||||
}),
|
||||
)
|
||||
render(<BackupPanel />)
|
||||
await screen.findByText('backup-2025-01-15.zip')
|
||||
|
||||
await user.click(screen.getAllByText('Restore')[0])
|
||||
const confirmBtn = await screen.findByText('Yes, restore')
|
||||
await screen.findByText('Yes, restore')
|
||||
|
||||
fireEvent.mouseEnter(confirmBtn)
|
||||
expect(confirmBtn.style.background).toBe('rgb(185, 28, 28)')
|
||||
fireEvent.mouseLeave(confirmBtn)
|
||||
expect(confirmBtn.style.background).toBe('rgb(220, 38, 38)')
|
||||
await user.keyboard('{Escape}')
|
||||
await waitFor(() => expect(screen.queryByText('Restore Backup?')).not.toBeInTheDocument())
|
||||
expect(restoreCalled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useState, useEffect, useRef, type ChangeEvent } from 'react'
|
||||
import { backupApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react'
|
||||
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { Segmented } from '../shared/dialogParts'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
import {
|
||||
ChoiceChips,
|
||||
SETTINGS_BUTTON,
|
||||
SETTINGS_BUTTON_PRIMARY,
|
||||
SETTINGS_ICON_BUTTON,
|
||||
SettingRow,
|
||||
SettingRows,
|
||||
SettingsCard,
|
||||
StatusPill,
|
||||
} from '../Settings/settingsKit'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
const INTERVAL_OPTIONS = [
|
||||
@@ -37,20 +53,36 @@ const HOURS = Array.from({ length: 24 }, (_, i) => i)
|
||||
|
||||
const DAYS_OF_MONTH = Array.from({ length: 28 }, (_, i) => i + 1)
|
||||
|
||||
/** The compact buttons of a backup row: white on a hairline, a size under the card's own. */
|
||||
const ROW_BUTTON = 'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 py-1.5 font-medium text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:cursor-default disabled:opacity-50'
|
||||
|
||||
interface BackupItem {
|
||||
filename: string
|
||||
created_at?: string | null
|
||||
size?: number | null
|
||||
}
|
||||
|
||||
interface RestoreTarget {
|
||||
type: 'file' | 'upload'
|
||||
filename: string
|
||||
file?: File
|
||||
}
|
||||
|
||||
export default function BackupPanel() {
|
||||
const [backups, setBackups] = useState([])
|
||||
const [backups, setBackups] = useState<BackupItem[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [restoringFile, setRestoringFile] = useState(null)
|
||||
const [restoringFile, setRestoringFile] = useState<string | null>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 })
|
||||
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
|
||||
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
|
||||
const [serverTimezone, setServerTimezone] = useState('')
|
||||
const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? }
|
||||
const fileInputRef = useRef(null)
|
||||
const [restoreConfirm, setRestoreConfirm] = useState<RestoreTarget | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const toast = useToast()
|
||||
const { t, language, locale } = useTranslation()
|
||||
const { t, locale } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
|
||||
const loadBackups = async () => {
|
||||
@@ -88,12 +120,12 @@ export default function BackupPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestore = (filename) => {
|
||||
const handleRestore = (filename: string) => {
|
||||
setRestoreConfirm({ type: 'file', filename })
|
||||
}
|
||||
|
||||
const handleUploadRestore = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
const handleUploadRestore = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
e.target.value = ''
|
||||
setRestoreConfirm({ type: 'upload', filename: file.name, file })
|
||||
@@ -127,8 +159,15 @@ export default function BackupPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (filename) => {
|
||||
if (!confirm(t('backup.confirm.delete', { name: filename }))) return
|
||||
// The question is asked in the planner's confirm dialog; the delete itself runs once it is answered.
|
||||
const handleDelete = (filename: string) => {
|
||||
setDeleteTarget(filename)
|
||||
}
|
||||
|
||||
const executeDelete = async () => {
|
||||
const filename = deleteTarget
|
||||
setDeleteTarget(null)
|
||||
if (!filename) return
|
||||
try {
|
||||
await backupApi.delete(filename)
|
||||
toast.success(t('backup.toast.deleted'))
|
||||
@@ -138,7 +177,7 @@ export default function BackupPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAutoSettingsChange = (key, value) => {
|
||||
const handleAutoSettingsChange = (key: string, value: unknown) => {
|
||||
setAutoSettings(prev => ({ ...prev, [key]: value }))
|
||||
setAutoSettingsDirty(true)
|
||||
}
|
||||
@@ -157,13 +196,13 @@ export default function BackupPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
const formatSize = (bytes: number | null | undefined) => {
|
||||
if (!bytes) return '-'
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const formatDate = (dateStr: string | null | undefined) => {
|
||||
if (!dateStr) return '-'
|
||||
try {
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
@@ -175,352 +214,315 @@ export default function BackupPanel() {
|
||||
} catch { return dateStr }
|
||||
}
|
||||
|
||||
const isAuto = (filename) => filename.startsWith('auto-backup-')
|
||||
const isAuto = (filename: string) => filename.startsWith('auto-backup-')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
const headerActions = (
|
||||
<>
|
||||
<Tooltip label={t('backup.refresh')}>
|
||||
<button type="button"
|
||||
onClick={loadBackups}
|
||||
disabled={isLoading}
|
||||
aria-label={t('backup.refresh')}
|
||||
className={SETTINGS_ICON_BUTTON}
|
||||
>
|
||||
<RefreshCw size={14} strokeWidth={2} className={isLoading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Manual Backups */}
|
||||
<div className="bg-white rounded-2xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<HardDrive className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">{t('backup.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted">{t('backup.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button"
|
||||
onClick={loadBackups}
|
||||
disabled={isLoading}
|
||||
className="p-2 text-gray-500 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title={t('backup.refresh')}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
{/* Upload & Restore */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="hidden"
|
||||
onChange={handleUploadRestore}
|
||||
/>
|
||||
<button type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
title={isUploading ? t('backup.uploading') : t('backup.upload')}
|
||||
>
|
||||
{isUploading
|
||||
? <Loader2 size={14} strokeWidth={2.2} className="animate-spin" />
|
||||
: <Upload size={14} strokeWidth={2.2} />}
|
||||
<span>{isUploading ? t('backup.uploading') : t('backup.upload')}</span>
|
||||
</button>
|
||||
|
||||
{/* Upload & Restore */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="hidden"
|
||||
onChange={handleUploadRestore}
|
||||
/>
|
||||
<button type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="flex items-center gap-2 border border-gray-200 text-gray-700 px-3 py-2 rounded-lg hover:bg-gray-50 text-sm font-medium disabled:opacity-60"
|
||||
title={isUploading ? t('backup.uploading') : t('backup.upload')}
|
||||
>
|
||||
{isUploading ? (
|
||||
<div className="w-4 h-4 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Upload className="w-4 h-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">{isUploading ? t('backup.uploading') : t('backup.upload')}</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={handleCreate}
|
||||
disabled={isCreating}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
style={fs(12.5, 'body')}
|
||||
title={isCreating ? t('backup.creating') : t('backup.create')}
|
||||
>
|
||||
{isCreating
|
||||
? <Loader2 size={14} strokeWidth={2.2} className="animate-spin" />
|
||||
: <Plus size={14} strokeWidth={2.2} />}
|
||||
<span>{isCreating ? t('backup.creating') : t('backup.create')}</span>
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
|
||||
<button type="button"
|
||||
onClick={handleCreate}
|
||||
disabled={isCreating}
|
||||
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-60"
|
||||
title={isCreating ? t('backup.creating') : t('backup.create')}
|
||||
>
|
||||
{isCreating ? (
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Plus className="w-4 h-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">{isCreating ? t('backup.creating') : t('backup.create')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && backups.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12 text-gray-400">
|
||||
<div className="w-6 h-6 border-2 border-gray-300 border-t-slate-700 rounded-full animate-spin mr-2" />
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : backups.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<HardDrive className="w-10 h-10 mb-3 mx-auto opacity-40" />
|
||||
<p className="text-sm">{t('backup.empty')}</p>
|
||||
<button type="button" onClick={handleCreate} className="mt-4 text-slate-700 text-sm hover:underline">
|
||||
{t('backup.createFirst')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{backups.map(backup => (
|
||||
<div key={backup.filename} className="flex items-center gap-4 py-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0">
|
||||
{isAuto(backup.filename)
|
||||
? <RefreshCw className="w-4 h-4 text-blue-500" />
|
||||
: <HardDrive className="w-4 h-4 text-gray-500" />
|
||||
}
|
||||
let backupList
|
||||
if (isLoading && backups.length === 0) {
|
||||
backupList = (
|
||||
<div className="flex items-center justify-center gap-2 py-10 text-content-faint" style={fs(12.5, 'body')}>
|
||||
<Loader2 size={16} strokeWidth={2} className="animate-spin" />
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
)
|
||||
} else if (backups.length === 0) {
|
||||
backupList = (
|
||||
<EmptyState
|
||||
title={t('backup.empty')}
|
||||
size={84}
|
||||
compact
|
||||
surface="var(--bg-secondary)"
|
||||
className="!py-6"
|
||||
action={
|
||||
<button type="button" onClick={handleCreate} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
|
||||
<Plus size={14} strokeWidth={2.2} />
|
||||
{t('backup.createFirst')}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
backupList = (
|
||||
<SettingRows>
|
||||
{backups.map(backup => {
|
||||
const auto = isAuto(backup.filename)
|
||||
const restoring = restoringFile === backup.filename
|
||||
return (
|
||||
<div key={backup.filename} className="flex flex-wrap items-center gap-x-3 gap-y-2 px-3.5 py-3">
|
||||
<span className="grid h-9 w-9 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-muted">
|
||||
{auto
|
||||
? <RefreshCw size={15} strokeWidth={2} />
|
||||
: <HardDrive size={15} strokeWidth={2} />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 basis-48">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="m-0 truncate font-geist font-semibold text-content" style={fs(13, 'body')}>{backup.filename}</p>
|
||||
{auto && <StatusPill>Auto</StatusPill>}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-sm text-gray-900 truncate">{backup.filename}</p>
|
||||
{isAuto(backup.filename) && (
|
||||
<span className="text-xs bg-blue-50 text-blue-600 border border-blue-100 rounded-full px-2 py-0.5 whitespace-nowrap">Auto</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-0.5">
|
||||
<span className="text-xs text-gray-400">{formatDate(backup.created_at)}</span>
|
||||
<span className="text-xs text-gray-400">{formatSize(backup.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<button type="button"
|
||||
onClick={() => backupApi.download(backup.filename).catch(() => toast.error(t('backup.toast.downloadError')))}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-slate-700 border border-slate-200 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
{t('backup.download')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => handleRestore(backup.filename)}
|
||||
disabled={restoringFile === backup.filename}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-amber-700 border border-amber-200 rounded-lg hover:bg-amber-50 disabled:opacity-60"
|
||||
>
|
||||
{restoringFile === backup.filename
|
||||
? <div className="w-3.5 h-3.5 border-2 border-amber-400 border-t-transparent rounded-full animate-spin" />
|
||||
: <RotateCcw className="w-3.5 h-3.5" />
|
||||
}
|
||||
{t('backup.restore')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => handleDelete(backup.filename)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 font-geist tabular-nums text-content-faint" style={fs(11.5)}>
|
||||
<span>{formatDate(backup.created_at)}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{formatSize(backup.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-none items-center gap-1.5">
|
||||
<button type="button"
|
||||
onClick={() => backupApi.download(backup.filename).catch(() => toast.error(t('backup.toast.downloadError')))}
|
||||
className={ROW_BUTTON}
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
<Download size={13} strokeWidth={2.2} />
|
||||
{t('backup.download')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => handleRestore(backup.filename)}
|
||||
disabled={restoring}
|
||||
className={ROW_BUTTON}
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{restoring
|
||||
? <Loader2 size={13} strokeWidth={2.2} className="animate-spin text-warning" />
|
||||
: <RotateCcw size={13} strokeWidth={2.2} className="text-warning" />}
|
||||
{t('backup.restore')}
|
||||
</button>
|
||||
<Tooltip label={t('common.delete')}>
|
||||
<button type="button"
|
||||
onClick={() => handleDelete(backup.filename)}
|
||||
aria-label={t('common.delete')}
|
||||
className={`${SETTINGS_ICON_BUTTON} hover:!text-danger`}
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</SettingRows>
|
||||
)
|
||||
}
|
||||
|
||||
const hourOptions = HOURS.map(h => {
|
||||
let label: string
|
||||
if (is12h) {
|
||||
const period = h >= 12 ? 'PM' : 'AM'
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||
label = `${h12}:00 ${period}`
|
||||
} else {
|
||||
label = `${String(h).padStart(2, '0')}:00`
|
||||
}
|
||||
return { value: String(h), label }
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
|
||||
{/* Manual Backups */}
|
||||
<SettingsCard
|
||||
icon={HardDrive}
|
||||
title={t('backup.title')}
|
||||
hint={t('backup.subtitle')}
|
||||
action={headerActions}
|
||||
>
|
||||
{backupList}
|
||||
</SettingsCard>
|
||||
|
||||
{/* Auto-Backup Settings */}
|
||||
<div className="bg-white rounded-2xl border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Clock className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">{t('backup.auto.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted">{t('backup.auto.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Enable toggle */}
|
||||
<label className="flex items-center justify-between gap-4 cursor-pointer">
|
||||
<div className="min-w-0 text-sm font-medium text-gray-900">
|
||||
{t('backup.auto.enable')}
|
||||
<p className="text-xs font-normal text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p>
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
|
||||
className="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: autoSettings.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: autoSettings.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
<SettingsCard icon={Clock} title={t('backup.auto.title')} hint={t('backup.auto.subtitle')}>
|
||||
<SettingRows>
|
||||
{/* The whole row is the switch's label, so a click on the text flips it too. */}
|
||||
<label className="flex cursor-pointer items-center gap-4 px-3.5 py-3">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-medium text-content" style={fs(13, 'body')}>{t('backup.auto.enable')}</span>
|
||||
<span className="mt-0.5 block leading-snug text-content-faint" style={fs(11.5)}>{t('backup.auto.enableHint')}</span>
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
on={!!autoSettings.enabled}
|
||||
onToggle={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
|
||||
label={t('backup.auto.enable')}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{autoSettings.enabled && (
|
||||
<>
|
||||
{/* Interval */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.interval')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{INTERVAL_OPTIONS.map(opt => (
|
||||
<button type="button"
|
||||
key={opt.value}
|
||||
onClick={() => handleAutoSettingsChange('interval', opt.value)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
autoSettings.interval === opt.value
|
||||
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
|
||||
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t(opt.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t('backup.auto.interval')}
|
||||
control={
|
||||
<Segmented<string>
|
||||
label={t('backup.auto.interval')}
|
||||
value={autoSettings.interval}
|
||||
onChange={v => handleAutoSettingsChange('interval', v)}
|
||||
options={INTERVAL_OPTIONS.map(opt => ({ value: opt.value, label: t(opt.labelKey) }))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Hour picker (for daily, weekly, monthly) */}
|
||||
{autoSettings.interval !== 'hourly' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.hour')}</label>
|
||||
<CustomSelect
|
||||
value={String(autoSettings.hour)}
|
||||
onChange={v => handleAutoSettingsChange('hour', Number.parseInt(String(v), 10))}
|
||||
size="sm"
|
||||
options={HOURS.map(h => {
|
||||
let label: string
|
||||
if (is12h) {
|
||||
const period = h >= 12 ? 'PM' : 'AM'
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||
label = `${h12}:00 ${period}`
|
||||
} else {
|
||||
label = `${String(h).padStart(2, '0')}:00`
|
||||
}
|
||||
return { value: String(h), label }
|
||||
})}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{t('backup.auto.hourHint', { format: is12h ? '12h' : '24h' })}{serverTimezone ? ` (Timezone: ${serverTimezone})` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t('backup.auto.hour')}
|
||||
hint={<>{t('backup.auto.hourHint', { format: is12h ? '12h' : '24h' })}{serverTimezone ? ` (Timezone: ${serverTimezone})` : ''}</>}
|
||||
control={
|
||||
<div className="w-40">
|
||||
<CustomSelect
|
||||
value={String(autoSettings.hour)}
|
||||
onChange={v => handleAutoSettingsChange('hour', Number.parseInt(String(v), 10))}
|
||||
size="sm"
|
||||
options={hourOptions}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Day of week (for weekly) */}
|
||||
{autoSettings.interval === 'weekly' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfWeek')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DAYS_OF_WEEK.map(opt => (
|
||||
<button type="button"
|
||||
key={opt.value}
|
||||
onClick={() => handleAutoSettingsChange('day_of_week', opt.value)}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
autoSettings.day_of_week === opt.value
|
||||
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
|
||||
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t(opt.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<SettingRow
|
||||
stacked
|
||||
label={t('backup.auto.dayOfWeek')}
|
||||
control={
|
||||
<ChoiceChips<string>
|
||||
label={t('backup.auto.dayOfWeek')}
|
||||
value={String(autoSettings.day_of_week)}
|
||||
onChange={v => handleAutoSettingsChange('day_of_week', Number(v))}
|
||||
options={DAYS_OF_WEEK.map(opt => ({ value: String(opt.value), label: t(opt.labelKey) }))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Day of month (for monthly) */}
|
||||
{autoSettings.interval === 'monthly' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfMonth')}</label>
|
||||
<CustomSelect
|
||||
value={String(autoSettings.day_of_month)}
|
||||
onChange={v => handleAutoSettingsChange('day_of_month', Number.parseInt(String(v), 10))}
|
||||
size="sm"
|
||||
options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">{t('backup.auto.dayOfMonthHint')}</p>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t('backup.auto.dayOfMonth')}
|
||||
hint={t('backup.auto.dayOfMonthHint')}
|
||||
control={
|
||||
<div className="w-28">
|
||||
<CustomSelect
|
||||
value={String(autoSettings.day_of_month)}
|
||||
onChange={v => handleAutoSettingsChange('day_of_month', Number.parseInt(String(v), 10))}
|
||||
size="sm"
|
||||
options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Keep duration */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{KEEP_OPTIONS.map(opt => (
|
||||
<button type="button"
|
||||
key={opt.value}
|
||||
onClick={() => handleAutoSettingsChange('keep_days', opt.value)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
autoSettings.keep_days === opt.value
|
||||
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
|
||||
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t(opt.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<SettingRow
|
||||
stacked
|
||||
label={t('backup.auto.keepLabel')}
|
||||
control={
|
||||
<ChoiceChips<string>
|
||||
label={t('backup.auto.keepLabel')}
|
||||
value={String(autoSettings.keep_days)}
|
||||
onChange={v => handleAutoSettingsChange('keep_days', Number(v))}
|
||||
options={KEEP_OPTIONS.map(opt => ({ value: String(opt.value), label: t(opt.labelKey) }))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SettingRows>
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex justify-end pt-2 border-t border-gray-100">
|
||||
<button type="button"
|
||||
onClick={handleSaveAutoSettings}
|
||||
disabled={autoSettingsSaving || !autoSettingsDirty}
|
||||
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-5 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{autoSettingsSaving
|
||||
? <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
: <Check className="w-4 h-4" />
|
||||
}
|
||||
{autoSettingsSaving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Restore Warning Modal */}
|
||||
{restoreConfirm && (
|
||||
<div
|
||||
// Dismiss-on-backdrop is a mouse shortcut for the Cancel button below;
|
||||
// the backdrop itself carries no semantics of its own.
|
||||
role="presentation"
|
||||
className="bg-[rgba(0,0,0,0.5)]"
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 9999, backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onClick={() => setRestoreConfirm(null)}
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }}
|
||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
|
||||
{/* Save button */}
|
||||
<div className="flex justify-end">
|
||||
<button type="button"
|
||||
onClick={handleSaveAutoSettings}
|
||||
disabled={autoSettingsSaving || !autoSettingsDirty}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{/* Red header */}
|
||||
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div className="bg-[rgba(255,255,255,0.2)]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<AlertTriangle size={20} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white" style={{ margin: 0, fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700 }}>
|
||||
{t('backup.restoreConfirmTitle')}
|
||||
</h3>
|
||||
<p className="text-[rgba(255,255,255,0.8)]" style={{ margin: '2px 0 0', fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
|
||||
{restoreConfirm.filename}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ padding: '20px 24px' }}>
|
||||
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.6, margin: 0 }}>
|
||||
{t('backup.restoreWarning')}
|
||||
</p>
|
||||
|
||||
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: 1.5 }}
|
||||
className="bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800"
|
||||
>
|
||||
{t('backup.restoreTip')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ padding: '0 24px 20px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||
<button type="button"
|
||||
onClick={() => setRestoreConfirm(null)}
|
||||
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={executeRestore}
|
||||
className="bg-[#dc2626] text-white"
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
|
||||
>
|
||||
{t('backup.restoreConfirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{autoSettingsSaving
|
||||
? <Loader2 size={14} strokeWidth={2.2} className="animate-spin" />
|
||||
: <Check size={14} strokeWidth={2.2} />}
|
||||
{autoSettingsSaving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
{/* Restore warning */}
|
||||
<ConfirmDialog
|
||||
isOpen={restoreConfirm !== null}
|
||||
onClose={() => setRestoreConfirm(null)}
|
||||
onConfirm={executeRestore}
|
||||
title={t('backup.restoreConfirmTitle')}
|
||||
message={t('backup.restoreWarning')}
|
||||
confirmLabel={t('backup.restoreConfirm')}
|
||||
danger
|
||||
>
|
||||
{restoreConfirm && (
|
||||
<>
|
||||
<div className="flex min-w-0 items-center gap-2.5 rounded-[12px] border border-edge-faint bg-surface-secondary px-3 py-2.5">
|
||||
<HardDrive size={15} strokeWidth={2} className="flex-none text-content-muted" />
|
||||
<span className="min-w-0 truncate font-geist font-semibold text-content" style={fs(12.5, 'body')}>{restoreConfirm.filename}</span>
|
||||
</div>
|
||||
<p className="m-0 rounded-[12px] bg-warning-soft px-3 py-2.5 leading-normal text-warning" style={fs(12, 'body')}>
|
||||
{t('backup.restoreTip')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</ConfirmDialog>
|
||||
|
||||
{/* Delete question */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteTarget !== null}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={executeDelete}
|
||||
title={t('common.delete')}
|
||||
message={deleteTarget ? t('backup.confirm.delete', { name: deleteTarget }) : ''}
|
||||
confirmLabel={t('common.delete')}
|
||||
danger
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useState, useEffect, useRef, useId } from 'react'
|
||||
import { categoriesApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Plus, Edit2, Trash2, Pipette } from 'lucide-react'
|
||||
import { Plus, Pencil, Trash2, Pipette, Tags, Loader2 } from 'lucide-react'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { SettingRows, SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON } from '../Settings/settingsKit'
|
||||
import { CATEGORY_ICON_MAP, ICON_LABELS, getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
@@ -12,18 +15,22 @@ const PRESET_COLORS = [
|
||||
'#6b7280', '#1f2937',
|
||||
]
|
||||
|
||||
// The colour a new category is stored with: data the server keeps, not a theme colour.
|
||||
const DEFAULT_COLOR = '#6366f1' // theme-lint-disable: a stored category colour, not styling
|
||||
|
||||
const ICON_NAMES = Object.keys(CATEGORY_ICON_MAP)
|
||||
|
||||
export default function CategoryManager() {
|
||||
const [categories, setCategories] = useState([])
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingId, setEditingId] = useState(null)
|
||||
const [form, setForm] = useState({ name: '', color: '#6366f1', icon: 'MapPin' })
|
||||
const [form, setForm] = useState({ name: '', color: DEFAULT_COLOR, icon: 'MapPin' })
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const colorInputRef = useRef(null)
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
const labelId = useId()
|
||||
|
||||
useEffect(() => { void loadCategories() }, [])
|
||||
|
||||
@@ -41,13 +48,13 @@ export default function CategoryManager() {
|
||||
|
||||
const handleStartEdit = (cat) => {
|
||||
setEditingId(cat.id)
|
||||
setForm({ name: cat.name, color: cat.color || '#6366f1', icon: cat.icon || 'MapPin' })
|
||||
setForm({ name: cat.name, color: cat.color || DEFAULT_COLOR, icon: cat.icon || 'MapPin' })
|
||||
setShowForm(false)
|
||||
}
|
||||
|
||||
const handleStartCreate = () => {
|
||||
setEditingId(null)
|
||||
setForm({ name: '', color: '#6366f1', icon: 'MapPin' })
|
||||
setForm({ name: '', color: DEFAULT_COLOR, icon: 'MapPin' })
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
@@ -71,7 +78,7 @@ export default function CategoryManager() {
|
||||
setShowForm(false)
|
||||
toast.success(t('categories.toast.created'))
|
||||
}
|
||||
setForm({ name: '', color: '#6366f1', icon: 'MapPin' })
|
||||
setForm({ name: '', color: DEFAULT_COLOR, icon: 'MapPin' })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('categories.toast.saveError')))
|
||||
} finally {
|
||||
@@ -92,22 +99,44 @@ export default function CategoryManager() {
|
||||
|
||||
const isPresetColor = PRESET_COLORS.includes(form.color)
|
||||
const PreviewIcon = getCategoryIcon(form.icon)
|
||||
const formOpen = showForm || editingId !== null
|
||||
const saveDisabled = isSaving || !form.name.trim()
|
||||
|
||||
const categoryForm = (
|
||||
<div className="bg-gray-50 rounded-xl p-4 space-y-3 border border-gray-200">
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={e => setForm(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder={t('categories.namePlaceholder')}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 bg-white"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-2">{t('categories.icon')}</label>
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
<div className="flex flex-wrap gap-1.5 px-1.5 py-1.5">
|
||||
const categoryDialog = (
|
||||
<DialogShell
|
||||
onClose={handleCancel}
|
||||
labelledBy={labelId}
|
||||
width="detail"
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={<DialogTile><PreviewIcon size={20} strokeWidth={1.9} color={form.color} /></DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={handleCancel}
|
||||
eyebrow={editingId ? t('common.edit') : t('categories.new')}
|
||||
titleInput={{
|
||||
value: form.name,
|
||||
onChange: name => setForm(prev => ({ ...prev, name })),
|
||||
label: t('categories.namePlaceholder'),
|
||||
placeholder: t('categories.namePlaceholder'),
|
||||
autoFocus: true,
|
||||
onKeyDown: e => { if (e.key === 'Enter' && !saveDisabled) void handleSave() },
|
||||
}}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={handleCancel}>{t('common.cancel')}</DialogButton>
|
||||
<DialogButton variant="primary" onClick={handleSave} disabled={saveDisabled}>
|
||||
{isSaving ? t('common.saving') : editingId ? t('categories.update') : t('categories.create')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
>
|
||||
<DialogSection label={t('categories.icon')}>
|
||||
<div className="max-h-52 overflow-y-auto rounded-[14px] border border-edge-faint bg-surface-secondary p-2">
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(38px,1fr))] gap-1">
|
||||
{ICON_NAMES.map(name => {
|
||||
const Icon = CATEGORY_ICON_MAP[name]
|
||||
const isSelected = form.icon === name
|
||||
@@ -116,28 +145,29 @@ export default function CategoryManager() {
|
||||
key={name}
|
||||
type="button"
|
||||
title={ICON_LABELS[name] || name}
|
||||
aria-label={ICON_LABELS[name] || name}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => setForm(prev => ({ ...prev, icon: name }))}
|
||||
className={`w-9 h-9 flex items-center justify-center rounded-lg transition-all ${
|
||||
isSelected
|
||||
? 'ring-2 ring-offset-1 ring-slate-700'
|
||||
: 'hover:bg-gray-200'
|
||||
className={`grid h-[38px] place-items-center rounded-[10px] transition-colors ${
|
||||
isSelected ? 'bg-surface-card shadow-sm ring-2 ring-content' : 'text-content-secondary hover:bg-surface-card'
|
||||
}`}
|
||||
style={{ background: isSelected ? `${form.color}18` : undefined }}
|
||||
style={isSelected ? { background: `${form.color}18` } : undefined}
|
||||
>
|
||||
<Icon size={17} strokeWidth={1.8} color={isSelected ? form.color : '#374151'} />
|
||||
<Icon size={17} strokeWidth={1.8} color={isSelected ? form.color : 'currentColor'} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogSection>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1.5">{t('categories.color')}</label>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<DialogSection label={t('categories.color')}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{PRESET_COLORS.map(color => (
|
||||
<button key={color} type="button" onClick={() => setForm(prev => ({ ...prev, color }))}
|
||||
className={`w-7 h-7 rounded-full transition-transform hover:scale-110 ${form.color === color ? 'ring-2 ring-offset-2 ring-gray-400 scale-110' : ''}`}
|
||||
aria-label={color}
|
||||
aria-pressed={form.color === color}
|
||||
className={`h-7 w-7 rounded-full transition-transform hover:scale-110 ${form.color === color ? 'scale-110 ring-2 ring-content ring-offset-2 ring-offset-surface-card' : ''}`}
|
||||
style={{ backgroundColor: color }} />
|
||||
))}
|
||||
|
||||
@@ -148,109 +178,94 @@ export default function CategoryManager() {
|
||||
value={form.color}
|
||||
onChange={e => setForm(prev => ({ ...prev, color: e.target.value }))}
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
title={t('categories.customColor')}
|
||||
aria-label={t('categories.customColor')}
|
||||
onClick={() => colorInputRef.current?.click()}
|
||||
className={`w-7 h-7 rounded-full flex items-center justify-center border-2 transition-transform hover:scale-110 ${
|
||||
className={`grid h-7 w-7 place-items-center rounded-full border-2 transition-transform hover:scale-110 ${
|
||||
!isPresetColor
|
||||
? 'ring-2 ring-offset-2 ring-gray-400 scale-110 border-transparent'
|
||||
: 'border-dashed border-gray-300 hover:border-gray-400'
|
||||
? 'scale-110 border-transparent ring-2 ring-content ring-offset-2 ring-offset-surface-card'
|
||||
: 'border-dashed border-edge text-content-faint hover:border-content-faint hover:text-content-muted'
|
||||
}`}
|
||||
style={!isPresetColor ? { backgroundColor: form.color } : undefined}
|
||||
>
|
||||
{isPresetColor && <Pipette className="w-3 h-3 text-gray-400" />}
|
||||
{isPresetColor && <Pipette size={12} strokeWidth={2} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogSection>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">{t('categories.preview')}:</span>
|
||||
<span className="inline-flex items-center gap-1.5 text-sm px-2.5 py-1 rounded-full font-medium"
|
||||
style={{ backgroundColor: `${form.color}20`, color: form.color }}>
|
||||
<PreviewIcon size={14} strokeWidth={1.8} />
|
||||
{form.name || t('categories.defaultName')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={handleCancel}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 border border-gray-200 rounded-lg hover:bg-gray-50">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" onClick={handleSave} disabled={isSaving || !form.name.trim()}
|
||||
className="px-4 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium">
|
||||
{isSaving ? t('common.saving') : editingId ? t('categories.update') : t('categories.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogSection label={t('categories.preview')}>
|
||||
<div className="flex items-center rounded-[14px] border border-edge-faint bg-surface-secondary px-3.5 py-3">
|
||||
<span className="inline-flex min-w-0 max-w-full items-center gap-1.5 truncate rounded-full px-2.5 py-1 font-semibold"
|
||||
style={{ ...fs(12.5, 'body'), backgroundColor: `${form.color}20`, color: form.color }}>
|
||||
<PreviewIcon size={14} strokeWidth={1.8} className="flex-none" />
|
||||
{form.name || t('categories.defaultName')}
|
||||
</span>
|
||||
</div>
|
||||
</DialogSection>
|
||||
</DialogShell>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">{t('categories.title')}</h2>
|
||||
<p className="mt-1 text-caption text-content-faint">{t('categories.subtitle')}</p>
|
||||
</div>
|
||||
<button type="button" onClick={handleStartCreate}
|
||||
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
|
||||
<Plus className="w-4 h-4" />
|
||||
<SettingsCard
|
||||
icon={Tags}
|
||||
title={t('categories.title')}
|
||||
hint={t('categories.subtitle')}
|
||||
badge={!isLoading && categories.length > 0 ? <StatusPill>{categories.length}</StatusPill> : undefined}
|
||||
action={
|
||||
<button type="button" onClick={handleStartCreate} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
||||
<Plus size={14} strokeWidth={2.2} />
|
||||
<span className="hidden sm:inline">{t('categories.new')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && <div className="mb-4">{categoryForm}</div>}
|
||||
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8 text-gray-400">
|
||||
<div className="w-6 h-6 border-2 border-gray-300 border-t-slate-600 rounded-full animate-spin" />
|
||||
<div className="grid place-items-center py-8">
|
||||
<Loader2 size={20} className="animate-spin text-content-faint" />
|
||||
</div>
|
||||
) : categories.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
<p className="text-sm">{t('categories.empty')}</p>
|
||||
<div className="rounded-[12px] border border-dashed border-edge px-4 py-6 text-center">
|
||||
<SettingsHint>{t('categories.empty')}</SettingsHint>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<SettingRows>
|
||||
{categories.map(cat => {
|
||||
const Icon = getCategoryIcon(cat.icon)
|
||||
return (
|
||||
<div key={cat.id}>
|
||||
{editingId === cat.id ? (
|
||||
<div className="mb-2">{categoryForm}</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 p-3 border border-gray-100 rounded-xl hover:border-gray-200 group">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: `${cat.color}20` }}>
|
||||
<Icon size={18} strokeWidth={1.8} color={cat.color} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900 text-sm">{cat.name}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full"
|
||||
style={{ backgroundColor: `${cat.color}20`, color: cat.color }}>
|
||||
{cat.color}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button type="button" onClick={() => handleStartEdit(cat)}
|
||||
className="p-1.5 text-gray-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg">
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" onClick={() => handleDelete(cat.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div key={cat.id} className={`group flex items-center gap-3 p-3 ${editingId === cat.id ? 'bg-surface-secondary' : ''}`}>
|
||||
<span className="grid h-9 w-9 flex-none place-items-center rounded-[10px]"
|
||||
style={{ backgroundColor: `${cat.color}20` }}>
|
||||
<Icon size={17} strokeWidth={1.8} color={cat.color} />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className="min-w-0 truncate font-medium text-content" style={fs(13, 'body')}>{cat.name}</span>
|
||||
<span className="flex-none rounded-full px-2 py-[2px] font-geist font-semibold uppercase tabular-nums"
|
||||
style={{ ...fs(10.5), backgroundColor: `${cat.color}20`, color: cat.color }}>
|
||||
{cat.color}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-none items-center gap-1.5">
|
||||
<Tooltip label={t('common.edit')}>
|
||||
<button type="button" onClick={() => handleStartEdit(cat)} aria-label={t('common.edit')} className={SETTINGS_ICON_BUTTON}>
|
||||
<Pencil size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('common.delete')}>
|
||||
<button type="button" onClick={() => handleDelete(cat.id)} aria-label={t('common.delete')} className={`${SETTINGS_ICON_BUTTON} hover:!text-danger`}>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</SettingRows>
|
||||
)}
|
||||
</div>
|
||||
{formOpen && categoryDialog}
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,9 +43,9 @@ function withToast() {
|
||||
return render(<><ToastContainer /><DefaultUserSettingsTab /></>);
|
||||
}
|
||||
|
||||
/** The selected option button is the one drawn with the strong border token. */
|
||||
/** The selected option is the pressed button of its segmented group. */
|
||||
function isActive(button: HTMLElement): boolean {
|
||||
return (button.style.border || '').includes('var(--text-primary)');
|
||||
return button.getAttribute('aria-pressed') === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Map as MapIcon, Settings2 } from 'lucide-react'
|
||||
import { Loader2, Map as MapIcon, Settings2 } from 'lucide-react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import Section from '../Settings/Section'
|
||||
import { SettingRows, SettingsHint } from '../Settings/settingsKit'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { EditorField, INPUT, Segmented } from '../shared/dialogParts'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { MapView } from '../Map/MapView'
|
||||
import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants'
|
||||
@@ -66,51 +69,49 @@ function styleForProvider(provider: GlMapProvider, style?: string | null): strin
|
||||
return normalizeStyleForProvider(provider, style)
|
||||
}
|
||||
|
||||
function OptionRow({
|
||||
/**
|
||||
* One default as a row of the card's white box: its name (with the reset link
|
||||
* once it is set) on the left, the choice on the right. `stacked` puts a wide
|
||||
* choice and a hint under the name. The label sits straight in the row, so the
|
||||
* row is the nearest box around both the name and its choice.
|
||||
*/
|
||||
function ChoiceRow({
|
||||
label,
|
||||
hint,
|
||||
stacked = false,
|
||||
children,
|
||||
}: {
|
||||
label: React.ReactNode
|
||||
hint?: string
|
||||
stacked?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-content-secondary">
|
||||
<div className={stacked ? 'flex flex-col gap-2.5 px-3.5 py-3' : 'flex flex-wrap items-center gap-x-4 gap-y-2 px-3.5 py-3'}>
|
||||
<label className={`block font-medium text-content ${stacked ? '' : 'min-w-0 flex-1 basis-40'}`} style={fs(13, 'body')}>
|
||||
{label}
|
||||
</label>
|
||||
{hint && <p className="text-xs mb-2 text-content-faint">{hint}</p>}
|
||||
<div className="flex gap-3 flex-wrap">{children}</div>
|
||||
{hint && <p className="-mt-2 m-0 leading-snug text-content-faint" style={fs(11.5)}>{hint}</p>}
|
||||
<div className={stacked ? 'min-w-0' : 'flex min-w-0 flex-none items-center'}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OptionButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button type="button"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
border: active ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: active ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
/** A wide field box inside the white rows, for the map card's token and style. */
|
||||
const FIELD_ROW = 'px-3.5 py-3'
|
||||
|
||||
/** On/Off for a boolean default; neither is pressed while it is unset. */
|
||||
function onOffValue(value: boolean | undefined): string {
|
||||
if (value === true) return 'on'
|
||||
if (value === false) return 'off'
|
||||
return ''
|
||||
}
|
||||
|
||||
/** The stored colour mode as one of the three choices; a legacy boolean still picks light or dark. */
|
||||
function colorModeValue(value: string | boolean | undefined): string {
|
||||
if (value === true) return 'dark'
|
||||
if (value === false) return 'light'
|
||||
return value ?? ''
|
||||
}
|
||||
|
||||
export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
@@ -165,12 +166,14 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
|
||||
const isSet = (key: keyof Defaults) => defaults[key] !== undefined
|
||||
|
||||
// Inside the row's own <label>, after the name: the link only shows once the
|
||||
// default is set, and the name stays the label's text.
|
||||
const ResetButton = ({ field }: { field: keyof Defaults }) =>
|
||||
isSet(field) ? (
|
||||
<button type="button"
|
||||
onClick={() => reset(field)}
|
||||
className="text-xs ml-2 text-content-faint underline"
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer' }}
|
||||
className="ml-2 align-baseline font-geist font-medium normal-case tracking-normal text-content-muted underline decoration-edge underline-offset-2 hover:text-content"
|
||||
style={fs(11)}
|
||||
>
|
||||
{t('admin.defaultSettings.resetToBuiltIn')}
|
||||
</button>
|
||||
@@ -202,7 +205,12 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
}], [])
|
||||
|
||||
if (!loaded) {
|
||||
return <p className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontStyle: 'italic', padding: 16 }}>Loading…</p>
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 rounded-2xl border border-edge-faint bg-surface-secondary px-4 py-5 text-content-faint" style={fs(12.5, 'body')}>
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
Loading…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const darkMode = defaults.dark_mode
|
||||
@@ -223,9 +231,9 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm text-content-faint mb-4">
|
||||
<SettingsHint className="mb-4">
|
||||
{t('admin.defaultSettings.description')}
|
||||
</p>
|
||||
</SettingsHint>
|
||||
|
||||
{/* Two columns from xl up, the same idea as the Settings tab: as one flat list
|
||||
the map fields sat a screen below the units while the right half of the page
|
||||
@@ -237,148 +245,125 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
carries its own bottom margin and a row gap would double it once the grid
|
||||
collapses. Two cards can sit straight in the grid; a third would need the
|
||||
explicit per-column stacks the Settings tab uses, or it leaves a hole. */}
|
||||
<div className="grid grid-cols-1 gap-x-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)] xl:items-start">
|
||||
<div className="grid grid-cols-1 gap-x-5 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)] xl:items-start">
|
||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||
<SettingRows>
|
||||
{/* Color Mode */}
|
||||
<OptionRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
|
||||
{([
|
||||
{ value: 'light', label: t('settings.light') },
|
||||
{ value: 'dark', label: t('settings.dark') },
|
||||
{ value: 'auto', label: t('settings.auto') },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={darkMode === opt.value || (opt.value === 'light' && darkMode === false) || (opt.value === 'dark' && darkMode === true)}
|
||||
onClick={() => save({ dark_mode: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
<ChoiceRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
|
||||
<Segmented<string>
|
||||
label={t('settings.colorMode')}
|
||||
value={colorModeValue(darkMode)}
|
||||
onChange={value => save({ dark_mode: value })}
|
||||
options={[
|
||||
{ value: 'light', label: t('settings.light') },
|
||||
{ value: 'dark', label: t('settings.dark') },
|
||||
{ value: 'auto', label: t('settings.auto') },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Temperature */}
|
||||
<OptionRow label={<>{t('settings.temperature')} <ResetButton field="temperature_unit" /></>}>
|
||||
{([
|
||||
{ value: 'celsius', label: '°C Celsius' },
|
||||
{ value: 'fahrenheit', label: '°F Fahrenheit' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={defaults.temperature_unit === opt.value}
|
||||
onClick={() => save({ temperature_unit: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
<ChoiceRow label={<>{t('settings.temperature')} <ResetButton field="temperature_unit" /></>}>
|
||||
<Segmented<string>
|
||||
label={t('settings.temperature')}
|
||||
value={defaults.temperature_unit ?? ''}
|
||||
onChange={value => save({ temperature_unit: value })}
|
||||
options={[
|
||||
{ value: 'celsius', label: '°C Celsius' },
|
||||
{ value: 'fahrenheit', label: '°F Fahrenheit' },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Distance */}
|
||||
<OptionRow label={<>{t('settings.distance')} <ResetButton field="distance_unit" /></>}>
|
||||
{([
|
||||
{ value: 'metric', label: 'km Metric' },
|
||||
{ value: 'imperial', label: 'mi Imperial' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={defaults.distance_unit === opt.value}
|
||||
onClick={() => save({ distance_unit: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
<ChoiceRow label={<>{t('settings.distance')} <ResetButton field="distance_unit" /></>}>
|
||||
<Segmented<DistanceUnit | ''>
|
||||
label={t('settings.distance')}
|
||||
value={defaults.distance_unit ?? ''}
|
||||
onChange={value => { if (value) void save({ distance_unit: value }) }}
|
||||
options={[
|
||||
{ value: 'metric', label: 'km Metric' },
|
||||
{ value: 'imperial', label: 'mi Imperial' },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Time Format */}
|
||||
<OptionRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
|
||||
{([
|
||||
{ value: '24h', label: '24h (14:30)' },
|
||||
{ value: '12h', label: '12h (2:30 PM)' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={defaults.time_format === opt.value}
|
||||
onClick={() => save({ time_format: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
<ChoiceRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
|
||||
<Segmented<string>
|
||||
label={t('settings.timeFormat')}
|
||||
value={defaults.time_format ?? ''}
|
||||
onChange={value => save({ time_format: value })}
|
||||
options={[
|
||||
{ value: '24h', label: '24h (14:30)' },
|
||||
{ value: '12h', label: '12h (2:30 PM)' },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Week start: the first column of every date picker (#2029) */}
|
||||
<OptionRow label={<>{t('settings.weekStart')} <ResetButton field="week_start" /></>}>
|
||||
{weekStartOptions(locale).map(opt => (
|
||||
<OptionButton key={opt.value} active={defaults.week_start === opt.value} onClick={() => save({ week_start: opt.value })}>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
<ChoiceRow label={<>{t('settings.weekStart')} <ResetButton field="week_start" /></>}>
|
||||
<Segmented<WeekStart | ''>
|
||||
label={t('settings.weekStart')}
|
||||
value={defaults.week_start ?? ''}
|
||||
onChange={value => { if (value) void save({ week_start: value }) }}
|
||||
options={weekStartOptions(locale)}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Default Currency */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('settings.currency')} <ResetButton field="default_currency" />
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={defaults.default_currency || ''}
|
||||
onChange={(value: string) => { if (value) void save({ default_currency: value }) }}
|
||||
placeholder={t('settings.currency')}
|
||||
searchable
|
||||
options={currenciesWith(defaults.default_currency).map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
|
||||
size="sm"
|
||||
style={{ maxWidth: 240 }}
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.currencyHint')}</p>
|
||||
</div>
|
||||
<ChoiceRow label={<>{t('settings.currency')} <ResetButton field="default_currency" /></>}>
|
||||
<div className="w-[220px] max-w-full">
|
||||
<CustomSelect
|
||||
value={defaults.default_currency || ''}
|
||||
onChange={(value: string) => { if (value) void save({ default_currency: value }) }}
|
||||
placeholder={t('settings.currency')}
|
||||
searchable
|
||||
options={currenciesWith(defaults.default_currency).map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
|
||||
/>
|
||||
</div>
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Blur Booking Codes */}
|
||||
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
||||
{([
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={String(opt.value)}
|
||||
active={defaults.blur_booking_codes === opt.value}
|
||||
onClick={() => save({ blur_booking_codes: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
<ChoiceRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
||||
<Segmented<string>
|
||||
label={t('settings.blurBookingCodes')}
|
||||
value={onOffValue(defaults.blur_booking_codes)}
|
||||
onChange={value => save({ blur_booking_codes: value === 'on' })}
|
||||
options={[
|
||||
{ value: 'on', label: t('settings.on') || 'On' },
|
||||
{ value: 'off', label: t('settings.off') || 'Off' },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
</SettingRows>
|
||||
<SettingsHint className="-mt-2">{t('settings.currencyHint')}</SettingsHint>
|
||||
</Section>
|
||||
|
||||
<Section title={t('settings.map')} icon={MapIcon}>
|
||||
{/* Map Tile URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('settings.mapTemplate')}
|
||||
<ResetButton field="map_tile_url" />
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={mapTileUrl}
|
||||
onChange={(value: string) => { if (value) { setMapTileUrl(value); void save({ map_tile_url: value }) } }}
|
||||
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||
size="sm"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mapTileUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
|
||||
onBlur={() => save({ map_tile_url: mapTileUrl })}
|
||||
placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.mapDefaultHint')}</p>
|
||||
{/* The key comes with the instance on a managed install, injected when the
|
||||
settings are read. A field here would only let somebody save a worse one. */}
|
||||
{!managed && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('admin.defaultSettings.cartoKey')}
|
||||
<ResetButton field="carto_api_key" />
|
||||
</label>
|
||||
<EditorField label={<>{t('settings.mapTemplate')}<ResetButton field="map_tile_url" /></>} hint={t('settings.mapDefaultHint')}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<CustomSelect
|
||||
value={mapTileUrl}
|
||||
onChange={(value: string) => { if (value) { setMapTileUrl(value); void save({ map_tile_url: value }) } }}
|
||||
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mapTileUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
|
||||
onBlur={() => save({ map_tile_url: mapTileUrl })}
|
||||
placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
className={`${INPUT} font-geist`}
|
||||
/>
|
||||
</div>
|
||||
</EditorField>
|
||||
{/* The key comes with the instance on a managed install, injected when the
|
||||
settings are read. A field here would only let somebody save a worse one. */}
|
||||
{!managed && (
|
||||
<EditorField label={<>{t('admin.defaultSettings.cartoKey')}<ResetButton field="carto_api_key" /></>} hint={t('admin.defaultSettings.cartoKeyHint')}>
|
||||
<input
|
||||
type="text"
|
||||
value={cartoKey}
|
||||
@@ -386,72 +371,60 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
onBlur={() => save({ carto_api_key: cartoKey })}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
className={`${INPUT} font-geist`}
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.cartoKeyHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{!managed && (
|
||||
<div className="mt-3.5">
|
||||
<RoutingInstanceFields defaults={defaults} onSave={save} onReset={reset} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
places: mapPreviewPlaces,
|
||||
dayPlaces: [],
|
||||
route: null,
|
||||
routeSegments: null,
|
||||
selectedPlaceId: null,
|
||||
onMarkerClick: null,
|
||||
onMapClick: null,
|
||||
onMapContextMenu: null,
|
||||
center: [48.8566, 2.3522],
|
||||
zoom: 10,
|
||||
// Same as the user-facing map tab: the field holds what is being
|
||||
// edited, so the key goes back on before the preview resolves it.
|
||||
tileUrl: withTileApiKey(mapTileUrl, cartoKey),
|
||||
fitKey: null,
|
||||
dayOrderMap: [],
|
||||
leftWidth: 0,
|
||||
rightWidth: 0,
|
||||
hasInspector: false,
|
||||
})}
|
||||
</div>
|
||||
</EditorField>
|
||||
)}
|
||||
{!managed && <RoutingInstanceFields defaults={defaults} onSave={save} onReset={reset} />}
|
||||
<div className="relative h-[200px] w-full overflow-hidden rounded-[12px] border border-edge-faint">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
places: mapPreviewPlaces,
|
||||
dayPlaces: [],
|
||||
route: null,
|
||||
routeSegments: null,
|
||||
selectedPlaceId: null,
|
||||
onMarkerClick: null,
|
||||
onMapClick: null,
|
||||
onMapContextMenu: null,
|
||||
center: [48.8566, 2.3522],
|
||||
zoom: 10,
|
||||
// Same as the user-facing map tab: the field holds what is being
|
||||
// edited, so the key goes back on before the preview resolves it.
|
||||
tileUrl: withTileApiKey(mapTileUrl, cartoKey),
|
||||
fitKey: null,
|
||||
dayOrderMap: [],
|
||||
leftWidth: 0,
|
||||
rightWidth: 0,
|
||||
hasInspector: false,
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Map provider / instance-wide Mapbox ───────────────────────── */}
|
||||
<div style={{ borderTop: '1px solid var(--border-primary)', paddingTop: 20, marginTop: 4 }}>
|
||||
<OptionRow
|
||||
<SettingRows>
|
||||
<ChoiceRow
|
||||
stacked
|
||||
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" /></>}
|
||||
hint={t('admin.defaultSettings.mapProviderHint')}
|
||||
>
|
||||
{([
|
||||
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
|
||||
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
|
||||
{ value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={mapProvider === opt.value}
|
||||
onClick={() => saveMapProvider(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
<Segmented<MapProvider>
|
||||
fill
|
||||
label={t('admin.defaultSettings.mapProvider')}
|
||||
value={mapProvider}
|
||||
onChange={saveMapProvider}
|
||||
options={[
|
||||
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
|
||||
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
|
||||
{ value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
|
||||
{mapProvider !== 'leaflet' && (
|
||||
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
{/* The token comes with the instance on a managed install, injected when the
|
||||
settings are read. A field here would only let somebody save a worse one. */}
|
||||
{mapProvider === 'mapbox-gl' && !managed && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('admin.defaultSettings.mapboxToken')}
|
||||
<ResetButton field="mapbox_access_token" />
|
||||
</label>
|
||||
{/* The token comes with the instance on a managed install, injected when the
|
||||
settings are read. A field here would only let somebody save a worse one. */}
|
||||
{mapProvider === 'mapbox-gl' && !managed && (
|
||||
<div className={FIELD_ROW}>
|
||||
<EditorField label={<>{t('admin.defaultSettings.mapboxToken')}<ResetButton field="mapbox_access_token" /></>} hint={t('admin.defaultSettings.mapboxTokenHint')}>
|
||||
<input
|
||||
type="text"
|
||||
value={mapboxToken}
|
||||
@@ -460,67 +433,67 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
placeholder="pk.eyJ…"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
className={`${INPUT} font-geist`}
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.mapboxTokenHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('admin.defaultSettings.mapboxStyle')}
|
||||
<ResetButton field={styleKey} />
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={mapboxStyle}
|
||||
onChange={(value: string) => { if (value) { setMapboxStyle(value); void save({ [styleKey]: value }) } }}
|
||||
placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')}
|
||||
options={glStylePresets.map(p => ({ value: p.url, label: p.name }))}
|
||||
size="sm"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mapboxStyle}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
|
||||
onBlur={() => {
|
||||
const nextStyle = normalizeStyleForProvider(mapProvider, mapboxStyle)
|
||||
setMapboxStyle(nextStyle)
|
||||
void save({ [styleKey]: nextStyle })
|
||||
}}
|
||||
placeholder={defaultStyleForProvider(mapProvider)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mapProvider === 'mapbox-gl' && (
|
||||
<>
|
||||
<OptionRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
|
||||
{([
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton key={String(opt.value)} active={(defaults.mapbox_3d_enabled ?? true) === opt.value} onClick={() => save({ mapbox_3d_enabled: opt.value })}>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
<OptionRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}>
|
||||
{([
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton key={String(opt.value)} active={(defaults.mapbox_quality_mode ?? false) === opt.value} onClick={() => save({ mapbox_quality_mode: opt.value })}>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
</>
|
||||
)}
|
||||
</EditorField>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mapProvider !== 'leaflet' && (
|
||||
<div className={FIELD_ROW}>
|
||||
<EditorField label={<>{t('admin.defaultSettings.mapboxStyle')}<ResetButton field={styleKey} /></>}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<CustomSelect
|
||||
value={mapboxStyle}
|
||||
onChange={(value: string) => { if (value) { setMapboxStyle(value); void save({ [styleKey]: value }) } }}
|
||||
placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')}
|
||||
options={glStylePresets.map(p => ({ value: p.url, label: p.name }))}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mapboxStyle}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
|
||||
onBlur={() => {
|
||||
const nextStyle = normalizeStyleForProvider(mapProvider, mapboxStyle)
|
||||
setMapboxStyle(nextStyle)
|
||||
void save({ [styleKey]: nextStyle })
|
||||
}}
|
||||
placeholder={defaultStyleForProvider(mapProvider)}
|
||||
className={`${INPUT} font-geist`}
|
||||
/>
|
||||
</div>
|
||||
</EditorField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mapProvider === 'mapbox-gl' && (
|
||||
<>
|
||||
<ChoiceRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
|
||||
<Segmented<string>
|
||||
label={t('admin.defaultSettings.mapbox3d')}
|
||||
value={onOffValue(defaults.mapbox_3d_enabled ?? true)}
|
||||
onChange={value => save({ mapbox_3d_enabled: value === 'on' })}
|
||||
options={[
|
||||
{ value: 'on', label: t('settings.on') || 'On' },
|
||||
{ value: 'off', label: t('settings.off') || 'Off' },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
|
||||
<ChoiceRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}>
|
||||
<Segmented<string>
|
||||
label={t('admin.defaultSettings.mapboxQuality')}
|
||||
value={onOffValue(defaults.mapbox_quality_mode ?? false)}
|
||||
onChange={value => save({ mapbox_quality_mode: value === 'on' })}
|
||||
options={[
|
||||
{ value: 'on', label: t('settings.on') || 'On' },
|
||||
{ value: 'off', label: t('settings.off') || 'Off' },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
</>
|
||||
)}
|
||||
</SettingRows>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -297,14 +297,13 @@ describe('DevNotificationsPanel', () => {
|
||||
expect(screen.getByText('Admin-Scoped Events')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-016: hovering a trigger paints and restores its background', async () => {
|
||||
it('FE-ADMIN-DEVNOTIF-016: a trigger takes its hover background from CSS, not inline styles', async () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
|
||||
const btn = screen.getByText('Simple → Me').closest('button')!;
|
||||
fireEvent.mouseEnter(btn);
|
||||
expect(btn.style.background).toBe('var(--bg-hover)');
|
||||
fireEvent.mouseLeave(btn);
|
||||
expect(btn.style.background).toBe('var(--bg-card)');
|
||||
expect(btn.style.background).toBe('');
|
||||
expect(btn.className).toContain('hover:bg-surface-secondary');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useId } from 'react'
|
||||
import { adminApi, tripsApi } from '../../api/client'
|
||||
import { getApiErrorMessage } from '../../utils/apiError'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { EditorField, INPUT } from '../shared/dialogParts'
|
||||
import { SettingsCard, StatusPill } from '../Settings/settingsKit'
|
||||
import {
|
||||
Bell, Zap, ArrowRight, CheckCircle, Navigation, User,
|
||||
Calendar, Clock, Image, MessageSquare, Tag, UserPlus,
|
||||
Download, MapPin,
|
||||
Bell, Zap, CheckCircle, Navigation, User, Calendar, Clock, Image, MessageSquare, Tag, UserPlus,
|
||||
Download, MapPin, ChevronDown, FlaskConical, Loader2, Map as MapIcon, ShieldCheck,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface Trip {
|
||||
@@ -20,8 +22,26 @@ interface AppUser {
|
||||
email: string
|
||||
}
|
||||
|
||||
/** A native select in the editors' box look, with the chevron the CustomSelect draws. */
|
||||
const SELECT = `${INPUT} appearance-none pr-9`
|
||||
|
||||
function SelectBox({ id, children, ...rest }: React.SelectHTMLAttributes<HTMLSelectElement>): React.ReactElement {
|
||||
return (
|
||||
<div className="relative">
|
||||
<select id={id} {...rest} className={SELECT}>{children}</select>
|
||||
<ChevronDown size={14} strokeWidth={2.2} className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-content-faint" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The buttons of one group, two to a row from a small window up. */
|
||||
function ButtonGrid({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
return <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">{children}</div>
|
||||
}
|
||||
|
||||
export default function DevNotificationsPanel(): React.ReactElement {
|
||||
const toast = useToast()
|
||||
const ids = useId()
|
||||
const user = useAuthStore(s => s.user)
|
||||
const [sending, setSending] = useState<string | null>(null)
|
||||
const [trips, setTrips] = useState<Trip[]>([])
|
||||
@@ -62,74 +82,38 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
const Btn = ({
|
||||
id, label, sub, icon: Icon, color, onClick,
|
||||
id, label, sub, icon: Icon, onClick,
|
||||
}: {
|
||||
id: string; label: string; sub: string; icon: React.ElementType; color: string; onClick: () => void
|
||||
id: string; label: string; sub: string; icon: React.ElementType; onClick: () => void
|
||||
}) => (
|
||||
<button type="button"
|
||||
onClick={onClick}
|
||||
disabled={sending !== null}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left w-full border-edge bg-surface-card"
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)' }}
|
||||
className="flex w-full min-w-0 items-center gap-3 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5 text-left transition-colors hover:bg-surface-secondary disabled:cursor-default"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: `${color}20`, color }}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-content">{label}</p>
|
||||
<p className="text-xs truncate text-content-faint">{sub}</p>
|
||||
</div>
|
||||
{sending === id && (
|
||||
<div className="w-4 h-4 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin flex-shrink-0" />
|
||||
)}
|
||||
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary">
|
||||
<Icon size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium text-content" style={fs(13, 'body')}>{label}</span>
|
||||
<span className="block truncate font-geist text-content-faint" style={fs(11)}>{sub}</span>
|
||||
</span>
|
||||
{sending === id && <Loader2 size={15} className="flex-none animate-spin text-content-faint" />}
|
||||
</button>
|
||||
)
|
||||
|
||||
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
|
||||
<h3 className="text-sm font-semibold mb-3 text-content-secondary">{children}</h3>
|
||||
)
|
||||
|
||||
const TripSelector = () => (
|
||||
<select
|
||||
value={selectedTripId ?? ''}
|
||||
onChange={e => setSelectedTripId(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 rounded-lg border text-sm mb-3 border-edge bg-surface-card text-content"
|
||||
>
|
||||
{trips.map(trip => <option key={trip.id} value={trip.id}>{trip.title}</option>)}
|
||||
</select>
|
||||
)
|
||||
|
||||
const UserSelector = () => (
|
||||
<select
|
||||
value={selectedUserId ?? ''}
|
||||
onChange={e => setSelectedUserId(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 rounded-lg border text-sm mb-3 border-edge bg-surface-card text-content"
|
||||
>
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.username} ({u.email})</option>)}
|
||||
</select>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="px-2 py-0.5 rounded text-xs font-mono font-bold bg-[#fbbf24] text-[#000]">
|
||||
DEV ONLY
|
||||
</div>
|
||||
<span className="text-sm font-medium text-content">
|
||||
Notification Testing
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{/* ── Type Testing ─────────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<SectionTitle>Type Testing</SectionTitle>
|
||||
<p className="text-xs mb-3 text-content-muted">
|
||||
Test how each in-app notification type renders, sent to yourself.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Btn id="simple-me" label="Simple → Me" sub="test_simple · user" icon={Bell} color="#6366f1"
|
||||
{/* The whole tab is a dev build's tool; the first card, always there, says so in its band. */}
|
||||
<SettingsCard
|
||||
icon={FlaskConical}
|
||||
title="Type Testing"
|
||||
hint="Test how each in-app notification type renders, sent to yourself."
|
||||
badge={<StatusPill tone="warning">DEV ONLY</StatusPill>}
|
||||
>
|
||||
<ButtonGrid>
|
||||
<Btn id="simple-me" label="Simple → Me" sub="test_simple · user" icon={Bell}
|
||||
onClick={() => fire('simple-me', {
|
||||
event: 'test_simple',
|
||||
scope: 'user',
|
||||
@@ -137,7 +121,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
params: {},
|
||||
})}
|
||||
/>
|
||||
<Btn id="boolean-me" label="Boolean → Me" sub="test_boolean · user" icon={CheckCircle} color="#10b981"
|
||||
<Btn id="boolean-me" label="Boolean → Me" sub="test_boolean · user" icon={CheckCircle}
|
||||
onClick={() => fire('boolean-me', {
|
||||
event: 'test_boolean',
|
||||
scope: 'user',
|
||||
@@ -150,7 +134,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<Btn id="navigate-me" label="Navigate → Me" sub="test_navigate · user" icon={Navigation} color="#f59e0b"
|
||||
<Btn id="navigate-me" label="Navigate → Me" sub="test_navigate · user" icon={Navigation}
|
||||
onClick={() => fire('navigate-me', {
|
||||
event: 'test_navigate',
|
||||
scope: 'user',
|
||||
@@ -158,7 +142,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
params: {},
|
||||
})}
|
||||
/>
|
||||
<Btn id="simple-admins" label="Simple → All Admins" sub="test_simple · admin" icon={Zap} color="#ef4444"
|
||||
<Btn id="simple-admins" label="Simple → All Admins" sub="test_simple · admin" icon={Zap}
|
||||
onClick={() => fire('simple-admins', {
|
||||
event: 'test_simple',
|
||||
scope: 'admin',
|
||||
@@ -166,19 +150,23 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
params: {},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ButtonGrid>
|
||||
</SettingsCard>
|
||||
|
||||
{/* ── Trip-Scoped Events ───────────────────────────────────────────── */}
|
||||
{trips.length > 0 && (
|
||||
<div>
|
||||
<SectionTitle>Trip-Scoped Events</SectionTitle>
|
||||
<p className="text-xs mb-3 text-content-muted">
|
||||
Fires each trip event to all members of the selected trip (excluding yourself).
|
||||
</p>
|
||||
<TripSelector />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Btn id="booking_change" label="booking_change" sub="navigate · trip" icon={Calendar} color="#6366f1"
|
||||
<SettingsCard icon={MapIcon} title="Trip-Scoped Events" hint="Fires each trip event to all members of the selected trip (excluding yourself).">
|
||||
<EditorField label="Trip" htmlFor={`${ids}-trip`}>
|
||||
<SelectBox
|
||||
id={`${ids}-trip`}
|
||||
value={selectedTripId ?? ''}
|
||||
onChange={e => setSelectedTripId(Number(e.target.value))}
|
||||
>
|
||||
{trips.map(trip => <option key={trip.id} value={trip.id}>{trip.title}</option>)}
|
||||
</SelectBox>
|
||||
</EditorField>
|
||||
<ButtonGrid>
|
||||
<Btn id="booking_change" label="booking_change" sub="navigate · trip" icon={Calendar}
|
||||
onClick={() => selectedTripId && fire('booking_change', {
|
||||
event: 'booking_change',
|
||||
scope: 'trip',
|
||||
@@ -186,7 +174,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
params: { actor: username, trip: tripTitle, booking: 'Test Hotel', type: 'hotel', tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
<Btn id="trip_reminder" label="trip_reminder" sub="navigate · trip" icon={Clock} color="#10b981"
|
||||
<Btn id="trip_reminder" label="trip_reminder" sub="navigate · trip" icon={Clock}
|
||||
onClick={() => selectedTripId && fire('trip_reminder', {
|
||||
event: 'trip_reminder',
|
||||
scope: 'trip',
|
||||
@@ -194,7 +182,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
params: { trip: tripTitle, tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
<Btn id="photos_shared" label="photos_shared" sub="navigate · trip" icon={Image} color="#f59e0b"
|
||||
<Btn id="photos_shared" label="photos_shared" sub="navigate · trip" icon={Image}
|
||||
onClick={() => selectedTripId && fire('photos_shared', {
|
||||
event: 'photos_shared',
|
||||
scope: 'trip',
|
||||
@@ -202,7 +190,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
params: { actor: username, trip: tripTitle, count: '5', tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
<Btn id="collab_message" label="collab_message" sub="navigate · trip" icon={MessageSquare} color="#8b5cf6"
|
||||
<Btn id="collab_message" label="collab_message" sub="navigate · trip" icon={MessageSquare}
|
||||
onClick={() => selectedTripId && fire('collab_message', {
|
||||
event: 'collab_message',
|
||||
scope: 'trip',
|
||||
@@ -210,7 +198,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
params: { actor: username, trip: tripTitle, preview: 'This is a test message preview.', tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
<Btn id="packing_tagged" label="packing_tagged" sub="navigate · trip" icon={Tag} color="#ec4899"
|
||||
<Btn id="packing_tagged" label="packing_tagged" sub="navigate · trip" icon={Tag}
|
||||
onClick={() => selectedTripId && fire('packing_tagged', {
|
||||
event: 'packing_tagged',
|
||||
scope: 'trip',
|
||||
@@ -218,25 +206,28 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
params: { actor: username, trip: tripTitle, category: 'Clothing', tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ButtonGrid>
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
{/* ── User-Scoped Events ───────────────────────────────────────────── */}
|
||||
{users.length > 0 && (
|
||||
<div>
|
||||
<SectionTitle>User-Scoped Events</SectionTitle>
|
||||
<p className="text-xs mb-3 text-content-muted">
|
||||
Fires each user event to the selected recipient.
|
||||
</p>
|
||||
<UserSelector />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<SettingsCard icon={User} title="User-Scoped Events" hint="Fires each user event to the selected recipient.">
|
||||
<EditorField label="Recipient" htmlFor={`${ids}-user`}>
|
||||
<SelectBox
|
||||
id={`${ids}-user`}
|
||||
value={selectedUserId ?? ''}
|
||||
onChange={e => setSelectedUserId(Number(e.target.value))}
|
||||
>
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.username} ({u.email})</option>)}
|
||||
</SelectBox>
|
||||
</EditorField>
|
||||
<ButtonGrid>
|
||||
<Btn
|
||||
id={`trip_invite-${selectedUserId}`}
|
||||
label="trip_invite"
|
||||
sub="navigate · user"
|
||||
icon={UserPlus}
|
||||
color="#06b6d4"
|
||||
onClick={() => selectedUserId && fire(`trip_invite-${selectedUserId}`, {
|
||||
event: 'trip_invite',
|
||||
scope: 'user',
|
||||
@@ -249,7 +240,6 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
label="vacay_invite"
|
||||
sub="navigate · user"
|
||||
icon={MapPin}
|
||||
color="#f97316"
|
||||
onClick={() => selectedUserId && fire(`vacay_invite-${selectedUserId}`, {
|
||||
event: 'vacay_invite',
|
||||
scope: 'user',
|
||||
@@ -257,18 +247,14 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
params: { actor: username, planId: '1' },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ButtonGrid>
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
{/* ── Admin-Scoped Events ──────────────────────────────────────────── */}
|
||||
<div>
|
||||
<SectionTitle>Admin-Scoped Events</SectionTitle>
|
||||
<p className="text-xs mb-3 text-content-muted">
|
||||
Fires to all admin users.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Btn id="version_available" label="version_available" sub="navigate · admin" icon={Download} color="#64748b"
|
||||
<SettingsCard icon={ShieldCheck} title="Admin-Scoped Events" hint="Fires to all admin users.">
|
||||
<ButtonGrid>
|
||||
<Btn id="version_available" label="version_available" sub="navigate · admin" icon={Download}
|
||||
onClick={() => fire('version_available', {
|
||||
event: 'version_available',
|
||||
scope: 'admin',
|
||||
@@ -276,8 +262,8 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
params: { version: '9.9.9-test' },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ButtonGrid>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ describe('GitHubPanel', () => {
|
||||
expect(listItem.closest('li')).toBeInTheDocument();
|
||||
|
||||
// Bold text rendered as <strong>
|
||||
const container = document.querySelector('.mt-2.p-3.rounded-lg')!;
|
||||
const container = listItem.closest('ul')!.parentElement!;
|
||||
expect(container.querySelector('strong')).toBeInTheDocument();
|
||||
expect(container.querySelector('strong')!.textContent).toBe('bold text');
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
BookOpen,
|
||||
Bug,
|
||||
Calendar,
|
||||
@@ -7,13 +8,16 @@ import {
|
||||
Coffee,
|
||||
ExternalLink,
|
||||
Heart,
|
||||
History,
|
||||
Lightbulb,
|
||||
Loader2,
|
||||
Tag,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, type ComponentType, type CSSProperties, type ReactNode } from 'react';
|
||||
import apiClient from '../../api/client';
|
||||
import { getLocaleForLanguage, useTranslation } from '../../i18n';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { SETTINGS_BUTTON, SettingsCard, StatusPill } from '../Settings/settingsKit';
|
||||
|
||||
const REPO = 'liketrek/TREK';
|
||||
const PER_PAGE = 10;
|
||||
@@ -30,6 +34,50 @@ interface GithubRelease {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** The Discord mark, sized and coloured like the lucide glyphs beside it. */
|
||||
function DiscordIcon({ size = 18 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface SupportLink {
|
||||
href: string;
|
||||
/** The brand's own colour: the icon and the hover edge take it, nothing else. */
|
||||
brand: string;
|
||||
icon: ComponentType<{ size?: number }>;
|
||||
title: ReactNode;
|
||||
hint: ReactNode;
|
||||
}
|
||||
|
||||
/** One link to the project's other places: a card like the bookings', its brand only on the icon. */
|
||||
function SupportCard({ link }: { link: SupportLink }) {
|
||||
const Icon = link.icon;
|
||||
return (
|
||||
<a
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex min-w-0 items-center gap-3 rounded-[14px] border border-edge-faint bg-surface-card p-3 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:border-[color:var(--brand)] hover:shadow-sm"
|
||||
style={{ '--brand': link.brand } as CSSProperties}
|
||||
>
|
||||
<span
|
||||
className="grid h-10 w-10 flex-none place-items-center rounded-[12px] shadow-sm"
|
||||
style={{ background: 'color-mix(in srgb, var(--brand) 13%, var(--bg-card))', color: 'var(--brand)' }}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold text-content" style={fs(13.5, 'body')}>{link.title}</span>
|
||||
<span className="block truncate text-content-faint" style={fs(11.5)}>{link.hint}</span>
|
||||
</span>
|
||||
<ExternalLink size={14} className="flex-none text-content-faint transition-colors group-hover:text-content-muted" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) {
|
||||
const { t, language } = useTranslation();
|
||||
const [releases, setReleases] = useState<GithubRelease[]>([]);
|
||||
@@ -64,32 +112,29 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
setLoadingMore(false);
|
||||
};
|
||||
|
||||
const toggleExpand = (id) => {
|
||||
const toggleExpand = (id: number) => {
|
||||
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const formatDate = (dateStr: string) => {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
};
|
||||
|
||||
// Simple markdown-to-html for release notes (handles headers, bold, lists, links)
|
||||
const renderBody = (body) => {
|
||||
const renderBody = (body: string) => {
|
||||
if (!body) return null;
|
||||
const lines = body.split('\n');
|
||||
const elements = [];
|
||||
let listItems = [];
|
||||
const elements: ReactNode[] = [];
|
||||
let listItems: string[] = [];
|
||||
|
||||
const flushList = () => {
|
||||
if (listItems.length > 0) {
|
||||
elements.push(
|
||||
<ul key={`ul-${elements.length}`} className="my-2 space-y-1">
|
||||
<ul key={`ul-${elements.length}`} className="my-1.5 space-y-1 pl-0">
|
||||
{listItems.map((item, i) => (
|
||||
<li key={i} className="flex gap-2 text-xs text-content-muted">
|
||||
<span
|
||||
className="mt-1.5 h-1 w-1 flex-shrink-0 rounded-full"
|
||||
style={{ background: 'var(--text-faint)' }}
|
||||
/>
|
||||
<li key={i} className="flex gap-2 leading-relaxed text-content-secondary" style={fs(12.5, 'body')}>
|
||||
<span className="mt-[0.6em] h-1 w-1 flex-shrink-0 rounded-full bg-content-faint" />
|
||||
<span dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
|
||||
</li>
|
||||
))}
|
||||
@@ -99,18 +144,18 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
}
|
||||
};
|
||||
|
||||
const escapeHtml = (str) =>
|
||||
const escapeHtml = (str: string) =>
|
||||
str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const inlineFormat = (text) => {
|
||||
const inlineFormat = (text: string) => {
|
||||
return escapeHtml(text)
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(
|
||||
/`(.+?)`/g,
|
||||
'<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>'
|
||||
'<code style="font-size:0.9em;padding:1px 5px;border-radius:6px;background:var(--bg-tertiary)">$1</code>'
|
||||
)
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
|
||||
const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#';
|
||||
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">${label}</a>`;
|
||||
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:var(--text-primary);text-decoration:underline;text-underline-offset:2px">${label}</a>`;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -124,14 +169,14 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
if (trimmed.startsWith('### ')) {
|
||||
flushList();
|
||||
elements.push(
|
||||
<h4 key={elements.length} className="mb-1 mt-3 text-xs font-semibold text-content">
|
||||
<h4 key={elements.length} className="mb-1 mt-3 font-geist font-bold uppercase tracking-[.08em] text-content-faint first:mt-0" style={fs(10)}>
|
||||
{trimmed.slice(4)}
|
||||
</h4>
|
||||
);
|
||||
} else if (trimmed.startsWith('## ')) {
|
||||
flushList();
|
||||
elements.push(
|
||||
<h3 key={elements.length} className="mb-1 mt-3 text-sm font-semibold text-content">
|
||||
<h3 key={elements.length} className="mb-1 mt-3 font-semibold text-content first:mt-0" style={fs(13, 'body')}>
|
||||
{trimmed.slice(3)}
|
||||
</h3>
|
||||
);
|
||||
@@ -142,7 +187,8 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
elements.push(
|
||||
<p
|
||||
key={elements.length}
|
||||
className="my-1 text-xs text-content-muted"
|
||||
className="my-1 leading-relaxed text-content-secondary"
|
||||
style={fs(12.5, 'body')}
|
||||
dangerouslySetInnerHTML={{ __html: inlineFormat(trimmed) }}
|
||||
/>
|
||||
);
|
||||
@@ -152,351 +198,185 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
return elements;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Support cards */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<a
|
||||
href="https://ko-fi.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#ff5e5b';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-[#ff5e5b15]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Coffee size={20} className="text-[#ff5e5b]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-content">Ko-fi</div>
|
||||
<div className="text-xs text-content-faint">{t('admin.github.support')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://buymeacoffee.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#ffdd00';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-[#ffdd0015]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Heart size={20} className="text-[#ffdd00]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-content">Buy Me a Coffee</div>
|
||||
<div className="text-xs text-content-faint">{t('admin.github.support')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.gg/NhZBDSd4qW"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#5865F2';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-[#5865F215]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-content">Discord</div>
|
||||
<div className="text-xs text-content-faint">Join the community</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
</div>
|
||||
const supportLinks: SupportLink[] = [
|
||||
{
|
||||
href: 'https://ko-fi.com/mauriceboe',
|
||||
brand: '#ff5e5b', // theme-lint-disable: Ko-fi brand colour
|
||||
icon: Coffee,
|
||||
title: 'Ko-fi',
|
||||
hint: t('admin.github.support'),
|
||||
},
|
||||
{
|
||||
href: 'https://buymeacoffee.com/mauriceboe',
|
||||
brand: '#e0b400', // theme-lint-disable: Buy Me a Coffee brand yellow, deepened so it reads on white
|
||||
icon: Heart,
|
||||
title: 'Buy Me a Coffee',
|
||||
hint: t('admin.github.support'),
|
||||
},
|
||||
{
|
||||
href: 'https://discord.gg/NhZBDSd4qW',
|
||||
brand: '#5865F2', // theme-lint-disable: Discord brand colour
|
||||
icon: DiscordIcon,
|
||||
title: 'Discord',
|
||||
hint: 'Join the community',
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/liketrek/TREK/issues/new?template=bug_report.yml',
|
||||
brand: 'var(--danger)',
|
||||
icon: Bug,
|
||||
title: t('settings.about.reportBug'),
|
||||
hint: t('settings.about.reportBugHint'),
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/liketrek/TREK/discussions/new?category=feature-requests',
|
||||
brand: 'var(--warning)',
|
||||
icon: Lightbulb,
|
||||
title: t('settings.about.featureRequest'),
|
||||
hint: t('settings.about.featureRequestHint'),
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/liketrek/TREK/wiki',
|
||||
brand: 'var(--text-secondary)',
|
||||
icon: BookOpen,
|
||||
title: 'Wiki',
|
||||
hint: t('settings.about.wikiHint'),
|
||||
},
|
||||
];
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<a
|
||||
href="https://github.com/liketrek/TREK/issues/new?template=bug_report.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#ef4444';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-[#ef444415]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Bug size={20} className="text-[#ef4444]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-content">{t('settings.about.reportBug')}</div>
|
||||
<div className="text-xs text-content-faint">{t('settings.about.reportBugHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/liketrek/TREK/discussions/new?category=feature-requests"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#f59e0b';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-[#f59e0b15]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Lightbulb size={20} className="text-[#f59e0b]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-content">{t('settings.about.featureRequest')}</div>
|
||||
<div className="text-xs text-content-faint">{t('settings.about.featureRequestHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/liketrek/TREK/wiki"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#6366f1';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-[#6366f115]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<BookOpen size={20} className="text-[#6366f1]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-content">Wiki</div>
|
||||
<div className="text-xs text-content-faint">{t('settings.about.wikiHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
</div>
|
||||
const subtitle = t('admin.github.subtitle').replace('{repo}', REPO);
|
||||
|
||||
{/* Loading / Error / Releases */}
|
||||
{loading ? (
|
||||
<div className="overflow-hidden rounded-xl border border-edge bg-surface-card">
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-content-muted" />
|
||||
</div>
|
||||
let releasesCard: ReactNode;
|
||||
if (loading) {
|
||||
releasesCard = (
|
||||
<SettingsCard icon={History} title={t('admin.github.title')} hint={subtitle}>
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-content-faint" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="overflow-hidden rounded-xl border border-edge bg-surface-card">
|
||||
<div className="p-6 text-center">
|
||||
<p className="text-sm text-content-muted">{t('admin.github.error')}</p>
|
||||
<p className="mt-1 text-xs text-content-faint">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-edge bg-surface-card">
|
||||
<div className="flex items-center justify-between border-b border-edge-secondary px-5 py-4">
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">{t('admin.github.title')}</h2>
|
||||
<p className="mt-0.5 text-xs text-content-faint">{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
|
||||
</div>
|
||||
<a
|
||||
href={`https://github.com/${REPO}/releases`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-lg bg-surface-secondary px-3 py-1.5 text-xs font-medium text-content-muted transition-colors"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
} else if (error) {
|
||||
releasesCard = <SettingsCard icon={AlertTriangle} tone="danger" title={t('admin.github.error')} hint={error} />;
|
||||
} else {
|
||||
const shown = isPrerelease ? releases : releases.filter((r) => !r.prerelease);
|
||||
releasesCard = (
|
||||
<SettingsCard
|
||||
icon={History}
|
||||
title={t('admin.github.title')}
|
||||
hint={subtitle}
|
||||
action={
|
||||
<a
|
||||
href={`https://github.com/${REPO}/releases`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`${SETTINGS_BUTTON} no-underline`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<ExternalLink size={13} strokeWidth={2.2} />
|
||||
GitHub
|
||||
</a>
|
||||
}
|
||||
>
|
||||
{/* Timeline */}
|
||||
<div className="relative">
|
||||
{/* Timeline line, from the first dot's middle down to the last one's */}
|
||||
{shown.length > 1 && <div className="absolute bottom-6 left-[15px] top-4 w-px bg-edge" aria-hidden="true" />}
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="px-5 py-4">
|
||||
<div className="relative">
|
||||
{/* Timeline line */}
|
||||
<div
|
||||
className="absolute bottom-3 left-[11px] top-3 w-px"
|
||||
style={{ background: 'var(--border-primary)' }}
|
||||
/>
|
||||
<div className="flex flex-col gap-5">
|
||||
{shown.map((release, idx) => {
|
||||
const isLatest = idx === 0;
|
||||
const isExpanded = expanded[release.id];
|
||||
|
||||
<div className="space-y-0">
|
||||
{(isPrerelease ? releases : releases.filter((r) => !r.prerelease)).map((release, idx) => {
|
||||
const isLatest = idx === 0;
|
||||
const isExpanded = expanded[release.id];
|
||||
return (
|
||||
<div key={release.id} className="relative flex gap-3.5">
|
||||
{/* Timeline dot */}
|
||||
<span
|
||||
className={`relative grid h-8 w-8 flex-none place-items-center rounded-[10px] ${
|
||||
isLatest ? 'bg-accent text-accent-text' : 'bg-surface-card text-content-faint shadow-sm ring-1 ring-edge-faint'
|
||||
}`}
|
||||
>
|
||||
<Tag size={13} strokeWidth={2.2} />
|
||||
</span>
|
||||
|
||||
return (
|
||||
<div key={release.id} className="relative pb-5 pl-8">
|
||||
{/* Timeline dot */}
|
||||
<div
|
||||
className="absolute left-0 top-1 flex h-[23px] w-[23px] items-center justify-center rounded-full border-2"
|
||||
style={{
|
||||
background: isLatest ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
borderColor: isLatest ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
<Tag size={10} style={{ color: isLatest ? 'var(--bg-card)' : 'var(--text-faint)' }} />
|
||||
</div>
|
||||
{/* Release content */}
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-geist font-bold tabular-nums text-content" style={fs(14, 'body')}>{release.tag_name}</span>
|
||||
{isLatest && <StatusPill tone="success">{t('admin.github.latest')}</StatusPill>}
|
||||
{release.prerelease && <StatusPill tone="warning">{t('admin.github.prerelease')}</StatusPill>}
|
||||
</div>
|
||||
|
||||
{/* Release content */}
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-content">{release.tag_name}</span>
|
||||
{isLatest && (
|
||||
<span className="rounded-full bg-[rgba(34,197,94,0.12)] px-2 py-0.5 text-[10px] font-semibold text-[#16a34a]">
|
||||
{t('admin.github.latest')}
|
||||
</span>
|
||||
)}
|
||||
{release.prerelease && (
|
||||
<span className="rounded-full bg-[rgba(245,158,11,0.12)] px-2 py-0.5 text-[10px] font-semibold text-[#d97706]">
|
||||
{t('admin.github.prerelease')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{release.name && release.name !== release.tag_name && (
|
||||
<p className="m-0 mt-0.5 truncate font-medium text-content-secondary" style={fs(12.5, 'body')}>{release.name}</p>
|
||||
)}
|
||||
|
||||
{release.name && release.name !== release.tag_name && (
|
||||
<p className="mt-0.5 text-xs font-medium text-content-muted">{release.name}</p>
|
||||
)}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-content-faint" style={fs(11.5)}>
|
||||
<span className="inline-flex items-center gap-1 font-geist tabular-nums text-content-faint">
|
||||
<Calendar size={11} />
|
||||
{formatDate(release.published_at || release.created_at)}
|
||||
</span>
|
||||
{release.author && (
|
||||
<span className="text-content-faint">
|
||||
{t('admin.github.by')} {release.author.login}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-center gap-3">
|
||||
<span className="flex items-center gap-1 text-[11px] text-content-faint">
|
||||
<Calendar size={10} />
|
||||
{formatDate(release.published_at || release.created_at)}
|
||||
</span>
|
||||
{release.author && (
|
||||
<span className="text-[11px] text-content-faint">
|
||||
{t('admin.github.by')} {release.author.login}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Expandable body */}
|
||||
{release.body && (
|
||||
<div className="mt-2">
|
||||
<button type="button"
|
||||
onClick={() => toggleExpand(release.id)}
|
||||
aria-expanded={!!isExpanded}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-surface-tertiary px-2.5 py-1 font-semibold text-content-muted transition-colors hover:text-content"
|
||||
style={fs(11.5, 'body')}
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={12} strokeWidth={2.4} /> : <ChevronDown size={12} strokeWidth={2.4} />}
|
||||
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
|
||||
</button>
|
||||
|
||||
{/* Expandable body */}
|
||||
{release.body && (
|
||||
<div className="mt-2">
|
||||
<button type="button"
|
||||
onClick={() => toggleExpand(release.id)}
|
||||
className="flex items-center gap-1 text-[11px] font-medium text-content-muted transition-colors"
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2 rounded-lg bg-surface-secondary p-3">{renderBody(release.body)}</div>
|
||||
)}
|
||||
{isExpanded && (
|
||||
<div className="mt-2.5 min-w-0 overflow-hidden break-words rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3">
|
||||
{renderBody(release.body)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Load more */}
|
||||
{hasMore && (
|
||||
<div className="pt-2 text-center">
|
||||
<button type="button"
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-surface-secondary px-4 py-2 text-xs font-medium text-content-muted transition-colors"
|
||||
>
|
||||
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
|
||||
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more */}
|
||||
{hasMore && (
|
||||
<div className="flex justify-center">
|
||||
<button type="button"
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{loadingMore ? <Loader2 size={13} className="animate-spin" /> : <ChevronDown size={13} strokeWidth={2.2} />}
|
||||
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* Support links: one card, the six places as tiles inside it */}
|
||||
<SettingsCard icon={Heart} title={t('settings.about')}>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{supportLinks.map((link) => <SupportCard key={link.href} link={link} />)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Loading / Error / Releases */}
|
||||
{releasesCard}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,57 +1,62 @@
|
||||
import { AlertTriangle, Check } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { NumericInput } from '../shared/NumericInput'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { INPUT } from '../shared/dialogParts'
|
||||
import { SETTINGS_BUTTON_PRIMARY, StatusPill } from '../Settings/settingsKit'
|
||||
import { useGoogleQuota } from './useGoogleQuota'
|
||||
|
||||
/**
|
||||
* The daily ceiling on Google calls (#1582), as one row of the Google options:
|
||||
* today's count as a badge beside the name, the number on the right, a save
|
||||
* button only while there is something to save.
|
||||
* today's count as a pill beside the name, the number on the right, a save
|
||||
* button only while there is something to save. Laid out like settingsKit's
|
||||
* SettingRow, but with the pill outside the <label> so the field keeps the
|
||||
* plain name as its accessible name.
|
||||
*/
|
||||
export default function GoogleDailyLimitRow() {
|
||||
const { t } = useTranslation()
|
||||
const { status, draft, setDraft, dirty, saving, save } = useGoogleQuota()
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-2 py-3">
|
||||
<div className="min-w-0 flex-1 basis-60">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label htmlFor="admin-google-daily-limit" className="text-sm font-medium text-content-secondary">{t('admin.googleQuota.title')}</label>
|
||||
{/* Today's count beside the name it belongs to: green while there is room, amber once the day is used up. */}
|
||||
{status && (
|
||||
status.exhausted ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-warning-soft px-2 py-[2px] text-caption font-semibold tabular-nums text-warning">
|
||||
<AlertTriangle size={11} strokeWidth={2.4} />
|
||||
{t('admin.googleQuota.reached', { used: status.used_today })}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-edge-faint bg-surface-secondary px-2 py-[2px] text-caption font-semibold tabular-nums text-content-secondary">
|
||||
<span aria-hidden className="h-1.5 w-1.5 rounded-full bg-success" />
|
||||
{status.daily_limit == null
|
||||
? t('admin.googleQuota.usedToday', { used: status.used_today })
|
||||
: t('admin.googleQuota.usedOfLimit', { used: status.used_today, limit: status.daily_limit })}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
<div className={`px-3.5 py-3 ${saving ? 'opacity-60' : ''}`}>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<div className="min-w-0 flex-1 basis-56">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label htmlFor="admin-google-daily-limit" className="font-medium text-content" style={fs(13, 'body')}>{t('admin.googleQuota.title')}</label>
|
||||
{/* Today's count beside the name it belongs to: a green dot while there is room, amber once the day is used up. */}
|
||||
{status && (
|
||||
status.exhausted ? (
|
||||
<StatusPill tone="warning" icon={<AlertTriangle size={11} strokeWidth={2.4} />}>
|
||||
{t('admin.googleQuota.reached', { used: status.used_today })}
|
||||
</StatusPill>
|
||||
) : (
|
||||
<StatusPill icon={<span aria-hidden className="mr-0.5 h-1.5 w-1.5 rounded-full bg-success" />}>
|
||||
{status.daily_limit == null
|
||||
? t('admin.googleQuota.usedToday', { used: status.used_today })
|
||||
: t('admin.googleQuota.usedOfLimit', { used: status.used_today, limit: status.daily_limit })}
|
||||
</StatusPill>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11.5)}>{t('admin.googleQuota.subtitle')}</p>
|
||||
</div>
|
||||
<p className="text-xs text-content-faint mt-0.5">{t('admin.googleQuota.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<NumericInput
|
||||
id="admin-google-daily-limit"
|
||||
mode="integer"
|
||||
value={draft}
|
||||
onValueChange={setDraft}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void save() } }}
|
||||
placeholder={t('admin.googleQuota.placeholder')}
|
||||
disabled={status === null || saving}
|
||||
className="w-32 px-3 py-1.5 border border-edge rounded-lg text-sm text-right tabular-nums bg-surface-input text-content focus:ring-2 focus:ring-accent focus:border-transparent disabled:opacity-60"
|
||||
/>
|
||||
<div className="flex flex-none items-center gap-2">
|
||||
<div className="w-32">
|
||||
<NumericInput
|
||||
id="admin-google-daily-limit"
|
||||
mode="integer"
|
||||
value={draft}
|
||||
onValueChange={setDraft}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void save() } }}
|
||||
placeholder={t('admin.googleQuota.placeholder')}
|
||||
disabled={status === null || saving}
|
||||
className={`${INPUT} text-right font-geist tabular-nums`}
|
||||
/>
|
||||
</div>
|
||||
{dirty && (
|
||||
<button type="button" onClick={() => void save()} disabled={saving}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-sm font-medium text-accent-text hover:opacity-90 disabled:opacity-60">
|
||||
<Check size={14} />
|
||||
className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
||||
<Check size={14} strokeWidth={2.2} />
|
||||
{t('common.save')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -24,9 +24,9 @@ function rowButtons(name: string, selector: string): HTMLElement[] {
|
||||
return within(row).getAllByRole('button');
|
||||
}
|
||||
|
||||
const templateButtons = (name: string) => rowButtons(name, '.px-5.py-3');
|
||||
const categoryButtons = (name: string) => rowButtons(name, '.bg-slate-50');
|
||||
const itemButtons = (name: string) => rowButtons(name, '.group');
|
||||
const templateButtons = (name: string) => rowButtons(name, '[data-row="template"]');
|
||||
const categoryButtons = (name: string) => rowButtons(name, '[data-row="category"]');
|
||||
const itemButtons = (name: string) => rowButtons(name, '[data-row="item"]');
|
||||
|
||||
/** Expands the single fixture template and waits for its content. */
|
||||
async function expandBeachTrip(user: ReturnType<typeof userEvent.setup>, firstChild: string) {
|
||||
@@ -171,23 +171,8 @@ describe('PackingTemplateManager', () => {
|
||||
await screen.findByText('Beach Trip');
|
||||
expect(screen.getByText('City Break')).toBeInTheDocument();
|
||||
|
||||
// Find all Trash2 (delete) buttons — there are 2 (one per template)
|
||||
const deleteButtons = screen.getAllByRole('button').filter(b =>
|
||||
b.className.includes('hover:bg-red-50') || b.querySelector('svg')
|
||||
);
|
||||
// Click the delete button for "Beach Trip" (first template row's trash button)
|
||||
// The buttons layout in each row: [chevron, edit, delete]
|
||||
// We find rows first
|
||||
const beachTripRow = screen.getByText('Beach Trip').closest('div');
|
||||
const trashBtn = beachTripRow!.parentElement!.querySelector('button.hover\\:bg-red-50') as HTMLElement | null;
|
||||
if (trashBtn) {
|
||||
await user.click(trashBtn);
|
||||
} else {
|
||||
// Fallback: find all red-hover buttons and click first
|
||||
const allBtns = screen.getAllByRole('button');
|
||||
const redBtns = allBtns.filter(b => b.className.includes('hover:bg-red-50'));
|
||||
await user.click(redBtns[0]);
|
||||
}
|
||||
// Template rows carry [chevron, name, edit, delete]
|
||||
await user.click(templateButtons('Beach Trip')[3]);
|
||||
await waitFor(() => expect(deleteCalled).toBe(true));
|
||||
await waitFor(() => expect(screen.queryByText('Beach Trip')).not.toBeInTheDocument());
|
||||
expect(screen.getByText('City Break')).toBeInTheDocument();
|
||||
@@ -209,18 +194,7 @@ describe('PackingTemplateManager', () => {
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
|
||||
// Find the Edit2 button on the template row
|
||||
const beachTripText = screen.getByText('Beach Trip');
|
||||
const row = beachTripText.closest('div')!.parentElement!;
|
||||
const editBtn = row.querySelector('button.hover\\:bg-slate-100') as HTMLElement | null;
|
||||
if (editBtn) {
|
||||
await user.click(editBtn);
|
||||
} else {
|
||||
// Fallback: find all slate-100-hover buttons
|
||||
const allBtns = screen.getAllByRole('button');
|
||||
const editBtns = allBtns.filter(b => b.className.includes('hover:bg-slate-100'));
|
||||
await user.click(editBtns[0]);
|
||||
}
|
||||
await user.click(templateButtons('Beach Trip')[2]);
|
||||
|
||||
const input = screen.getByDisplayValue('Beach Trip');
|
||||
await user.clear(input);
|
||||
@@ -301,13 +275,8 @@ describe('PackingTemplateManager', () => {
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('Clothing');
|
||||
|
||||
// Find the Edit2 button in the Clothing category header
|
||||
const clothingHeader = screen.getByText('Clothing').closest('div')!;
|
||||
const editBtns = Array.from(clothingHeader.querySelectorAll('button')).filter(
|
||||
b => b.className.includes('hover:text-slate-700')
|
||||
);
|
||||
// Second button (after Plus) is Edit2
|
||||
await user.click(editBtns[1]);
|
||||
// Category headers carry [add item, edit, delete]
|
||||
await user.click(categoryButtons('Clothing')[1]);
|
||||
|
||||
const catInput = screen.getByDisplayValue('Clothing');
|
||||
await user.clear(catInput);
|
||||
@@ -334,10 +303,7 @@ describe('PackingTemplateManager', () => {
|
||||
await screen.findByText('Clothing');
|
||||
expect(screen.getByText('T-shirt')).toBeInTheDocument();
|
||||
|
||||
// Find the Trash2 button in the Clothing category header
|
||||
const clothingHeader = screen.getByText('Clothing').closest('div')!;
|
||||
const trashBtn = clothingHeader.querySelector('button.hover\\:text-red-500') as HTMLElement;
|
||||
await user.click(trashBtn);
|
||||
await user.click(categoryButtons('Clothing')[2]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Clothing')).not.toBeInTheDocument();
|
||||
@@ -363,18 +329,8 @@ describe('PackingTemplateManager', () => {
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('T-shirt');
|
||||
|
||||
// Find the Edit2 button in the T-shirt item row (opacity-0 group-hover buttons)
|
||||
const itemRow = screen.getByText('T-shirt').closest('div')!;
|
||||
const editBtn = Array.from(itemRow.querySelectorAll('button')).find(
|
||||
b => b.className.includes('opacity-0')
|
||||
) as HTMLElement | undefined;
|
||||
if (editBtn) {
|
||||
await user.click(editBtn);
|
||||
} else {
|
||||
// Directly click the first button in the item row
|
||||
const btns = itemRow.querySelectorAll('button');
|
||||
await user.click(btns[0] as HTMLElement);
|
||||
}
|
||||
// Item rows carry [edit, delete]
|
||||
await user.click(itemButtons('T-shirt')[0]);
|
||||
|
||||
const input = screen.getByDisplayValue('T-shirt');
|
||||
await user.clear(input);
|
||||
@@ -401,14 +357,7 @@ describe('PackingTemplateManager', () => {
|
||||
await screen.findByText('T-shirt');
|
||||
expect(screen.getByText('Shorts')).toBeInTheDocument();
|
||||
|
||||
// Find the Trash2 button in the T-shirt row
|
||||
const itemRow = screen.getByText('T-shirt').closest('div')!;
|
||||
const trashBtns = Array.from(itemRow.querySelectorAll('button')).filter(
|
||||
b => b.className.includes('opacity-0')
|
||||
);
|
||||
// Second opacity-0 button is the delete (trash) button
|
||||
const trashBtn = trashBtns[1] || trashBtns[0];
|
||||
await user.click(trashBtn as HTMLElement);
|
||||
await user.click(itemButtons('T-shirt')[1]);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('T-shirt')).not.toBeInTheDocument());
|
||||
expect(screen.getByText('Shorts')).toBeInTheDocument();
|
||||
@@ -489,16 +438,7 @@ describe('PackingTemplateManager', () => {
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
|
||||
const beachTripText = screen.getByText('Beach Trip');
|
||||
const row = beachTripText.closest('div')!.parentElement!;
|
||||
const editBtn = row.querySelector('button.hover\\:bg-slate-100') as HTMLElement | null;
|
||||
if (editBtn) {
|
||||
await user.click(editBtn);
|
||||
} else {
|
||||
const allBtns = screen.getAllByRole('button');
|
||||
const editBtns = allBtns.filter(b => b.className.includes('hover:bg-slate-100'));
|
||||
await user.click(editBtns[0]);
|
||||
}
|
||||
await user.click(templateButtons('Beach Trip')[2]);
|
||||
|
||||
const input = screen.getByDisplayValue('Beach Trip');
|
||||
await user.type(input, '{Escape}');
|
||||
|
||||
@@ -2,7 +2,22 @@ import { useState, useEffect, useRef } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plus, Trash2, Edit2, Package, X, Check, ChevronDown, ChevronRight, FolderPlus } from 'lucide-react'
|
||||
import { Plus, Trash2, Pencil, Package, X, Check, ChevronRight, FolderPlus, Loader2 } from 'lucide-react'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { COMPOSER } from '../Packing/packingPopoverStyles'
|
||||
import { SettingRows, SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON } from '../Settings/settingsKit'
|
||||
|
||||
/** The planner's composer (#2541): a framed field on the card with its confirm and cancel beside it. */
|
||||
const COMPOSER_ROW = 'flex items-center gap-2 rounded-[10px] border border-edge bg-surface-card py-1 pl-3 pr-1'
|
||||
const COMPOSER_INPUT = 'min-w-0 flex-1 border-0 bg-transparent py-1 font-medium text-content outline-none placeholder:text-content-faint'
|
||||
const COMPOSER_CANCEL = 'grid h-7 w-7 flex-none place-items-center rounded-[8px] text-content-faint hover:bg-surface-secondary hover:text-content'
|
||||
/** A name being renamed in place, sized to the row it sits in. */
|
||||
const ROW_INPUT = 'block min-w-0 rounded-[8px] border border-edge bg-surface-input px-2 py-1 text-content outline-none focus:ring-2 focus:ring-[color:var(--text-primary)]'
|
||||
/** The quieter icon action of a category head or an item row, named by its tooltip. */
|
||||
const SMALL_ICON_BUTTON = 'grid h-7 w-7 flex-none place-items-center rounded-[8px] text-content-faint transition-colors hover:bg-surface-card hover:text-content'
|
||||
/** An item's actions show while the pointer or the focus is on its row. */
|
||||
const REVEAL = 'opacity-0 focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100'
|
||||
|
||||
interface TemplateCategory { id: number; template_id: number; name: string; sort_order: number }
|
||||
interface TemplateItem { id: number; category_id: number; name: string; sort_order: number; weight_grams?: number | null; quantity?: number | null; bag_name?: string | null }
|
||||
@@ -156,164 +171,212 @@ export default function PackingTemplateManager() {
|
||||
} catch { toast.error(t('admin.packingTemplates.deleteItemError')) }
|
||||
}
|
||||
|
||||
const inputStyle = 'w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent outline-none'
|
||||
const btnIcon = 'p-1.5 rounded-lg transition-colors'
|
||||
const composerConfirmClass = (enabled: boolean) =>
|
||||
`grid h-7 w-7 flex-none place-items-center rounded-[8px] bg-accent text-accent-text transition-opacity disabled:cursor-default ${enabled ? 'hover:opacity-90' : 'opacity-40'}`
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.packingTemplates.title')}</h2>
|
||||
<p className="mt-1 text-caption text-content-faint">{t('admin.packingTemplates.subtitle')}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 transition-colors">
|
||||
<Plus className="w-4 h-4" /> <span className="hidden sm:inline">{t('admin.packingTemplates.create')}</span>
|
||||
<SettingsCard
|
||||
icon={Package}
|
||||
title={t('admin.packingTemplates.title')}
|
||||
hint={t('admin.packingTemplates.subtitle')}
|
||||
badge={!isLoading && templates.length > 0 ? <StatusPill>{templates.length}</StatusPill> : undefined}
|
||||
action={
|
||||
<button type="button" onClick={() => setShowCreate(true)} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
||||
<Plus size={14} strokeWidth={2.2} /> <span className="hidden sm:inline">{t('admin.packingTemplates.create')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create template */}
|
||||
}
|
||||
>
|
||||
{/* Create template: the planner's composer, a framed field with its confirm beside it */}
|
||||
{showCreate && (
|
||||
<div className="px-5 py-3 border-b border-slate-100 flex items-center gap-3">
|
||||
<Package size={16} className="text-slate-400 flex-shrink-0" />
|
||||
<div className={COMPOSER_ROW} style={{ boxShadow: COMPOSER.boxShadow }}>
|
||||
<Package size={15} className="flex-none text-content-faint" />
|
||||
<input autoFocus value={createName} onChange={e => setCreateName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') void handleCreateTemplate(); if (e.key === 'Escape') setShowCreate(false) }}
|
||||
placeholder={t('admin.packingTemplates.namePlaceholder')} className={inputStyle} />
|
||||
<button type="button" onClick={handleCreateTemplate} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={16} /></button>
|
||||
<button type="button" onClick={() => setShowCreate(false)} className={`${btnIcon} text-slate-400 hover:text-slate-600`}><X size={16} /></button>
|
||||
placeholder={t('admin.packingTemplates.namePlaceholder')} className={COMPOSER_INPUT} style={fs(13, 'body')} />
|
||||
<Tooltip label={t('common.save')}>
|
||||
<button type="button" onClick={handleCreateTemplate} aria-label={t('common.save')} className={composerConfirmClass(!!createName.trim())}>
|
||||
<Check size={14} strokeWidth={2.5} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('common.cancel')}>
|
||||
<button type="button" onClick={() => setShowCreate(false)} aria-label={t('common.cancel')} className={COMPOSER_CANCEL}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Template list */}
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center"><div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" /></div>
|
||||
<div className="grid place-items-center py-8">
|
||||
<Loader2 size={20} className="animate-spin text-content-faint" />
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-slate-400">{t('admin.packingTemplates.empty')}</div>
|
||||
<div className="rounded-[12px] border border-dashed border-edge px-4 py-6 text-center">
|
||||
<SettingsHint>{t('admin.packingTemplates.empty')}</SettingsHint>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-100">
|
||||
{templates.map(tmpl => (
|
||||
<div key={tmpl.id}>
|
||||
{/* Template row */}
|
||||
<div className="px-5 py-3 flex items-center gap-3 hover:bg-slate-50 transition-colors">
|
||||
<button type="button" onClick={() => toggleExpand(tmpl.id)} className="text-slate-400 flex-shrink-0 p-0 bg-transparent border-none cursor-pointer">
|
||||
{expandedId === tmpl.id ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
|
||||
</button>
|
||||
<Package size={16} className="text-slate-400 flex-shrink-0" />
|
||||
{editingTemplate === tmpl.id ? (
|
||||
<input autoFocus value={editTemplateName} onChange={e => setEditTemplateName(e.target.value)}
|
||||
onBlur={() => handleRenameTemplate(tmpl.id)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') void handleRenameTemplate(tmpl.id); if (e.key === 'Escape') setEditingTemplate(null) }}
|
||||
className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm" />
|
||||
) : (
|
||||
<button type="button" onClick={() => toggleExpand(tmpl.id)} className="flex-1 text-left bg-transparent border-0 p-0 text-sm font-medium text-slate-700 cursor-pointer">{tmpl.name}</button>
|
||||
)}
|
||||
<span className="text-xs text-slate-400 px-2 py-0.5 bg-slate-100 rounded-full">
|
||||
{tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')}
|
||||
</span>
|
||||
<button type="button" onClick={() => { setEditingTemplate(tmpl.id); setEditTemplateName(tmpl.name) }}
|
||||
className={`${btnIcon} hover:bg-slate-100 text-slate-400 hover:text-slate-700`}><Edit2 size={14} /></button>
|
||||
<button type="button" onClick={() => handleDeleteTemplate(tmpl.id)}
|
||||
className={`${btnIcon} hover:bg-red-50 text-slate-400 hover:text-red-500`}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
<SettingRows>
|
||||
{templates.map(tmpl => {
|
||||
const expanded = expandedId === tmpl.id
|
||||
return (
|
||||
<div key={tmpl.id}>
|
||||
{/* Template row */}
|
||||
<div data-row="template" className={`flex items-center gap-2.5 px-3 py-2.5 transition-colors ${expanded ? 'bg-surface-secondary' : 'hover:bg-surface-secondary'}`}>
|
||||
<Tooltip label={expanded ? t('common.collapse') : t('common.expand')}>
|
||||
<button type="button" onClick={() => toggleExpand(tmpl.id)} aria-expanded={expanded}
|
||||
aria-label={expanded ? t('common.collapse') : t('common.expand')}
|
||||
className="grid h-7 w-7 flex-none place-items-center rounded-[8px] text-content-faint hover:bg-surface-tertiary hover:text-content">
|
||||
<ChevronRight size={15} strokeWidth={2.2} className={`transition-transform duration-150 ${expanded ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary">
|
||||
<Package size={15} strokeWidth={1.9} />
|
||||
</span>
|
||||
{editingTemplate === tmpl.id ? (
|
||||
<input autoFocus value={editTemplateName} onChange={e => setEditTemplateName(e.target.value)}
|
||||
onBlur={() => handleRenameTemplate(tmpl.id)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') void handleRenameTemplate(tmpl.id); if (e.key === 'Escape') setEditingTemplate(null) }}
|
||||
aria-label={t('common.rename')}
|
||||
className={`${ROW_INPUT} flex-1 font-semibold`} style={fs(13, 'body')} />
|
||||
) : (
|
||||
<button type="button" onClick={() => toggleExpand(tmpl.id)} className="min-w-0 flex-1 truncate text-left font-semibold text-content" style={fs(13, 'body')}>{tmpl.name}</button>
|
||||
)}
|
||||
<span className="max-sm:hidden">
|
||||
<StatusPill>{tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')}</StatusPill>
|
||||
</span>
|
||||
<Tooltip label={t('common.rename')}>
|
||||
<button type="button" onClick={() => { setEditingTemplate(tmpl.id); setEditTemplateName(tmpl.name) }}
|
||||
aria-label={t('common.rename')} className={SETTINGS_ICON_BUTTON}><Pencil size={14} strokeWidth={2} /></button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('common.delete')}>
|
||||
<button type="button" onClick={() => handleDeleteTemplate(tmpl.id)}
|
||||
aria-label={t('common.delete')} className={`${SETTINGS_ICON_BUTTON} hover:!text-danger`}><Trash2 size={14} strokeWidth={2} /></button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expandedId === tmpl.id && (
|
||||
<div className="px-5 pb-4 ml-8 space-y-3">
|
||||
{categories.map(cat => {
|
||||
const catItems = items.filter(i => i.category_id === cat.id)
|
||||
return (
|
||||
<div key={cat.id} className="border border-slate-200 rounded-lg overflow-hidden">
|
||||
{/* Category header */}
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-50">
|
||||
{editingCatId === cat.id ? (
|
||||
<>
|
||||
{/* Expanded content: the template's categories as the packing list draws them */}
|
||||
{expanded && (
|
||||
<div className="flex flex-col gap-2.5 border-t border-edge-faint bg-surface-secondary px-3 py-3 sm:pl-[52px]">
|
||||
{categories.map(cat => {
|
||||
const catItems = items.filter(i => i.category_id === cat.id)
|
||||
return (
|
||||
<div key={cat.id} className="overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
|
||||
{/* Category header */}
|
||||
<div data-row="category" className="flex items-center gap-1.5 border-b border-edge-faint bg-surface-tertiary py-1.5 pl-3 pr-1.5">
|
||||
{editingCatId === cat.id ? (
|
||||
<input autoFocus value={editCatName} onChange={e => setEditCatName(e.target.value)}
|
||||
onBlur={() => handleRenameCategory(cat.id)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') void handleRenameCategory(cat.id); if (e.key === 'Escape') setEditingCatId(null) }}
|
||||
className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm font-semibold" />
|
||||
</>
|
||||
) : (
|
||||
<span className="flex-1 text-xs font-bold text-slate-500 uppercase tracking-wider">{cat.name}</span>
|
||||
)}
|
||||
<span className="text-xs text-slate-400">{catItems.length}</span>
|
||||
<button type="button" onClick={() => { setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) }}
|
||||
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Plus size={13} /></button>
|
||||
<button type="button" onClick={() => { setEditingCatId(cat.id); setEditCatName(cat.name) }}
|
||||
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Edit2 size={13} /></button>
|
||||
<button type="button" onClick={() => handleDeleteCategory(cat.id)}
|
||||
className={`${btnIcon} text-slate-400 hover:text-red-500`}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
{(catItems.length > 0 || addingItemToCatId === cat.id) && (
|
||||
<div className="divide-y divide-slate-50">
|
||||
{catItems.map(item => (
|
||||
<div key={item.id} className="flex items-center gap-3 px-4 py-2 group">
|
||||
{editingItemId === item.id ? (
|
||||
<>
|
||||
<input autoFocus value={editItemName} onChange={e => setEditItemName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') void handleRenameItem(item.id); if (e.key === 'Escape') setEditingItemId(null) }}
|
||||
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
|
||||
<button type="button" onClick={() => handleRenameItem(item.id)} className="p-1 text-slate-600 hover:text-slate-900"><Check size={13} /></button>
|
||||
<button type="button" onClick={() => setEditingItemId(null)} className="p-1 text-slate-400"><X size={13} /></button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1 text-sm text-slate-700">{item.name}</span>
|
||||
{templateItemMeta(item) && (
|
||||
<span className="text-caption text-content-faint tabular-nums truncate max-w-[45%]">{templateItemMeta(item)}</span>
|
||||
)}
|
||||
<button type="button" onClick={() => { setEditingItemId(item.id); setEditItemName(item.name) }}
|
||||
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-slate-700 transition-all"><Edit2 size={12} /></button>
|
||||
<button type="button" onClick={() => handleDeleteItem(item.id)}
|
||||
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-red-500 transition-all"><Trash2 size={12} /></button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add item inline */}
|
||||
{addingItemToCatId === cat.id && (
|
||||
<div className="flex items-center gap-2 px-4 py-2">
|
||||
<input ref={addItemRef} value={newItemName} onChange={e => setNewItemName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && newItemName.trim()) void handleAddItem(cat.id); if (e.key === 'Escape') { setAddingItemToCatId(null); setNewItemName('') } }}
|
||||
placeholder={t('admin.packingTemplates.itemName')}
|
||||
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
|
||||
<button type="button" onClick={() => handleAddItem(cat.id)} disabled={!newItemName.trim()}
|
||||
className="p-1.5 rounded-lg bg-slate-900 text-white disabled:bg-slate-300 hover:bg-slate-700 transition-colors"><Plus size={13} /></button>
|
||||
<button type="button" onClick={() => { setAddingItemToCatId(null); setNewItemName('') }}
|
||||
className="p-1 text-slate-400 hover:text-slate-600"><X size={13} /></button>
|
||||
</div>
|
||||
aria-label={t('common.rename')}
|
||||
className={`${ROW_INPUT} flex-1 font-semibold`} style={fs(12.5, 'body')} />
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 truncate font-geist font-bold uppercase tracking-[.08em] text-content-muted" style={fs(10.5)}>{cat.name}</span>
|
||||
)}
|
||||
<span className="min-w-[20px] flex-none rounded-full bg-surface-card px-1.5 py-px text-center font-geist font-bold tabular-nums text-content-faint" style={fs(10)}>{catItems.length}</span>
|
||||
<Tooltip label={t('common.add')}>
|
||||
<button type="button" onClick={() => { setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) }}
|
||||
aria-label={t('common.add')} className={SMALL_ICON_BUTTON}><Plus size={13} strokeWidth={2.2} /></button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('common.rename')}>
|
||||
<button type="button" onClick={() => { setEditingCatId(cat.id); setEditCatName(cat.name) }}
|
||||
aria-label={t('common.rename')} className={SMALL_ICON_BUTTON}><Pencil size={12} strokeWidth={2} /></button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('common.delete')}>
|
||||
<button type="button" onClick={() => handleDeleteCategory(cat.id)}
|
||||
aria-label={t('common.delete')} className={`${SMALL_ICON_BUTTON} hover:!text-danger`}><Trash2 size={12} strokeWidth={2} /></button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Add category button */}
|
||||
{addingCategory ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input autoFocus value={newCatName} onChange={e => setNewCatName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') void handleAddCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }}
|
||||
placeholder={t('admin.packingTemplates.categoryName')}
|
||||
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm" />
|
||||
<button type="button" onClick={handleAddCategory} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={15} /></button>
|
||||
<button type="button" onClick={() => { setAddingCategory(false); setNewCatName('') }} className={`${btnIcon} text-slate-400`}><X size={15} /></button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={() => setAddingCategory(true)}
|
||||
className="flex items-center gap-2 px-3 py-2.5 w-full text-sm text-slate-400 hover:text-slate-600 border border-dashed border-slate-200 rounded-lg hover:border-slate-400 transition-colors">
|
||||
<FolderPlus size={14} /> {t('admin.packingTemplates.addCategory')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Items */}
|
||||
{(catItems.length > 0 || addingItemToCatId === cat.id) && (
|
||||
<div className="divide-y divide-edge-faint">
|
||||
{catItems.map(item => (
|
||||
<div key={item.id} data-row="item" className="group flex min-h-[40px] items-center gap-2 py-1.5 pl-3 pr-1.5">
|
||||
{editingItemId === item.id ? (
|
||||
<>
|
||||
<input autoFocus value={editItemName} onChange={e => setEditItemName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') void handleRenameItem(item.id); if (e.key === 'Escape') setEditingItemId(null) }}
|
||||
aria-label={t('common.rename')}
|
||||
className={`${ROW_INPUT} flex-1`} style={fs(12.5, 'body')} />
|
||||
<Tooltip label={t('common.save')}>
|
||||
<button type="button" onClick={() => handleRenameItem(item.id)} aria-label={t('common.save')} className={composerConfirmClass(true)}><Check size={13} strokeWidth={2.5} /></button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('common.cancel')}>
|
||||
<button type="button" onClick={() => setEditingItemId(null)} aria-label={t('common.cancel')} className={COMPOSER_CANCEL}><X size={13} /></button>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span aria-hidden className="h-[7px] w-[7px] flex-none rounded-full border-[1.5px] border-edge" />
|
||||
<span className="min-w-0 flex-1 truncate text-content" style={fs(12.5, 'body')}>{item.name}</span>
|
||||
{templateItemMeta(item) && (
|
||||
<span className="max-w-[45%] truncate font-geist tabular-nums text-content-faint" style={fs(11)}>{templateItemMeta(item)}</span>
|
||||
)}
|
||||
<Tooltip label={t('common.rename')}>
|
||||
<button type="button" onClick={() => { setEditingItemId(item.id); setEditItemName(item.name) }}
|
||||
aria-label={t('common.rename')} className={`${SMALL_ICON_BUTTON} ${REVEAL}`}><Pencil size={12} strokeWidth={2} /></button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('common.delete')}>
|
||||
<button type="button" onClick={() => handleDeleteItem(item.id)}
|
||||
aria-label={t('common.delete')} className={`${SMALL_ICON_BUTTON} ${REVEAL} hover:!text-danger`}><Trash2 size={12} strokeWidth={2} /></button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add item inline */}
|
||||
{addingItemToCatId === cat.id && (
|
||||
<div className="flex items-center gap-2 py-1.5 pl-3 pr-1.5">
|
||||
<input ref={addItemRef} value={newItemName} onChange={e => setNewItemName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && newItemName.trim()) void handleAddItem(cat.id); if (e.key === 'Escape') { setAddingItemToCatId(null); setNewItemName('') } }}
|
||||
placeholder={t('admin.packingTemplates.itemName')}
|
||||
className={`${ROW_INPUT} flex-1`} style={fs(12.5, 'body')} />
|
||||
<Tooltip label={t('common.add')}>
|
||||
<button type="button" onClick={() => handleAddItem(cat.id)} disabled={!newItemName.trim()}
|
||||
aria-label={t('common.add')} className={composerConfirmClass(!!newItemName.trim())}><Plus size={13} strokeWidth={2.5} /></button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('common.cancel')}>
|
||||
<button type="button" onClick={() => { setAddingItemToCatId(null); setNewItemName('') }}
|
||||
aria-label={t('common.cancel')} className={COMPOSER_CANCEL}><X size={13} /></button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Add category */}
|
||||
{addingCategory ? (
|
||||
<div className={COMPOSER_ROW} style={{ boxShadow: COMPOSER.boxShadow }}>
|
||||
<input autoFocus value={newCatName} onChange={e => setNewCatName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') void handleAddCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }}
|
||||
placeholder={t('admin.packingTemplates.categoryName')}
|
||||
className={COMPOSER_INPUT} style={fs(13, 'body')} />
|
||||
<Tooltip label={t('common.save')}>
|
||||
<button type="button" onClick={handleAddCategory} aria-label={t('common.save')} className={composerConfirmClass(!!newCatName.trim())}><Check size={14} strokeWidth={2.5} /></button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('common.cancel')}>
|
||||
<button type="button" onClick={() => { setAddingCategory(false); setNewCatName('') }} aria-label={t('common.cancel')} className={COMPOSER_CANCEL}><X size={14} /></button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={() => setAddingCategory(true)}
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-[10px] border border-dashed border-edge px-3 py-2 font-semibold text-content-muted transition-colors hover:border-content-faint hover:text-content"
|
||||
style={fs(12, 'body')}>
|
||||
<FolderPlus size={13} strokeWidth={2.2} /> {t('admin.packingTemplates.addCategory')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</SettingRows>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { usePermissionsStore, PermissionLevel } from '../../store/permissionsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Save, Loader2, RotateCcw } from 'lucide-react'
|
||||
import { Save, Loader2, RotateCcw, ShieldCheck } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { DialogSection, fs } from '../shared/DialogShell'
|
||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsCard, StatusPill } from '../Settings/settingsKit'
|
||||
|
||||
interface PermissionEntry {
|
||||
key: string
|
||||
@@ -89,84 +91,74 @@ export default function PermissionsPanel(): React.ReactElement {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" />
|
||||
</div>
|
||||
<SettingsCard icon={ShieldCheck} title={t('perm.title')} hint={t('perm.subtitle')}>
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 size={20} className="animate-spin text-content-faint" />
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">{t('perm.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('perm.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button"
|
||||
onClick={handleReset}
|
||||
disabled={saving}
|
||||
title={t('perm.resetDefaults')}
|
||||
aria-label={t('perm.resetDefaults')}
|
||||
className="flex items-center justify-center gap-1.5 px-0 sm:px-3 py-1.5 text-sm w-8 sm:w-auto border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">{t('perm.resetDefaults')}</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !dirty}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 disabled:bg-slate-400 transition-colors"
|
||||
>
|
||||
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100">
|
||||
{CATEGORIES.map(cat => (
|
||||
<div key={cat.id} className="px-6 py-4">
|
||||
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">
|
||||
{t(`perm.cat.${cat.id}`)}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{cat.keys.map(key => {
|
||||
const entry = entryMap.get(key)
|
||||
if (!entry) return null
|
||||
const currentLevel = values[key] || entry.defaultLevel
|
||||
const isDefault = currentLevel === entry.defaultLevel
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-700">{t(`perm.action.${key}`)}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t(`perm.actionHint.${key}`)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isDefault && (
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700">
|
||||
{t('perm.customized')}
|
||||
</span>
|
||||
)}
|
||||
<CustomSelect
|
||||
value={currentLevel}
|
||||
onChange={(val) => handleChange(key, val as PermissionLevel)}
|
||||
options={entry.allowedLevels.map(l => ({
|
||||
value: l,
|
||||
label: t(LEVEL_LABELS[l] || l),
|
||||
}))}
|
||||
style={{ minWidth: 160 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<SettingsCard
|
||||
icon={ShieldCheck}
|
||||
title={t('perm.title')}
|
||||
hint={t('perm.subtitle')}
|
||||
action={
|
||||
<div className="flex items-center gap-2" style={fs(12.5, 'body')}>
|
||||
<button type="button"
|
||||
onClick={handleReset}
|
||||
disabled={saving}
|
||||
aria-label={t('perm.resetDefaults')}
|
||||
className={SETTINGS_BUTTON}
|
||||
>
|
||||
<RotateCcw size={14} strokeWidth={2.2} />
|
||||
<span>{t('perm.resetDefaults')}</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !dirty}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} strokeWidth={2.2} />}
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={`flex flex-col gap-5 transition-opacity ${saving ? 'opacity-60' : ''}`}>
|
||||
{CATEGORIES.map(cat => (
|
||||
<DialogSection key={cat.id} label={t(`perm.cat.${cat.id}`)}>
|
||||
<SettingRows>
|
||||
{cat.keys.map(key => {
|
||||
const entry = entryMap.get(key)
|
||||
if (!entry) return null
|
||||
const currentLevel = values[key] || entry.defaultLevel
|
||||
const isDefault = currentLevel === entry.defaultLevel
|
||||
return (
|
||||
<SettingRow
|
||||
key={key}
|
||||
label={t(`perm.action.${key}`)}
|
||||
hint={t(`perm.actionHint.${key}`)}
|
||||
control={<>
|
||||
{!isDefault && <StatusPill tone="warning">{t('perm.customized')}</StatusPill>}
|
||||
<CustomSelect
|
||||
value={currentLevel}
|
||||
onChange={(val) => handleChange(key, val as PermissionLevel)}
|
||||
options={entry.allowedLevels.map(l => ({
|
||||
value: l,
|
||||
label: t(LEVEL_LABELS[l] || l),
|
||||
}))}
|
||||
style={{ minWidth: 180 }}
|
||||
/>
|
||||
</>}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</SettingRows>
|
||||
</DialogSection>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { declaredPoiCategories } from './pluginCaps'
|
||||
|
||||
/** The category's own colour with a white glyph on it, as the explore pill draws a chip that is on. */
|
||||
const SWATCH = 'grid h-6 w-6 flex-none place-items-center rounded-full text-white shadow-sm' // theme-lint-disable: the glyph sits on the plugin's category colour, not on a theme surface
|
||||
|
||||
interface Props {
|
||||
pluginId: string
|
||||
/** The plugin's declared permissions: the list only shows with the grant that serves it. */
|
||||
@@ -28,15 +31,15 @@ export default function PluginPoiCategoryList({ pluginId, permissions, categorie
|
||||
return (
|
||||
<div className={className}>
|
||||
<h4 className={titleClassName}>{t('admin.plugins.poiCategoriesTitle')}</h4>
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
<ul className="m-0 mt-2 flex list-none flex-col gap-1.5 p-0">
|
||||
{declared.map(({ id, label, Icon, color }) => (
|
||||
<li key={id} className={`flex items-center gap-2.5 py-0.5 ${itemClassName}`}>
|
||||
<li key={id} className={`flex min-w-0 items-center gap-2.5 py-0.5 ${itemClassName}`}>
|
||||
<span aria-hidden="true" data-testid="poi-category-swatch"
|
||||
className="grid h-6 w-6 shrink-0 place-items-center rounded-full text-white"
|
||||
className={SWATCH}
|
||||
style={{ backgroundColor: color }}>
|
||||
<Icon size={13} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="min-w-0">{label}</span>
|
||||
<span className="min-w-0 truncate">{label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { INPUT, LABEL } from '../shared/dialogParts'
|
||||
|
||||
export interface RoutingDefaults {
|
||||
routing_base_url?: string
|
||||
@@ -10,6 +12,11 @@ interface RoutingFieldsProps {
|
||||
defaults: RoutingDefaults
|
||||
onSave: (patch: RoutingDefaults) => Promise<void>
|
||||
onReset: (key: keyof RoutingDefaults) => Promise<void>
|
||||
/**
|
||||
* The phone's hint look. A caller that passes it keeps the phone's own field
|
||||
* styling as well; without it the fields take the desktop editors' look
|
||||
* (eyebrow label, boxed input, faint hint).
|
||||
*/
|
||||
hintClassName?: string
|
||||
}
|
||||
|
||||
@@ -18,11 +25,31 @@ const ROUTERS = [
|
||||
{ key: 'valhalla_base_url', label: 'settings.valhallaBase', hint: 'settings.valhallaBaseHint', placeholder: 'https://valhalla.example.org' },
|
||||
] as const
|
||||
|
||||
function RoutingUrlField({ router, defaults, onSave, onReset, hintClassName = 'text-caption leading-relaxed text-content-faint' }: RoutingFieldsProps & { router: typeof ROUTERS[number] }) {
|
||||
// The phone screen's look, unchanged: MAdminDefaultUserSettings passes its own hint class.
|
||||
const PHONE = {
|
||||
wrap: 'min-w-0 space-y-1.5',
|
||||
head: 'flex flex-wrap items-baseline gap-x-2 gap-y-1',
|
||||
label: 'text-body font-medium text-content-secondary',
|
||||
reset: 'text-caption text-content-muted underline disabled:opacity-50',
|
||||
input: 'min-h-11 w-full min-w-0 rounded-lg border border-edge bg-surface-input px-3 py-2 text-body text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50',
|
||||
}
|
||||
|
||||
// The desktop editors' look (shared/dialogParts): an eyebrow over a boxed field.
|
||||
const DESKTOP = {
|
||||
wrap: 'min-w-0',
|
||||
head: 'flex flex-wrap items-baseline justify-between gap-x-2',
|
||||
label: LABEL,
|
||||
reset: 'mb-[5px] rounded-full font-medium text-content-muted underline-offset-2 hover:text-content hover:underline disabled:opacity-50',
|
||||
input: INPUT,
|
||||
}
|
||||
|
||||
function RoutingUrlField({ router, defaults, onSave, onReset, hintClassName }: RoutingFieldsProps & { router: typeof ROUTERS[number] }) {
|
||||
const { t } = useTranslation()
|
||||
const id = useId()
|
||||
const [draft, setDraft] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const phone = hintClassName !== undefined
|
||||
const look = phone ? PHONE : DESKTOP
|
||||
const saved = defaults[router.key] ?? ''
|
||||
const commit = async () => {
|
||||
if (draft === null) return
|
||||
@@ -38,11 +65,11 @@ function RoutingUrlField({ router, defaults, onSave, onReset, hintClassName = 't
|
||||
finally { setDraft(null); setSaving(false) }
|
||||
}
|
||||
return (
|
||||
<div className="min-w-0 space-y-1.5" aria-busy={saving}>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<label htmlFor={id} className="text-body font-medium text-content-secondary">{t(router.label)}</label>
|
||||
<div className={`${look.wrap} ${!phone && saving ? 'opacity-60' : ''}`} aria-busy={saving}>
|
||||
<div className={look.head}>
|
||||
<label htmlFor={id} className={look.label}>{t(router.label)}</label>
|
||||
{defaults[router.key] !== undefined && (
|
||||
<button type="button" disabled={saving} onClick={reset} className="text-caption text-content-muted underline disabled:opacity-50">
|
||||
<button type="button" disabled={saving} onClick={reset} className={look.reset} style={phone ? undefined : fs(11)}>
|
||||
{t('admin.defaultSettings.resetToBuiltIn')}
|
||||
</button>
|
||||
)}
|
||||
@@ -52,13 +79,16 @@ function RoutingUrlField({ router, defaults, onSave, onReset, hintClassName = 't
|
||||
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur() }}
|
||||
placeholder={router.placeholder} spellCheck={false} autoComplete="off" autoCapitalize="none"
|
||||
aria-describedby={`${id}-hint`}
|
||||
className="min-h-11 w-full min-w-0 rounded-lg border border-edge bg-surface-input px-3 py-2 text-body text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
|
||||
className={look.input}
|
||||
/>
|
||||
<p id={`${id}-hint`} className={hintClassName}>{t(router.hint)}</p>
|
||||
{phone
|
||||
? <p id={`${id}-hint`} className={hintClassName}>{t(router.hint)}</p>
|
||||
: <p id={`${id}-hint`} className="m-0 mt-1 leading-normal text-content-faint" style={fs(11)}>{t(router.hint)}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RoutingInstanceFields(props: RoutingFieldsProps) {
|
||||
return <div className="flex flex-col gap-3">{ROUTERS.map(router => <RoutingUrlField key={router.key} router={router} {...props} />)}</div>
|
||||
const phone = props.hintClassName !== undefined
|
||||
return <div className={phone ? 'flex flex-col gap-3' : 'flex flex-col gap-4'}>{ROUTERS.map(router => <RoutingUrlField key={router.key} router={router} {...props} />)}</div>
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { useId, useState } from 'react'
|
||||
import { ChevronRight, GraduationCap, Loader2, MapPinned, Plus, Trash2, WifiOff } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSchoolHolidayCatalog } from './useSchoolHolidayCatalog'
|
||||
import SchoolHolidayRegionEditor, { holidayButtonClass, holidayInputClass } from './SchoolHolidayRegionEditor'
|
||||
import SchoolHolidayRegionEditor from './SchoolHolidayRegionEditor'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import ErrorBoundary from '../shared/ErrorBoundary'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { AddRowButton, EditorField, GRID_2, INPUT, LABEL, PANEL } from '../shared/dialogParts'
|
||||
import { SettingRows, SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON } from '../Settings/settingsKit'
|
||||
|
||||
export default function SchoolHolidayCatalog() {
|
||||
return <ErrorBoundary boundaryId="school-holiday-catalog"><Catalog /></ErrorBoundary>
|
||||
@@ -19,47 +23,80 @@ function Catalog() {
|
||||
const [code, setCode] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [remove, setRemove] = useState<{ label: string; action: () => Promise<boolean> } | null>(null)
|
||||
const fieldId = useId()
|
||||
const selectedCountry = state.catalog.countries.find(item => item.code === country)?.code || state.catalog.countries[0]?.code || ''
|
||||
const regions = state.catalog.regions.filter(region => region.country === selectedCountry)
|
||||
const disabled = state.busy || state.loading || state.offline
|
||||
const addRegion = () => state.setEditor({ id: 0, country: selectedCountry, name: '', code: '', revision: 0, holidays: [] })
|
||||
|
||||
return <section className="space-y-4 rounded-2xl border border-edge bg-surface-card p-4 sm:p-6">
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">{t('schoolCatalog.title')}</h2>
|
||||
<p className="mt-1 text-caption text-content-faint">{t('schoolCatalog.hint')}</p>
|
||||
</div>
|
||||
{state.offline && <p role="status" className="text-body text-warning">{t('schoolCatalog.offline')}</p>}
|
||||
{state.loading && <p role="status" className="text-body text-content-muted">{t('common.loading')}</p>}
|
||||
{state.error && !state.editor && <div role="alert" className="space-y-2 text-body text-danger">{state.error}<button type="button" disabled={disabled} onClick={() => void state.refresh()} className={`${holidayButtonClass} block`}>{t('schoolCatalog.retry')}</button></div>}
|
||||
<fieldset disabled={disabled} className="space-y-4">
|
||||
return <SettingsCard
|
||||
icon={GraduationCap}
|
||||
title={t('schoolCatalog.title')}
|
||||
hint={t('schoolCatalog.hint')}
|
||||
badge={state.catalog.countries.length > 0 ? <StatusPill>{state.catalog.regions.length}</StatusPill> : undefined}
|
||||
>
|
||||
{state.offline && <p role="status" className="m-0 flex items-center gap-2 rounded-[12px] bg-warning-soft px-3.5 py-2.5 text-warning" style={fs(12.5, 'body')}><WifiOff size={14} className="flex-none" />{t('schoolCatalog.offline')}</p>}
|
||||
{state.loading && <p role="status" className="m-0 flex items-center gap-2 text-content-muted" style={fs(12.5, 'body')}><Loader2 size={14} className="flex-none animate-spin" />{t('common.loading')}</p>}
|
||||
{state.error && !state.editor && <div role="alert" className="flex flex-wrap items-center gap-3 rounded-[12px] bg-danger-soft px-3.5 py-2.5 text-danger" style={fs(12.5, 'body')}>
|
||||
<span className="min-w-0 flex-1 basis-48">{state.error}</span>
|
||||
<button type="button" disabled={disabled} onClick={() => void state.refresh()} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>{t('schoolCatalog.retry')}</button>
|
||||
</div>}
|
||||
<fieldset disabled={disabled} className={`m-0 flex min-w-0 flex-col gap-4 border-0 p-0 ${state.busy ? 'opacity-60' : ''}`}>
|
||||
{state.catalog.countries.length > 0 && <div className="flex items-end gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-1 text-body"><span>{t('schoolCatalog.country')}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className={LABEL}>{t('schoolCatalog.country')}</span>
|
||||
<CustomSelect value={selectedCountry} onChange={value => setCountry(String(value))} options={state.catalog.countries.map(item => ({ value: item.code, label: item.name }))} placeholder={t('schoolCatalog.country')} searchable disabled={disabled} />
|
||||
</div>
|
||||
<button type="button" disabled={adding} title={t('schoolCatalog.addCountry')} aria-label={t('schoolCatalog.addCountry')} onClick={() => setAdding(true)} className={`${holidayButtonClass} inline-flex shrink-0 items-center justify-center`}><Plus size={18} /></button>
|
||||
<button type="button" disabled={regions.length > 0} title={t('schoolCatalog.deleteHint')} className={holidayButtonClass} aria-label={t('schoolCatalog.deleteCountry')} onClick={() => setRemove({ label: selectedCountry, action: () => state.deleteCountry(selectedCountry) })}><Trash2 size={18} /></button>
|
||||
<Tooltip label={t('schoolCatalog.addCountry')}>
|
||||
<button type="button" disabled={adding} aria-label={t('schoolCatalog.addCountry')} onClick={() => setAdding(true)} className={`${SETTINGS_ICON_BUTTON} h-[38px] w-[38px]`}><Plus size={16} strokeWidth={2.2} /></button>
|
||||
</Tooltip>
|
||||
{/* The hint is the reason the button is off, so it has to show while it is: the
|
||||
wrapper takes the pointer the disabled button lets through. */}
|
||||
<Tooltip label={t('schoolCatalog.deleteHint')}>
|
||||
<span className="inline-flex flex-none">
|
||||
<button type="button" disabled={regions.length > 0} className={`${SETTINGS_ICON_BUTTON} h-[38px] w-[38px] hover:!text-danger disabled:pointer-events-none`} aria-label={t('schoolCatalog.deleteCountry')} onClick={() => setRemove({ label: selectedCountry, action: () => state.deleteCountry(selectedCountry) })}><Trash2 size={15} strokeWidth={2} /></button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>}
|
||||
{!adding && state.catalog.countries.length === 0 && <button type="button" onClick={() => setAdding(true)} className={`${holidayButtonClass} flex items-center gap-2`}><Plus size={16} />{t('schoolCatalog.addCountry')}</button>}
|
||||
{adding && <form className="space-y-3 rounded-xl border border-edge p-3" onSubmit={async event => {
|
||||
{!adding && state.catalog.countries.length === 0 && <div>
|
||||
<button type="button" onClick={() => setAdding(true)} className={SETTINGS_BUTTON} style={fs(13, 'body')}><Plus size={14} strokeWidth={2.2} />{t('schoolCatalog.addCountry')}</button>
|
||||
</div>}
|
||||
{adding && <form className={PANEL} onSubmit={async event => {
|
||||
event.preventDefault()
|
||||
if (await state.createCountry({ code, name: name.trim() })) { setCountry(code); setAdding(false); setCode(''); setName('') }
|
||||
}}>
|
||||
<label className="block space-y-1 text-body"><span>{t('schoolCatalog.country')}</span><input autoFocus required maxLength={100} className={holidayInputClass} value={name} onChange={event => setName(event.target.value)} /></label>
|
||||
<label className="block space-y-1 text-body"><span>{t('schoolCatalog.code')}</span><input required pattern="[A-Z]{2}" maxLength={2} className={holidayInputClass} value={code} onChange={event => setCode(event.target.value.toUpperCase())} placeholder="US" /></label>
|
||||
<div className="flex gap-2"><button type="submit" className={holidayButtonClass}>{t('common.save')}</button><button type="button" onClick={() => setAdding(false)} className={holidayButtonClass}>{t('common.cancel')}</button></div>
|
||||
<div className={GRID_2}>
|
||||
<EditorField label={t('schoolCatalog.country')} htmlFor={`${fieldId}-name`}>
|
||||
<input id={`${fieldId}-name`} autoFocus required maxLength={100} className={INPUT} value={name} onChange={event => setName(event.target.value)} />
|
||||
</EditorField>
|
||||
<EditorField label={t('schoolCatalog.code')} htmlFor={`${fieldId}-code`}>
|
||||
<input id={`${fieldId}-code`} required pattern="[A-Z]{2}" maxLength={2} className={`${INPUT} font-geist uppercase tracking-[.08em]`} value={code} onChange={event => setCode(event.target.value.toUpperCase())} placeholder="US" />
|
||||
</EditorField>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={() => setAdding(false)} className={SETTINGS_BUTTON} style={fs(13, 'body')}>{t('common.cancel')}</button>
|
||||
<button type="submit" className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>{t('common.save')}</button>
|
||||
</div>
|
||||
</form>}
|
||||
{selectedCountry && <>
|
||||
{regions.length === 0 && <p className="text-body text-content-muted">{t('schoolCatalog.empty')}</p>}
|
||||
<ul className="divide-y divide-edge">{regions.map(region => <li key={region.id} className="flex items-center gap-2 py-2">
|
||||
<button type="button" className={`${holidayButtonClass} min-w-0 flex-1 text-left`} onClick={() => void state.openRegion(region.id)}>{region.name}</button>
|
||||
<button type="button" title={t('schoolCatalog.addRegion')} aria-label={t('schoolCatalog.addRegion')} className={`${holidayButtonClass} inline-flex shrink-0 items-center justify-center`} onClick={addRegion}><Plus size={18} /></button>
|
||||
<button type="button" aria-label={`${t('common.delete')} ${region.name}`} className={holidayButtonClass} onClick={() => setRemove({ label: region.name, action: () => state.deleteRegion(region.id, region.revision) })}><Trash2 size={18} /></button>
|
||||
</li>)}</ul>
|
||||
{regions.length === 0 && <button type="button" className={`${holidayButtonClass} flex items-center gap-2`} onClick={addRegion}><Plus size={16} />{t('schoolCatalog.addRegion')}</button>}
|
||||
{regions.length === 0
|
||||
? <div className="rounded-[12px] border border-dashed border-edge px-4 py-5 text-center"><SettingsHint>{t('schoolCatalog.empty')}</SettingsHint></div>
|
||||
: <SettingRows>
|
||||
{regions.map(region => <div key={region.id} className="flex items-center gap-2 py-1.5 pl-1.5 pr-3">
|
||||
<button type="button" className="group flex min-w-0 flex-1 items-center gap-3 rounded-[10px] px-2 py-1.5 text-left transition-colors hover:bg-surface-secondary" onClick={() => void state.openRegion(region.id)}>
|
||||
<span aria-hidden className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary"><MapPinned size={15} strokeWidth={1.9} /></span>
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(13, 'body')}>{region.name}</span>
|
||||
<ChevronRight aria-hidden size={15} className="flex-none text-content-faint transition-transform group-hover:translate-x-0.5" />
|
||||
</button>
|
||||
<Tooltip label={`${t('common.delete')} ${region.name}`}>
|
||||
<button type="button" aria-label={`${t('common.delete')} ${region.name}`} className={`${SETTINGS_ICON_BUTTON} hover:!text-danger`} onClick={() => setRemove({ label: region.name, action: () => state.deleteRegion(region.id, region.revision) })}><Trash2 size={14} strokeWidth={2} /></button>
|
||||
</Tooltip>
|
||||
</div>)}
|
||||
</SettingRows>}
|
||||
<AddRowButton onClick={addRegion}>{t('schoolCatalog.addRegion')}</AddRowButton>
|
||||
</>}
|
||||
</fieldset>
|
||||
{state.editor && <SchoolHolidayRegionEditor key={state.editor.id} region={state.editor} busy={state.busy} offline={state.offline} error={state.offline ? t('schoolCatalog.offline') : state.error} onClose={() => state.setEditor(null)} onSave={body => { if (state.editor) void state.saveRegion(state.editor.country, state.editor.id, body) }} />}
|
||||
<ConfirmDialog isOpen={Boolean(remove)} onClose={() => setRemove(null)} title={t('common.delete')} message={`${remove?.label || ''}. ${t('schoolCatalog.deleteHint')}`} onConfirm={() => { if (remove) void remove.action(); setRemove(null) }} />
|
||||
</section>
|
||||
</SettingsCard>
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { CalendarRange, Trash2 } from 'lucide-react'
|
||||
import { schoolHolidayRegionRequestSchema, type SchoolHolidayRegionDetail, type SchoolHolidayRegionRequest } from '@trek/shared'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import Modal from '../shared/Modal'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import { AddRowButton, EditorField, GRID_2, INPUT, LABEL, PANEL } from '../shared/dialogParts'
|
||||
import { SettingsHint } from '../Settings/settingsKit'
|
||||
|
||||
export const holidayInputClass = 'w-full rounded-lg border border-edge bg-surface-input px-3 py-2 text-body text-content'
|
||||
export const holidayButtonClass = 'rounded-lg border border-edge px-3 py-2 text-body font-medium text-content hover:bg-surface-hover disabled:opacity-50'
|
||||
/** The dialog body's padded column, kept inside the form so the footer's Save submits it. */
|
||||
const BODY = 'flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto px-6 pb-6 pt-5'
|
||||
const ALERT = 'm-0 rounded-[12px] bg-danger-soft px-3.5 py-2.5 text-danger'
|
||||
|
||||
export default function SchoolHolidayRegionEditor({ region, busy, offline = false, error, onClose, onSave }: {
|
||||
region: SchoolHolidayRegionDetail
|
||||
@@ -27,49 +31,79 @@ export default function SchoolHolidayRegionEditor({ region, busy, offline = fals
|
||||
const body = { name, revision: region.revision, holidays: holidays.map(({ name, startDate, endDate }) => ({ name, startDate, endDate })) }
|
||||
const dirty = name !== region.name || JSON.stringify(body.holidays) !== JSON.stringify(region.holidays)
|
||||
const close = () => { if (!busy) { if (dirty) setDiscard(true); else onClose() } }
|
||||
const labelId = `${fieldId}-title`
|
||||
|
||||
return <>
|
||||
<Modal isOpen onClose={close} title={t('schoolCatalog.region')} size="xl">
|
||||
<form className="space-y-4" onSubmit={event => {
|
||||
<DialogShell
|
||||
onClose={close}
|
||||
labelledBy={labelId}
|
||||
width="editor"
|
||||
align="top"
|
||||
blocked={discard}
|
||||
bodyClassName="flex min-h-0 flex-1 flex-col"
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={<DialogTile><CalendarRange size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={close}
|
||||
eyebrow={t('schoolCatalog.title')}
|
||||
title={name.trim() || t('schoolCatalog.region')}
|
||||
sub={region.country}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<form className="flex min-h-0 flex-1 flex-col" onSubmit={event => {
|
||||
event.preventDefault()
|
||||
const parsed = schoolHolidayRegionRequestSchema.safeParse(body)
|
||||
setInvalid(!parsed.success)
|
||||
if (parsed.success) onSave(parsed.data)
|
||||
}}>
|
||||
<fieldset disabled={busy || offline} className="space-y-4">
|
||||
<label className="block space-y-1 text-body"><span>{t('schoolCatalog.region')}</span>
|
||||
<input autoFocus required maxLength={150} className={holidayInputClass} value={name} onChange={event => setName(event.target.value)} />
|
||||
</label>
|
||||
<p className="text-caption text-content-muted">{t('schoolCatalog.periodHint')}</p>
|
||||
{holidays.map((holiday, index) => <div key={holiday.key} className="space-y-2 rounded-xl border border-edge p-3">
|
||||
<div className="space-y-1 text-body">
|
||||
<label htmlFor={`${fieldId}-holiday-${holiday.key}`}>{t('schoolCatalog.name')}</label>
|
||||
<div className="flex items-stretch gap-2">
|
||||
<input id={`${fieldId}-holiday-${holiday.key}`} required maxLength={150} className={`${holidayInputClass} min-w-0 flex-1`} value={holiday.name} onChange={event => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, name: event.target.value } : row))} />
|
||||
<button type="button" className="flex shrink-0 items-center justify-center rounded-[10px] border border-edge px-[9px] text-content-faint transition-colors hover:border-[color:var(--text-faint)] hover:text-content disabled:opacity-50" aria-label={`${t('common.delete')} ${holiday.name || index + 1}`} onClick={() => setHolidays(rows => rows.filter(row => row.key !== holiday.key))}><Trash2 size={13} /></button>
|
||||
<div className={BODY}>
|
||||
<fieldset disabled={busy || offline} className={`m-0 flex min-w-0 flex-col gap-5 border-0 p-0 ${busy ? 'opacity-60' : ''}`}>
|
||||
<EditorField label={t('schoolCatalog.region')} htmlFor={`${fieldId}-region`}>
|
||||
<input id={`${fieldId}-region`} autoFocus required maxLength={150} className={INPUT} value={name} onChange={event => setName(event.target.value)} />
|
||||
</EditorField>
|
||||
<SettingsHint>{t('schoolCatalog.periodHint')}</SettingsHint>
|
||||
{holidays.map((holiday, index) => {
|
||||
const removeLabel = `${t('common.delete')} ${holiday.name || index + 1}`
|
||||
return <div key={holiday.key} className={PANEL}>
|
||||
<EditorField label={t('schoolCatalog.name')} htmlFor={`${fieldId}-holiday-${holiday.key}`}>
|
||||
<div className="flex items-stretch gap-2">
|
||||
<input id={`${fieldId}-holiday-${holiday.key}`} required maxLength={150} className={`${INPUT} flex-1`} value={holiday.name} onChange={event => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, name: event.target.value } : row))} />
|
||||
<Tooltip label={removeLabel}>
|
||||
<button type="button" className="grid w-[38px] flex-none place-items-center rounded-[10px] border border-edge bg-surface-card text-content-faint transition-colors hover:border-content-faint hover:text-danger disabled:cursor-default disabled:opacity-50" aria-label={removeLabel} onClick={() => setHolidays(rows => rows.filter(row => row.key !== holiday.key))}>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</EditorField>
|
||||
<div className={GRID_2}>
|
||||
<div role="group" aria-label={t('schoolCatalog.start')} className="min-w-0">
|
||||
<span className={LABEL}>{t('schoolCatalog.start')}</span>
|
||||
<CustomDatePicker key={String(busy || offline)} value={holiday.startDate} placeholder={t('schoolCatalog.start')} max={holiday.endDate || undefined} onChange={startDate => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, startDate } : row))} />
|
||||
</div>
|
||||
<div role="group" aria-label={t('schoolCatalog.end')} className="min-w-0">
|
||||
<span className={LABEL}>{t('schoolCatalog.end')}</span>
|
||||
<CustomDatePicker key={String(busy || offline)} value={holiday.endDate} placeholder={t('schoolCatalog.end')} min={holiday.startDate || undefined} onChange={endDate => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, endDate } : row))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<div role="group" aria-label={t('schoolCatalog.start')} className="min-w-0 space-y-1 text-caption"><span>{t('schoolCatalog.start')}</span>
|
||||
<CustomDatePicker key={String(busy || offline)} value={holiday.startDate} placeholder={t('schoolCatalog.start')} max={holiday.endDate || undefined} onChange={startDate => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, startDate } : row))} />
|
||||
</div>
|
||||
<div role="group" aria-label={t('schoolCatalog.end')} className="min-w-0 space-y-1 text-caption"><span>{t('schoolCatalog.end')}</span>
|
||||
<CustomDatePicker key={String(busy || offline)} value={holiday.endDate} placeholder={t('schoolCatalog.end')} min={holiday.startDate || undefined} onChange={endDate => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, endDate } : row))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>)}
|
||||
<button type="button" disabled={holidays.length >= 500} className={`${holidayButtonClass} flex items-center gap-2`} onClick={() => {
|
||||
setHolidays(rows => [...rows, { key: nextKey, name: '', startDate: '', endDate: '' }]); setNextKey(key => key + 1)
|
||||
}}><Plus size={16} />{t('schoolCatalog.addPeriod')}</button>
|
||||
{invalid && <p role="alert" className="text-body text-danger">{t('schoolCatalog.invalid')}</p>}
|
||||
{error && <p role="alert" className="text-body text-danger">{error}</p>}
|
||||
</fieldset>
|
||||
<div className="flex justify-end gap-2 border-t border-edge pt-4">
|
||||
<button type="button" className={holidayButtonClass} onClick={close}>{t('common.cancel')}</button>
|
||||
<button type="submit" disabled={busy || offline} aria-busy={busy} className="rounded-lg bg-accent px-4 py-2 text-body font-semibold text-accent-text disabled:opacity-50">{busy ? t('common.loading') : t('common.save')}</button>
|
||||
</div>
|
||||
})}
|
||||
<AddRowButton disabled={holidays.length >= 500} onClick={() => {
|
||||
setHolidays(rows => [...rows, { key: nextKey, name: '', startDate: '', endDate: '' }]); setNextKey(key => key + 1)
|
||||
}}>{t('schoolCatalog.addPeriod')}</AddRowButton>
|
||||
{invalid && <p role="alert" className={ALERT} style={fs(12.5, 'body')}>{t('schoolCatalog.invalid')}</p>}
|
||||
{error && <p role="alert" className={ALERT} style={fs(12.5, 'body')}>{error}</p>}
|
||||
</fieldset>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={close}>{t('common.cancel')}</DialogButton>
|
||||
<DialogButton type="submit" variant="primary" disabled={busy || offline} aria-busy={busy}>{busy ? t('common.loading') : t('common.save')}</DialogButton>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
</DialogShell>
|
||||
<ConfirmDialog isOpen={discard} onClose={() => setDiscard(false)} onConfirm={onClose} title={t('common.cancel')} message={t('schoolCatalog.discard')} />
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Activity, FolderTree, HardDrive } from 'lucide-react'
|
||||
import React, { useEffect, useId, useRef, useState } from 'react'
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
ArrowRightLeft,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
Cloud,
|
||||
Copy,
|
||||
FolderTree,
|
||||
HardDrive,
|
||||
Loader2,
|
||||
Lock,
|
||||
Pencil,
|
||||
PlugZap,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
STORAGE_CATEGORIES,
|
||||
type StorageBackend,
|
||||
@@ -14,7 +34,17 @@ import { relativeTime } from '../../../utils/relativeTime'
|
||||
import ConfirmDialog from '../../shared/ConfirmDialog'
|
||||
import CustomSelect from '../../shared/CustomSelect'
|
||||
import { useToast } from '../../shared/Toast'
|
||||
import { Tooltip } from '../../shared/Tooltip'
|
||||
import { NEUTRAL_TINT, fs } from '../../shared/DialogShell'
|
||||
import Section from '../../Settings/Section'
|
||||
import {
|
||||
SETTINGS_BUTTON,
|
||||
SETTINGS_BUTTON_PRIMARY,
|
||||
SETTINGS_ICON_BUTTON,
|
||||
SettingRows,
|
||||
SettingsHint,
|
||||
StatusPill,
|
||||
} from '../../Settings/settingsKit'
|
||||
import BackendForm, { type BackendFormMirrorProps } from './BackendForm'
|
||||
import {
|
||||
CACHE_CATEGORIES,
|
||||
@@ -43,6 +73,17 @@ import { useStorageAdmin } from './useStorageAdmin'
|
||||
const categoryNames = (t: (key: string) => string, ids: readonly string[]): string =>
|
||||
ids.map((id) => t(`storage.category.${id}`)).join(', ')
|
||||
|
||||
/** The compact buttons of a backend row: white on a hairline, a size under the card's own. */
|
||||
const ROW_BUTTON = 'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 py-1.5 font-medium text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:cursor-default disabled:opacity-50'
|
||||
const ROW_BUTTON_PRIMARY = 'inline-flex items-center gap-1.5 rounded-[10px] bg-accent px-2.5 py-1.5 font-medium text-accent-text hover:opacity-90 disabled:cursor-default disabled:opacity-50'
|
||||
/** The icon square on the left of a row. */
|
||||
const TILE = 'grid h-9 w-9 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-muted'
|
||||
/** What a row says under its head line, lined up with the name rather than the tile. */
|
||||
const DETAILS = 'mt-2 flex flex-col items-start gap-1.5 empty:hidden sm:pl-12'
|
||||
const META = 'm-0 leading-snug text-content-faint'
|
||||
|
||||
const TYPE_ICON: Record<string, LucideIcon> = { local: HardDrive, s3: Cloud, mirror: Copy }
|
||||
|
||||
function TestResult({ result, failedLabel, okLabel }: {
|
||||
result: StorageTestResponse | undefined
|
||||
failedLabel: string
|
||||
@@ -50,19 +91,37 @@ function TestResult({ result, failedLabel, okLabel }: {
|
||||
}): React.ReactElement | null {
|
||||
if (result === undefined) return null
|
||||
return (
|
||||
<div className="mt-2 space-y-0.5">
|
||||
<p className="text-xs font-semibold text-content">{result.ok ? okLabel : failedLabel}</p>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<StatusPill
|
||||
tone={result.ok ? 'success' : 'danger'}
|
||||
icon={result.ok ? <Check size={10} strokeWidth={2.6} /> : <X size={10} strokeWidth={2.6} />}
|
||||
>
|
||||
{result.ok ? okLabel : failedLabel}
|
||||
</StatusPill>
|
||||
{result.targets.map((target) => (
|
||||
<p key={target.name} className="text-xs text-content-faint">
|
||||
{target.ok ? '✓' : '✗'} {target.name}
|
||||
{target.error ? ` — ${target.error}` : ''}
|
||||
<p key={target.name} className="m-0 flex min-w-0 items-start gap-1.5 text-content-faint" style={fs(11.5)}>
|
||||
{target.ok
|
||||
? <Check size={12} strokeWidth={2.4} className="mt-[1px] flex-none text-success" />
|
||||
: <X size={12} strokeWidth={2.4} className="mt-[1px] flex-none text-danger" />}
|
||||
<span className="min-w-0 break-words">
|
||||
<span className="font-geist font-semibold text-content-secondary">{target.name}</span>
|
||||
{target.error ? ` — ${target.error}` : ''}
|
||||
</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LINK_BUTTON_STYLE: React.CSSProperties = { background: 'none', border: 'none', cursor: 'pointer' }
|
||||
/** A thin bar for a running sync or move. */
|
||||
function Progress({ done, total }: { done: number; total: number }): React.ReactElement {
|
||||
const share = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0
|
||||
return (
|
||||
<div className="h-1.5 w-full max-w-[280px] overflow-hidden rounded-full bg-surface-tertiary" aria-hidden="true">
|
||||
<div className="h-full rounded-full bg-accent transition-[width] duration-300" style={{ width: `${share}%` }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminStoragePanel(): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
@@ -94,6 +153,7 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
// confirmed the first — a ref (not state) so the guard is visible on that
|
||||
// very next synchronous re-render, not just after a state-driven one.
|
||||
const migrationStartInFlight = useRef(false)
|
||||
const migrateTitleId = useId()
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingPromptCheck.current || !admin.state) return
|
||||
@@ -144,12 +204,20 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
}, [migrationQueue, admin.state])
|
||||
|
||||
if (admin.loading) {
|
||||
return <p className="text-sm italic p-4 text-content-faint">{t('storage.loading')}</p>
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-1 py-4 text-content-faint" style={fs(12.5, 'body')}>
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
{t('storage.loading')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!admin.state || !admin.draft) {
|
||||
return (
|
||||
<div role="alert" className="rounded-xl border p-4 text-sm border-edge bg-surface-card text-content">
|
||||
{admin.loadError || t('common.error')}
|
||||
<div role="alert" className="flex items-start gap-3 rounded-2xl border border-edge-faint bg-danger-soft px-4 py-3">
|
||||
<AlertTriangle size={16} strokeWidth={2.2} className="mt-0.5 flex-none text-danger" />
|
||||
<p className="m-0 min-w-0 flex-1 break-words leading-normal text-content" style={fs(13, 'body')}>
|
||||
{admin.loadError || t('common.error')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -299,49 +367,81 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col">
|
||||
{state.configError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-xl border p-3 mb-4 text-sm"
|
||||
style={{ borderColor: 'var(--warning)', background: 'var(--warning-soft)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{t('storage.configError.banner', { error: state.configError })}
|
||||
<div className="mb-5 flex items-start gap-3 rounded-2xl border border-edge-faint bg-warning-soft px-4 py-3">
|
||||
<AlertTriangle size={16} strokeWidth={2.2} className="mt-0.5 flex-none text-warning" />
|
||||
<p role="alert" className="m-0 min-w-0 flex-1 break-words leading-normal text-content" style={fs(13, 'body')}>
|
||||
{t('storage.configError.banner', { error: state.configError })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Section title={t('storage.health.title')} icon={Activity}>
|
||||
{state.health.replicaFailures.length === 0 ? (
|
||||
<p className="text-sm text-content-faint">{t('storage.health.allClear')}</p>
|
||||
<p className="m-0 flex items-center gap-2 text-content-muted" style={fs(12.5, 'body')}>
|
||||
<CheckCircle2 size={15} strokeWidth={2.2} className="flex-none text-success" />
|
||||
{t('storage.health.allClear')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
<SettingRows>
|
||||
{state.health.replicaFailures.map((failure) => (
|
||||
<li key={`${failure.backend}-${failure.key}-${failure.at}`} className="text-sm text-content">
|
||||
{t('storage.health.failureLine', {
|
||||
op: failure.op,
|
||||
key: failure.key,
|
||||
backend: failure.backend,
|
||||
error: failure.error,
|
||||
})}
|
||||
<span className="text-content-faint"> · {relativeTime(failure.at, locale)}</span>
|
||||
</li>
|
||||
<div key={`${failure.backend}-${failure.key}-${failure.at}`} className="flex items-start gap-2.5 px-3.5 py-2.5">
|
||||
<AlertTriangle size={14} strokeWidth={2.2} className="mt-0.5 flex-none text-danger" />
|
||||
<p className="m-0 min-w-0 flex-1 break-words leading-snug text-content" style={fs(12.5, 'body')}>
|
||||
<span>
|
||||
{t('storage.health.failureLine', {
|
||||
op: failure.op,
|
||||
key: failure.key,
|
||||
backend: failure.backend,
|
||||
error: failure.error,
|
||||
})}
|
||||
</span>
|
||||
<span className="whitespace-nowrap text-content-faint"> · {relativeTime(failure.at, locale)}</span>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</SettingRows>
|
||||
)}
|
||||
{state.seedFilePresent && <p className="text-xs mt-2 text-content-faint">{t('storage.health.seedFile')}</p>}
|
||||
{state.seedFilePresent && <SettingsHint>{t('storage.health.seedFile')}</SettingsHint>}
|
||||
</Section>
|
||||
|
||||
<Section title={t('storage.backends.title')} icon={HardDrive}>
|
||||
<p className="text-sm text-content-faint" style={{ marginTop: -8 }}>{t('storage.description')}</p>
|
||||
<p className="text-xs mb-2 text-content-faint">
|
||||
{state.usage
|
||||
? t('storage.usage.computed', { age: relativeTime(state.usage.computedAt, locale) })
|
||||
: t('storage.usage.never')}
|
||||
{' '}
|
||||
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={handleRefreshStats}>
|
||||
<Section
|
||||
title={t('storage.backends.title')}
|
||||
icon={HardDrive}
|
||||
hint={t('storage.description')}
|
||||
action={
|
||||
editing ? undefined : (
|
||||
<button type="button"
|
||||
onClick={() =>
|
||||
setEditing({
|
||||
initial: null,
|
||||
originalName: null,
|
||||
mirror: { candidates: replicaCandidates(rows, null), initialTargets: [] },
|
||||
})
|
||||
}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.2} />
|
||||
{t('storage.backends.add')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<p className="m-0 min-w-0 flex-1 text-content-faint" style={fs(11.5)}>
|
||||
{state.usage
|
||||
? t('storage.usage.computed', { age: relativeTime(state.usage.computedAt, locale) })
|
||||
: t('storage.usage.never')}
|
||||
</p>
|
||||
<button type="button" className={ROW_BUTTON} style={fs(12, 'body')} onClick={handleRefreshStats}>
|
||||
<RefreshCw size={13} strokeWidth={2.2} />
|
||||
{state.usage ? t('storage.usage.refresh') : t('storage.usage.compute')}
|
||||
</button>
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
</div>
|
||||
|
||||
<SettingRows>
|
||||
{rows.map((row) => {
|
||||
const resultKey = row.mirrorName ?? row.name
|
||||
const result = testResultFor(resultKey)
|
||||
@@ -352,150 +452,172 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
: row.backend
|
||||
const rowUsage = usageSums?.[row.name]
|
||||
const backfill = row.mirrorName ? state.backfills.find((b) => b.backend === row.mirrorName) : undefined
|
||||
const TypeIcon = TYPE_ICON[row.type] ?? HardDrive
|
||||
return (
|
||||
<div key={row.name} data-testid={`storage-backend-${row.name}`} className="rounded-xl border p-4 border-edge-secondary">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-content">{row.name}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-secondary">
|
||||
{t(`storage.type.${row.type}`)}
|
||||
<div key={row.name} data-testid={`storage-backend-${row.name}`} className="px-3.5 py-3">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<span className={TILE}>
|
||||
<TypeIcon size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-faint">
|
||||
{t(`storage.source.${row.source}`)}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() =>
|
||||
row.mirrorName
|
||||
? admin.testMirror(resultKey, mirrorProbeTargets(draft, state, testCandidate))
|
||||
: admin.test(testCandidate)
|
||||
}
|
||||
>
|
||||
{t('storage.actions.test')}
|
||||
</button>
|
||||
{row.source !== 'env' && (
|
||||
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => startEdit(row)}>
|
||||
{t('storage.actions.edit')}
|
||||
<div className="min-w-0 flex-1 basis-48">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<span className="min-w-0 truncate font-geist font-semibold text-content" style={fs(13.5, 'body')}>{row.name}</span>
|
||||
<StatusPill>{t(`storage.type.${row.type}`)}</StatusPill>
|
||||
<StatusPill icon={row.source === 'env' ? <Lock size={10} strokeWidth={2.4} /> : undefined}>
|
||||
{t(`storage.source.${row.source}`)}
|
||||
</StatusPill>
|
||||
</div>
|
||||
<p className="m-0 mt-0.5 text-content-faint" style={fs(11.5)}>
|
||||
{row.categories.length > 0
|
||||
? t('storage.backends.usedBy', { categories: categoryNames(t, row.categories) })
|
||||
: t('storage.backends.unused')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-none items-center gap-1.5">
|
||||
<button type="button"
|
||||
className={ROW_BUTTON}
|
||||
style={fs(12, 'body')}
|
||||
onClick={() =>
|
||||
row.mirrorName
|
||||
? admin.testMirror(resultKey, mirrorProbeTargets(draft, state, testCandidate))
|
||||
: admin.test(testCandidate)
|
||||
}
|
||||
>
|
||||
<PlugZap size={13} strokeWidth={2.2} />
|
||||
{t('storage.actions.test')}
|
||||
</button>
|
||||
)}
|
||||
{row.source === 'settings' && (
|
||||
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: row.name, degenerate: false })}>
|
||||
{t('storage.actions.remove')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{row.categories.length > 0
|
||||
? t('storage.backends.usedBy', { categories: categoryNames(t, row.categories) })
|
||||
: t('storage.backends.unused')}
|
||||
</p>
|
||||
{rowUsage && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.usage.line', { objects: String(rowUsage.objects), size: formatBytes(rowUsage.bytes) })}
|
||||
{row.name === 'uploads-local' && state.usage!.legacyPhotos.objects > 0
|
||||
? ` (${t('storage.usage.legacyNote')})`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
{row.mirrorTargets.length > 0 && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.mirror.mirroredTo', { targets: row.mirrorTargets.join(', ') })}
|
||||
</p>
|
||||
)}
|
||||
{row.replicaOf.length > 0 && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.mirror.replicaOf', { primaries: row.replicaOf.join(', ') })}
|
||||
</p>
|
||||
)}
|
||||
{row.source === 'env' && <p className="text-xs mt-1 text-content-faint">{t('storage.backends.envReadOnly')}</p>}
|
||||
{result === 'running' ? (
|
||||
<p className="text-xs mt-2 text-content-faint">{t('storage.test.running')}</p>
|
||||
) : (
|
||||
<TestResult
|
||||
result={result as StorageTestResponse | undefined}
|
||||
okLabel={t('storage.test.ok')}
|
||||
failedLabel={t('storage.test.failed')}
|
||||
/>
|
||||
)}
|
||||
{row.mirrorTargets.length > 0 && (
|
||||
<div className="mt-2">
|
||||
{backfill?.status === 'running' ? (
|
||||
<>
|
||||
<p className="text-xs text-content-faint">
|
||||
{t('storage.sync.running', { done: String(backfill.done), total: String(backfill.total) })}
|
||||
</p>
|
||||
<p className="text-xs text-content-faint">
|
||||
{t('storage.sync.counts', {
|
||||
copied: String(backfill.copied),
|
||||
skipped: String(backfill.skipped),
|
||||
failed: String(backfill.failed),
|
||||
})}
|
||||
</p>
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary mt-1"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => handleCancelBackfill(row)}
|
||||
>
|
||||
{t('storage.sync.cancel')}
|
||||
{row.source !== 'env' && (
|
||||
<Tooltip label={t('storage.actions.edit')}>
|
||||
<button type="button" aria-label={t('storage.actions.edit')} className={SETTINGS_ICON_BUTTON} onClick={() => startEdit(row)}>
|
||||
<Pencil size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Terminal statuses render ALONGSIDE the button (not instead-of) —
|
||||
a completed/cancelled/errored backfill must stay re-runnable
|
||||
without a page reload. */}
|
||||
{backfill?.status === 'done' && (
|
||||
<p className="text-xs text-content-faint">
|
||||
{t('storage.sync.done', {
|
||||
</Tooltip>
|
||||
)}
|
||||
{row.source === 'settings' && (
|
||||
<Tooltip label={t('storage.actions.remove')}>
|
||||
<button type="button" aria-label={t('storage.actions.remove')} className={`${SETTINGS_ICON_BUTTON} hover:!text-danger`} onClick={() => setConfirmRemove({ name: row.name, degenerate: false })}>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={DETAILS}>
|
||||
{rowUsage && (
|
||||
<p className={`${META} font-geist tabular-nums`} style={fs(11.5)}>
|
||||
{t('storage.usage.line', { objects: String(rowUsage.objects), size: formatBytes(rowUsage.bytes) })}
|
||||
{row.name === 'uploads-local' && state.usage!.legacyPhotos.objects > 0
|
||||
? ` (${t('storage.usage.legacyNote')})`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
{row.mirrorTargets.length > 0 && (
|
||||
<p className={META} style={fs(11.5)}>
|
||||
{t('storage.mirror.mirroredTo', { targets: row.mirrorTargets.join(', ') })}
|
||||
</p>
|
||||
)}
|
||||
{row.replicaOf.length > 0 && (
|
||||
<p className={META} style={fs(11.5)}>
|
||||
{t('storage.mirror.replicaOf', { primaries: row.replicaOf.join(', ') })}
|
||||
</p>
|
||||
)}
|
||||
{row.source === 'env' && <p className={META} style={fs(11.5)}>{t('storage.backends.envReadOnly')}</p>}
|
||||
{result === 'running' ? (
|
||||
<p className={`${META} inline-flex items-center gap-1.5`} style={fs(11.5)}>
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
{t('storage.test.running')}
|
||||
</p>
|
||||
) : (
|
||||
<TestResult
|
||||
result={result as StorageTestResponse | undefined}
|
||||
okLabel={t('storage.test.ok')}
|
||||
failedLabel={t('storage.test.failed')}
|
||||
/>
|
||||
)}
|
||||
{row.mirrorTargets.length > 0 && (
|
||||
<div className="flex flex-col items-start gap-1.5">
|
||||
{backfill?.status === 'running' ? (
|
||||
<>
|
||||
<p className={`${META} font-geist tabular-nums`} style={fs(11.5)}>
|
||||
{t('storage.sync.running', { done: String(backfill.done), total: String(backfill.total) })}
|
||||
</p>
|
||||
<Progress done={backfill.done} total={backfill.total} />
|
||||
<p className={`${META} font-geist tabular-nums`} style={fs(11.5)}>
|
||||
{t('storage.sync.counts', {
|
||||
copied: String(backfill.copied),
|
||||
deleted: String(backfill.deleted),
|
||||
skipped: String(backfill.skipped),
|
||||
failed: String(backfill.failed),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{backfill?.status === 'cancelled' && (
|
||||
<p className="text-xs text-content-faint">{t('storage.sync.cancelled')}</p>
|
||||
)}
|
||||
{backfill?.status === 'error' && (
|
||||
<p className="text-xs text-content-faint">
|
||||
{t('storage.sync.error', { error: backfill.error ?? '' })}
|
||||
</p>
|
||||
)}
|
||||
{syncPrompt !== row.name && (
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => handleStartBackfill(row)}
|
||||
className={ROW_BUTTON}
|
||||
style={fs(12, 'body')}
|
||||
onClick={() => handleCancelBackfill(row)}
|
||||
>
|
||||
{t('storage.sync.now')}
|
||||
<X size={13} strokeWidth={2.2} />
|
||||
{t('storage.sync.cancel')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{syncPrompt === row.name && (
|
||||
<div className="mt-2 rounded-lg border p-2 border-edge-secondary">
|
||||
<p className="text-xs text-content">{t('storage.sync.prompt')}</p>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => handleStartBackfill(row)}
|
||||
>
|
||||
{t('storage.sync.now')}
|
||||
</button>
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => setSyncPrompt(null)}
|
||||
>
|
||||
{t('storage.sync.dismiss')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Terminal statuses render ALONGSIDE the button (not instead-of) —
|
||||
a completed/cancelled/errored backfill must stay re-runnable
|
||||
without a page reload. */}
|
||||
{backfill?.status === 'done' && (
|
||||
<p className={`${META} font-geist tabular-nums`} style={fs(11.5)}>
|
||||
{t('storage.sync.done', {
|
||||
copied: String(backfill.copied),
|
||||
deleted: String(backfill.deleted),
|
||||
failed: String(backfill.failed),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{backfill?.status === 'cancelled' && (
|
||||
<p className={META} style={fs(11.5)}>{t('storage.sync.cancelled')}</p>
|
||||
)}
|
||||
{backfill?.status === 'error' && (
|
||||
<p className="m-0 text-danger" style={fs(11.5)}>
|
||||
{t('storage.sync.error', { error: backfill.error ?? '' })}
|
||||
</p>
|
||||
)}
|
||||
{syncPrompt !== row.name && (
|
||||
<button type="button"
|
||||
className={ROW_BUTTON}
|
||||
style={fs(12, 'body')}
|
||||
onClick={() => handleStartBackfill(row)}
|
||||
>
|
||||
<RefreshCw size={13} strokeWidth={2.2} />
|
||||
{t('storage.sync.now')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{syncPrompt === row.name && (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-[12px] border border-edge-faint bg-surface-secondary px-3 py-2.5">
|
||||
<p className="m-0 min-w-0 flex-1 basis-48 text-content" style={fs(12.5, 'body')}>{t('storage.sync.prompt')}</p>
|
||||
<div className="flex flex-none items-center gap-1.5">
|
||||
<button type="button"
|
||||
className={ROW_BUTTON}
|
||||
style={fs(12, 'body')}
|
||||
onClick={() => setSyncPrompt(null)}
|
||||
>
|
||||
{t('storage.sync.dismiss')}
|
||||
</button>
|
||||
<button type="button"
|
||||
className={ROW_BUTTON_PRIMARY}
|
||||
style={fs(12, 'body')}
|
||||
onClick={() => handleStartBackfill(row)}
|
||||
>
|
||||
<RefreshCw size={13} strokeWidth={2.2} />
|
||||
{t('storage.sync.now')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -504,123 +626,119 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
const result = testResultFor(backend.name)
|
||||
const primary = backend.type === 'mirror' ? backend.options.primary : ''
|
||||
return (
|
||||
<div key={backend.name} data-testid={`storage-backend-${backend.name}`} className="rounded-xl border p-4 border-edge-secondary">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-content">{backend.name}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-secondary">
|
||||
{t('storage.type.mirror')}
|
||||
<div key={backend.name} data-testid={`storage-backend-${backend.name}`} className="px-3.5 py-3">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<span className={`${TILE} !bg-warning-soft !text-warning`}>
|
||||
<AlertTriangle size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => admin.test(backend)}>
|
||||
{t('storage.actions.test')}
|
||||
</button>
|
||||
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: backend.name, degenerate: true })}>
|
||||
{t('storage.actions.remove')}
|
||||
</button>
|
||||
<div className="min-w-0 flex-1 basis-48">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<span className="min-w-0 truncate font-geist font-semibold text-content" style={fs(13.5, 'body')}>{backend.name}</span>
|
||||
<StatusPill>{t('storage.type.mirror')}</StatusPill>
|
||||
</div>
|
||||
<p className="m-0 mt-0.5 text-warning" style={fs(11.5)}>{t(`storage.mirror.degenerate.${reason}`, { primary })}</p>
|
||||
</div>
|
||||
<div className="flex flex-none items-center gap-1.5">
|
||||
<button type="button" className={ROW_BUTTON} style={fs(12, 'body')} onClick={() => admin.test(backend)}>
|
||||
<PlugZap size={13} strokeWidth={2.2} />
|
||||
{t('storage.actions.test')}
|
||||
</button>
|
||||
<Tooltip label={t('storage.actions.remove')}>
|
||||
<button type="button" aria-label={t('storage.actions.remove')} className={`${SETTINGS_ICON_BUTTON} hover:!text-danger`} onClick={() => setConfirmRemove({ name: backend.name, degenerate: true })}>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs mt-1 text-content-faint">{t(`storage.mirror.degenerate.${reason}`, { primary })}</p>
|
||||
{result === 'running' ? (
|
||||
<p className="text-xs mt-2 text-content-faint">{t('storage.test.running')}</p>
|
||||
) : (
|
||||
<TestResult
|
||||
result={result as StorageTestResponse | undefined}
|
||||
okLabel={t('storage.test.ok')}
|
||||
failedLabel={t('storage.test.failed')}
|
||||
/>
|
||||
{(result === 'running' || result !== undefined) && (
|
||||
<div className={DETAILS}>
|
||||
{result === 'running' ? (
|
||||
<p className={`${META} inline-flex items-center gap-1.5`} style={fs(11.5)}>
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
{t('storage.test.running')}
|
||||
</p>
|
||||
) : (
|
||||
<TestResult
|
||||
result={result as StorageTestResponse | undefined}
|
||||
okLabel={t('storage.test.ok')}
|
||||
failedLabel={t('storage.test.failed')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</SettingRows>
|
||||
|
||||
{state.migrations.length > 0 && (
|
||||
<div className="space-y-3 mt-3">
|
||||
<SettingRows>
|
||||
{state.migrations.map((m) => (
|
||||
<div
|
||||
key={m.category}
|
||||
data-testid={`storage-migration-${m.category}`}
|
||||
className="rounded-xl border p-4 border-edge-secondary"
|
||||
className="flex items-start gap-3 px-3.5 py-3"
|
||||
>
|
||||
<p className="text-sm font-semibold text-content">{t(`storage.category.${m.category}`)}</p>
|
||||
{m.status === 'running' ? (
|
||||
<>
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.running', {
|
||||
category: t(`storage.category.${m.category}`),
|
||||
done: String(m.done),
|
||||
total: String(m.total),
|
||||
})}
|
||||
</p>
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary mt-1"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => void handleCancelMigration(m)}
|
||||
>
|
||||
{t('storage.migrate.cancel')}
|
||||
</button>
|
||||
</>
|
||||
) : m.status === 'done' ? (
|
||||
<>
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.done', { copied: String(m.copied), skipped: String(m.skipped) })}
|
||||
</p>
|
||||
{m.failed > 0 && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.doneFailures', { failed: String(m.failed) })}
|
||||
</p>
|
||||
)}
|
||||
{m.reclaimable && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.reclaimable', {
|
||||
objects: String(m.reclaimable.objects),
|
||||
size: formatBytes(m.reclaimable.bytes),
|
||||
from: m.from,
|
||||
<span className={TILE}>
|
||||
<ArrowRightLeft size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col items-start gap-1">
|
||||
<p className="m-0 font-semibold text-content" style={fs(13.5, 'body')}>{t(`storage.category.${m.category}`)}</p>
|
||||
{m.status === 'running' ? (
|
||||
<>
|
||||
<p className={`${META} font-geist tabular-nums`} style={fs(11.5)}>
|
||||
{t('storage.migrate.running', {
|
||||
category: t(`storage.category.${m.category}`),
|
||||
done: String(m.done),
|
||||
total: String(m.total),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : m.status === 'failed' ? (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.failed', { error: m.error ?? '' })}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs mt-1 text-content-faint">{t('storage.migrate.cancelled')}</p>
|
||||
)}
|
||||
<Progress done={m.done} total={m.total} />
|
||||
<button type="button"
|
||||
className={`${ROW_BUTTON} mt-1`}
|
||||
style={fs(12, 'body')}
|
||||
onClick={() => void handleCancelMigration(m)}
|
||||
>
|
||||
<X size={13} strokeWidth={2.2} />
|
||||
{t('storage.migrate.cancel')}
|
||||
</button>
|
||||
</>
|
||||
) : m.status === 'done' ? (
|
||||
<>
|
||||
<p className={`${META} font-geist tabular-nums`} style={fs(11.5)}>
|
||||
{t('storage.migrate.done', { copied: String(m.copied), skipped: String(m.skipped) })}
|
||||
</p>
|
||||
{m.failed > 0 && (
|
||||
<p className="m-0 text-danger" style={fs(11.5)}>
|
||||
{t('storage.migrate.doneFailures', { failed: String(m.failed) })}
|
||||
</p>
|
||||
)}
|
||||
{m.reclaimable && (
|
||||
<p className={META} style={fs(11.5)}>
|
||||
{t('storage.migrate.reclaimable', {
|
||||
objects: String(m.reclaimable.objects),
|
||||
size: formatBytes(m.reclaimable.bytes),
|
||||
from: m.from,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : m.status === 'failed' ? (
|
||||
<p className="m-0 text-danger" style={fs(11.5)}>
|
||||
{t('storage.migrate.failed', { error: m.error ?? '' })}
|
||||
</p>
|
||||
) : (
|
||||
<p className={META} style={fs(11.5)}>{t('storage.migrate.cancelled')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<BackendForm
|
||||
initial={editing.initial}
|
||||
backendNames={backendNames}
|
||||
mirror={editing.mirror}
|
||||
onCommit={commitBackend}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
) : (
|
||||
<button type="button"
|
||||
onClick={() =>
|
||||
setEditing({
|
||||
initial: null,
|
||||
originalName: null,
|
||||
mirror: { candidates: replicaCandidates(rows, null), initialTargets: [] },
|
||||
})
|
||||
}
|
||||
style={{
|
||||
padding: '8px 20px', borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
border: '2px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{t('storage.backends.add')}
|
||||
</button>
|
||||
</SettingRows>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={t('storage.categories.title')} icon={FolderTree}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{STORAGE_CATEGORIES.map((category) => {
|
||||
// The admin state's category record is exhaustive by schema contract.
|
||||
const stateEntry = state.categories[category]!
|
||||
@@ -628,16 +746,22 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
const changed = selectedPrimary !== primaryNameOf(state, draft, stateEntry.backend)
|
||||
const viaMirror = effective[category] !== selectedPrimary
|
||||
return (
|
||||
<div key={category} data-testid={`storage-category-${category}`}>
|
||||
<label className="block text-sm font-medium mb-1 text-content-secondary">
|
||||
{t(`storage.category.${category}`)}
|
||||
<span className="ml-2 px-1.5 py-0.5 rounded border font-mono font-normal text-xs border-edge text-content-faint">
|
||||
<div
|
||||
key={category}
|
||||
data-testid={`storage-category-${category}`}
|
||||
className={`flex min-w-0 flex-col gap-2.5 rounded-[14px] border bg-surface-card p-3.5 ${changed ? 'border-edge' : 'border-edge-faint'}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="m-0 font-semibold text-content" style={fs(13.5, 'body')}>{t(`storage.category.${category}`)}</p>
|
||||
<p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11.5)}>{t(`storage.categoryDesc.${category}`)}</p>
|
||||
</div>
|
||||
<code className="flex-none rounded-full bg-surface-tertiary px-2 py-[2px] font-geist font-semibold text-content-muted" style={fs(10.5)}>
|
||||
{category}
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs mb-1.5 text-content-faint">{t(`storage.categoryDesc.${category}`)}</p>
|
||||
</code>
|
||||
</div>
|
||||
{state.usage?.categories[category] && (
|
||||
<p className="text-xs mb-1.5 text-content-faint">
|
||||
<p className={`${META} font-geist tabular-nums`} style={fs(11.5)}>
|
||||
{t('storage.usage.line', {
|
||||
objects: String(state.usage.categories[category]!.objects),
|
||||
size: formatBytes(state.usage.categories[category]!.bytes),
|
||||
@@ -655,15 +779,14 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
: row.name,
|
||||
}))}
|
||||
size="sm"
|
||||
style={{ maxWidth: 320 }}
|
||||
/>
|
||||
{changed && (
|
||||
<p role="alert" className="text-xs mt-1 text-content-faint">
|
||||
<p role="alert" className="m-0 rounded-[10px] bg-warning-soft px-2.5 py-1.5 leading-snug text-warning" style={fs(11.5)}>
|
||||
{t('storage.categories.reassignWarning')}
|
||||
</p>
|
||||
)}
|
||||
{viaMirror && CACHE_CATEGORIES.includes(category) && (
|
||||
<p role="note" className="text-xs mt-1 text-content-faint">
|
||||
<p role="note" className="m-0 leading-snug text-content-faint" style={fs(11.5)}>
|
||||
{t('storage.mirror.cacheWarning')}
|
||||
</p>
|
||||
)}
|
||||
@@ -673,89 +796,111 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-2xl border border-edge-faint bg-surface-secondary px-4 py-3">
|
||||
<button type="button"
|
||||
onClick={save}
|
||||
disabled={!admin.dirty || admin.saving}
|
||||
style={{
|
||||
padding: '10px 20px', borderRadius: 10, cursor: admin.dirty && !admin.saving ? 'pointer' : 'default',
|
||||
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600,
|
||||
border: '2px solid var(--text-primary)', background: 'var(--bg-hover)', color: 'var(--text-primary)',
|
||||
opacity: admin.dirty && !admin.saving ? 1 : 0.5,
|
||||
}}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{admin.saving ? <Loader2 size={14} strokeWidth={2.2} className="animate-spin" /> : <Check size={14} strokeWidth={2.2} />}
|
||||
{t('storage.save')}
|
||||
</button>
|
||||
{admin.dirty && <span className="text-xs text-content-faint">{t('storage.unsaved')}</span>}
|
||||
{admin.dirty && <StatusPill tone="warning">{t('storage.unsaved')}</StatusPill>}
|
||||
{migrationQueue.length > 0 && (
|
||||
<SettingsHint className="min-w-0 flex-1">
|
||||
{t('storage.migrate.queued', { categories: categoryNames(t, migrationQueue.map((c) => c.category)) })}
|
||||
</SettingsHint>
|
||||
)}
|
||||
</div>
|
||||
{migrationQueue.length > 0 && (
|
||||
<p className="text-xs mt-2 text-content-faint">
|
||||
{t('storage.migrate.queued', { categories: categoryNames(t, migrationQueue.map((c) => c.category)) })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{migratePromptOpen && (
|
||||
<div className="rounded-lg border px-3 py-2 mt-2 border-edge bg-surface-secondary" role="alertdialog">
|
||||
<p className="text-sm text-content">{t('storage.migrate.promptTitle')}</p>
|
||||
{migrationCandidates.map((c) => (
|
||||
<p key={c.category} className="text-xs mt-1 text-content-secondary">
|
||||
{c.objects === null
|
||||
? t('storage.migrate.promptLineUnknown', {
|
||||
category: t(`storage.category.${c.category}`),
|
||||
from: c.from,
|
||||
to: c.to,
|
||||
})
|
||||
: t('storage.migrate.promptLine', {
|
||||
category: t(`storage.category.${c.category}`),
|
||||
objects: String(c.objects),
|
||||
size: formatBytes(c.bytes ?? 0),
|
||||
from: c.from,
|
||||
to: c.to,
|
||||
})}
|
||||
</p>
|
||||
))}
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-labelledby={migrateTitleId}
|
||||
className="mt-3 overflow-hidden rounded-2xl border border-edge-faint bg-surface-card shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b border-edge-faint px-4 py-3" style={{ background: NEUTRAL_TINT }}>
|
||||
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-card text-content-secondary shadow-sm">
|
||||
<ArrowRightLeft size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<p id={migrateTitleId} className="m-0 min-w-0 flex-1 font-bold text-content" style={fs(14, 'body')}>{t('storage.migrate.promptTitle')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 px-4 py-3">
|
||||
{migrationCandidates.map((c) => (
|
||||
<p key={c.category} className="m-0 font-geist tabular-nums text-content-secondary" style={fs(12.5, 'body')}>
|
||||
{c.objects === null
|
||||
? t('storage.migrate.promptLineUnknown', {
|
||||
category: t(`storage.category.${c.category}`),
|
||||
from: c.from,
|
||||
to: c.to,
|
||||
})
|
||||
: t('storage.migrate.promptLine', {
|
||||
category: t(`storage.category.${c.category}`),
|
||||
objects: String(c.objects),
|
||||
size: formatBytes(c.bytes ?? 0),
|
||||
from: c.from,
|
||||
to: c.to,
|
||||
})}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-edge-faint px-4 py-3">
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => void moveAndSave()}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(13, 'body')}
|
||||
onClick={() => setMigratePromptOpen(false)}
|
||||
>
|
||||
{t('storage.migrate.move')}
|
||||
{t('storage.migrate.promptCancel')}
|
||||
</button>
|
||||
<span className="flex-1" />
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(13, 'body')}
|
||||
onClick={() => void routeOnlySave()}
|
||||
>
|
||||
{t('storage.migrate.routeOnly')}
|
||||
</button>
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => setMigratePromptOpen(false)}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
style={fs(13, 'body')}
|
||||
onClick={() => void moveAndSave()}
|
||||
>
|
||||
{t('storage.migrate.promptCancel')}
|
||||
{t('storage.migrate.move')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{admin.saveError && (
|
||||
<div role="alert" className="text-sm mt-2 text-content">
|
||||
<p>{admin.saveError}</p>
|
||||
<div role="alert" className="mt-3 flex flex-wrap items-start gap-3 rounded-2xl border border-edge-faint bg-danger-soft px-4 py-3">
|
||||
<AlertTriangle size={16} strokeWidth={2.2} className="mt-0.5 flex-none text-danger" />
|
||||
<p className="m-0 min-w-0 flex-1 basis-56 break-words leading-normal text-content" style={fs(13, 'body')}>{admin.saveError}</p>
|
||||
{/* A 409 leaves the draft pinned to a version the server has moved
|
||||
past; "save again" can never clear it, so the banner offers the
|
||||
one action that can — see useStorageAdmin.discardDraft. */}
|
||||
{admin.saveConflict && (
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary mt-1"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
className={ROW_BUTTON}
|
||||
style={fs(12, 'body')}
|
||||
onClick={() => void admin.discardDraft()}
|
||||
>
|
||||
<RotateCcw size={13} strokeWidth={2.2} />
|
||||
{t('storage.discardAndReload')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<BackendForm
|
||||
initial={editing.initial}
|
||||
backendNames={backendNames}
|
||||
mirror={editing.mirror}
|
||||
onCommit={commitBackend}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={confirmRemove !== null}
|
||||
onClose={() => setConfirmRemove(null)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useId, useState } from 'react'
|
||||
import { HardDrive } from 'lucide-react'
|
||||
import {
|
||||
STORAGE_BACKEND_TYPES,
|
||||
STORAGE_BACKEND_TYPE_IDS,
|
||||
@@ -8,6 +9,18 @@ import {
|
||||
} from '@trek/shared'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import CustomSelect from '../../shared/CustomSelect'
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogShell,
|
||||
DialogTile,
|
||||
FooterSpacer,
|
||||
NEUTRAL_TINT,
|
||||
fs,
|
||||
} from '../../shared/DialogShell'
|
||||
import { EditorField, GRID_2, INPUT } from '../../shared/dialogParts'
|
||||
|
||||
type FieldValues = Record<string, string | string[]>
|
||||
|
||||
@@ -41,15 +54,17 @@ interface BackendFormProps {
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const LABEL_CLASS = 'block text-sm font-medium mb-1.5 text-content-secondary'
|
||||
const INPUT_CLASS =
|
||||
'mt-1.5 w-full px-3 py-2 border rounded-lg text-sm border-edge bg-surface-card text-content'
|
||||
/** A box of checkbox rows, the white list the settings cards use. */
|
||||
const CHECK_ROWS = 'divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card'
|
||||
const CHECK_ROW = 'flex cursor-pointer items-center gap-3 px-3.5 py-2.5 text-content hover:bg-surface-secondary'
|
||||
const CHECKBOX = 'h-4 w-4 flex-none cursor-pointer accent-accent'
|
||||
|
||||
/**
|
||||
* Renders whatever STORAGE_BACKEND_TYPES declares, by field kind. The raw
|
||||
* `mirror` type is hidden from the type select — mirrors are composed via the
|
||||
* Mirror-targets prop block and synthesized by the panels (the ref-kind
|
||||
* renderers below stay for future registry types).
|
||||
* renderers below stay for future registry types). Drawn as the planner's
|
||||
* editor dialog: head band, fields under eyebrows, the answers in the footer.
|
||||
*/
|
||||
export default function BackendForm({
|
||||
initial,
|
||||
@@ -59,6 +74,8 @@ export default function BackendForm({
|
||||
onCancel,
|
||||
}: BackendFormProps): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const labelId = useId()
|
||||
const fieldId = (key: string) => `${labelId}-${key}`
|
||||
const [type, setType] = useState<StorageBackendTypeId>(initial?.type ?? 'local')
|
||||
const [name, setName] = useState(initial?.name ?? '')
|
||||
const [values, setValues] = useState<FieldValues>(() => valuesOf(initial))
|
||||
@@ -104,8 +121,7 @@ export default function BackendForm({
|
||||
const value = values[field.key]
|
||||
if (field.kind === 'backend-ref') {
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<span className={LABEL_CLASS}>{t(field.labelKey)}</span>
|
||||
<EditorField key={field.key} label={t(field.labelKey)}>
|
||||
<CustomSelect
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(next) => setValue(field.key, String(next))}
|
||||
@@ -113,19 +129,19 @@ export default function BackendForm({
|
||||
placeholder={t(field.labelKey)}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</EditorField>
|
||||
)
|
||||
}
|
||||
if (field.kind === 'backend-ref-list') {
|
||||
const selected = Array.isArray(value) ? value : []
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<span className={LABEL_CLASS}>{t(field.labelKey)}</span>
|
||||
<div className="space-y-1">
|
||||
<EditorField key={field.key} label={t(field.labelKey)} className="col-span-full">
|
||||
<div className={CHECK_ROWS}>
|
||||
{refOptions.map((candidate) => (
|
||||
<label key={candidate} className="flex items-center gap-2 text-sm text-content">
|
||||
<label key={candidate} className={CHECK_ROW} style={fs(13, 'body')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={CHECKBOX}
|
||||
checked={selected.includes(candidate)}
|
||||
onChange={(e) =>
|
||||
setValue(
|
||||
@@ -136,84 +152,112 @@ export default function BackendForm({
|
||||
)
|
||||
}
|
||||
/>
|
||||
{candidate}
|
||||
<span className="min-w-0 truncate font-geist">{candidate}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</EditorField>
|
||||
)
|
||||
}
|
||||
const inputType = field.kind === 'secret' ? 'password' : field.kind === 'number' ? 'number' : 'text'
|
||||
return (
|
||||
<label key={field.key} className={LABEL_CLASS}>
|
||||
{t(field.labelKey)}
|
||||
<EditorField
|
||||
key={field.key}
|
||||
label={t(field.labelKey)}
|
||||
htmlFor={fieldId(field.key)}
|
||||
hint={field.helpKey ? t(field.helpKey) : undefined}
|
||||
>
|
||||
<input
|
||||
id={fieldId(field.key)}
|
||||
type={inputType}
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(e) => setValue(field.key, e.target.value)}
|
||||
placeholder={field.defaultValue !== undefined ? String(field.defaultValue) : ''}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className={INPUT_CLASS}
|
||||
className={`${INPUT} ${inputType === 'number' ? 'tabular-nums' : ''}`}
|
||||
/>
|
||||
{field.helpKey && <span className="block text-xs mt-1 font-normal text-content-faint">{t(field.helpKey)}</span>}
|
||||
</label>
|
||||
</EditorField>
|
||||
)
|
||||
}
|
||||
|
||||
const visibleCandidates = mirror ? mirror.candidates.filter((candidate) => candidate !== name.trim()) : []
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border p-4 space-y-4 border-edge bg-surface-card">
|
||||
<p className="text-sm font-semibold text-content">
|
||||
{initial ? t('storage.form.editTitle') : t('storage.form.addTitle')}
|
||||
</p>
|
||||
|
||||
<label className={LABEL_CLASS}>
|
||||
{t('storage.form.name')}
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className={INPUT_CLASS}
|
||||
<DialogShell
|
||||
onClose={onCancel}
|
||||
labelledBy={labelId}
|
||||
width="editor"
|
||||
align="top"
|
||||
discardGuard={{ name, type, values, targets }}
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={<DialogTile><HardDrive size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={onCancel}
|
||||
title={initial ? t('storage.form.editTitle') : t('storage.form.addTitle')}
|
||||
/>
|
||||
</label>
|
||||
{duplicate && (
|
||||
<p className="text-xs text-content-faint" role="alert">
|
||||
{t('storage.form.duplicateName', { name: name.trim() })}
|
||||
</p>
|
||||
)}
|
||||
}
|
||||
footer={
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={onCancel}>{t('storage.form.cancel')}</DialogButton>
|
||||
<DialogButton variant="primary" onClick={apply} disabled={!canApply}>
|
||||
{t('storage.form.apply')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
>
|
||||
<div className={GRID_2}>
|
||||
<div className="min-w-0">
|
||||
<EditorField label={t('storage.form.name')} htmlFor={fieldId('name')}>
|
||||
<input
|
||||
id={fieldId('name')}
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className={`${INPUT} font-geist`}
|
||||
/>
|
||||
</EditorField>
|
||||
{duplicate && (
|
||||
<p className="m-0 mt-1 text-danger" style={fs(11)} role="alert">
|
||||
{t('storage.form.duplicateName', { name: name.trim() })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className={LABEL_CLASS}>{t('storage.form.type')}</span>
|
||||
<CustomSelect
|
||||
value={type}
|
||||
onChange={(next) => {
|
||||
setType(next as StorageBackendTypeId)
|
||||
setValues({}) // a different type has different fields
|
||||
}}
|
||||
options={STORAGE_BACKEND_TYPE_IDS.filter((id) => id !== 'mirror').map((id) => ({
|
||||
value: id,
|
||||
label: t(`storage.type.${id}`),
|
||||
}))}
|
||||
size="sm"
|
||||
disabled={initial !== null}
|
||||
/>
|
||||
<EditorField label={t('storage.form.type')}>
|
||||
<CustomSelect
|
||||
value={type}
|
||||
onChange={(next) => {
|
||||
setType(next as StorageBackendTypeId)
|
||||
setValues({}) // a different type has different fields
|
||||
}}
|
||||
options={STORAGE_BACKEND_TYPE_IDS.filter((id) => id !== 'mirror').map((id) => ({
|
||||
value: id,
|
||||
label: t(`storage.type.${id}`),
|
||||
}))}
|
||||
size="sm"
|
||||
disabled={initial !== null}
|
||||
/>
|
||||
</EditorField>
|
||||
</div>
|
||||
|
||||
{fields.map(renderField)}
|
||||
{fields.length > 0 && <div className={GRID_2}>{fields.map(renderField)}</div>}
|
||||
|
||||
{mirror && (
|
||||
<div>
|
||||
<span className={LABEL_CLASS}>{t('storage.mirror.targets')}</span>
|
||||
<p className="text-xs mb-1 text-content-faint">{t('storage.mirror.targetsHelp')}</p>
|
||||
<div className="space-y-1">
|
||||
{mirror.candidates
|
||||
.filter((candidate) => candidate !== name.trim())
|
||||
.map((candidate) => (
|
||||
<label key={candidate} className="flex items-center gap-2 text-sm text-content">
|
||||
<DialogSection label={t('storage.mirror.targets')}>
|
||||
<p className="m-0 mb-2.5 leading-normal text-content-faint" style={fs(11.5)}>{t('storage.mirror.targetsHelp')}</p>
|
||||
{visibleCandidates.length > 0 && (
|
||||
<div className={CHECK_ROWS}>
|
||||
{visibleCandidates.map((candidate) => (
|
||||
<label key={candidate} className={CHECK_ROW} style={fs(13, 'body')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={CHECKBOX}
|
||||
checked={targets.includes(candidate)}
|
||||
onChange={(e) =>
|
||||
setTargets(
|
||||
@@ -223,42 +267,18 @@ export default function BackendForm({
|
||||
)
|
||||
}
|
||||
/>
|
||||
{candidate}
|
||||
<span className="min-w-0 truncate font-geist">{candidate}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{targets.length > 0 && (
|
||||
<p className="text-xs mt-1 text-content-faint" role="note">
|
||||
<p className="m-0 mt-2.5 rounded-[12px] bg-warning-soft px-3 py-2 leading-normal text-warning" style={fs(11.5, 'body')} role="note">
|
||||
{t('storage.mirror.latencyNote')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</DialogSection>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button"
|
||||
onClick={apply}
|
||||
disabled={!canApply}
|
||||
style={{
|
||||
padding: '8px 20px', borderRadius: 10, cursor: canApply ? 'pointer' : 'default',
|
||||
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600,
|
||||
border: '2px solid var(--text-primary)', background: 'var(--bg-hover)',
|
||||
color: 'var(--text-primary)', opacity: canApply ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
{t('storage.form.apply')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
padding: '8px 20px', borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
border: '2px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{t('storage.form.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,62 +1,105 @@
|
||||
import { BookOpen, Bug, Coffee, ExternalLink, Heart, Info, Lightbulb } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { BookOpen, Bug, Coffee, ExternalLink, Github, Heart, Info, Lightbulb } from 'lucide-react';
|
||||
import React, { type CSSProperties, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import Section from './Section';
|
||||
import { StatusPill } from './settingsKit';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
interface Props {
|
||||
appVersion: string;
|
||||
}
|
||||
|
||||
const DISCORD_PATH = 'M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z';
|
||||
|
||||
// The services' own colours: each tile takes its brand on hover and in its icon square.
|
||||
const BRAND = {
|
||||
kofi: '#ff5e5b', // theme-lint-disable: Ko-fi brand colour
|
||||
bmac: '#ffdd00', // theme-lint-disable: Buy Me a Coffee brand colour
|
||||
discord: '#5865F2', // theme-lint-disable: Discord brand colour
|
||||
bug: '#ef4444', // theme-lint-disable: the bug tile's signal red, a fixed brand-like mark
|
||||
idea: '#f59e0b', // theme-lint-disable: the idea tile's signal amber, a fixed brand-like mark
|
||||
wiki: '#6366f1', // theme-lint-disable: the wiki tile's signal indigo, a fixed brand-like mark
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* One outbound link as a tile: a tinted icon square, a title and a hint, the
|
||||
* external-link mark on the right. The border picks up the brand colour while
|
||||
* the pointer is on it.
|
||||
*/
|
||||
function LinkTile({ href, brand, icon, title, hint }: {
|
||||
href: string;
|
||||
brand?: string;
|
||||
icon?: ReactNode;
|
||||
title: ReactNode;
|
||||
hint: ReactNode;
|
||||
}) {
|
||||
const tint: CSSProperties | undefined = brand
|
||||
? { background: `color-mix(in srgb, ${brand} 12%, transparent)`, color: brand }
|
||||
: undefined;
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex min-w-0 items-center gap-3 overflow-hidden rounded-[14px] border border-edge bg-surface-card px-3.5 py-3 no-underline shadow-sm transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={brand ? (e) => {
|
||||
e.currentTarget.style.borderColor = brand;
|
||||
e.currentTarget.style.boxShadow = `0 0 0 1px ${brand}22`;
|
||||
} : undefined}
|
||||
onMouseLeave={brand ? (e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
} : undefined}
|
||||
>
|
||||
{icon && (
|
||||
<span
|
||||
className={`grid h-10 w-10 flex-none place-items-center rounded-[12px] ${brand ? '' : 'bg-surface-tertiary text-content-secondary'}`}
|
||||
style={tint}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold text-content" style={fs(13, 'body')}>{title}</div>
|
||||
<div className="truncate text-content-faint" style={fs(11.5)}>{hint}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="flex-none text-content-faint" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
const { t, locale } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const managed = useAuthStore((s) => s.managed);
|
||||
|
||||
return (
|
||||
<Section title={t('settings.about')} icon={Info}>
|
||||
<Section title={t('settings.about')} icon={Info} badge={<StatusPill>v{appVersion}</StatusPill>}>
|
||||
<style>{`
|
||||
@keyframes heartPulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.15); }
|
||||
}
|
||||
`}</style>
|
||||
<p
|
||||
className="text-content-secondary"
|
||||
style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.6, marginBottom: 6, marginTop: -4 }}
|
||||
>
|
||||
{/* The stock line calls TREK self-hosted and points at 'your own server'.
|
||||
Both are true for the reader who set it up and neither is for a customer
|
||||
of a hosted instance, so the mode picks the sentence rather than the
|
||||
wording being watered down for everybody. */}
|
||||
{t(managed ? 'settings.about.descriptionManaged' : 'settings.about.description')}
|
||||
</p>
|
||||
<p
|
||||
className="text-content-faint"
|
||||
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: 1.6, marginBottom: 16 }}
|
||||
>
|
||||
{t('settings.about.madeWith')}{' '}
|
||||
<Heart
|
||||
size={11}
|
||||
fill="#991b1b"
|
||||
stroke="#991b1b"
|
||||
style={{ display: 'inline-block', verticalAlign: '-1px', animation: 'heartPulse 1.5s ease-in-out infinite' }}
|
||||
/>{' '}
|
||||
{t('settings.about.madeBy')}{' '}
|
||||
<span
|
||||
className="bg-surface-tertiary text-content-faint"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: 99,
|
||||
padding: '1px 7px',
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 600,
|
||||
verticalAlign: '1px',
|
||||
}}
|
||||
>
|
||||
v{appVersion}
|
||||
</span>
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="m-0 leading-relaxed text-content-secondary" style={fs(13, 'body')}>
|
||||
{/* The stock line calls TREK self-hosted and points at 'your own server'.
|
||||
Both are true for the reader who set it up and neither is for a customer
|
||||
of a hosted instance, so the mode picks the sentence rather than the
|
||||
wording being watered down for everybody. */}
|
||||
{t(managed ? 'settings.about.descriptionManaged' : 'settings.about.description')}
|
||||
</p>
|
||||
<p className="m-0 leading-relaxed text-content-faint" style={fs(12, 'body')}>
|
||||
{t('settings.about.madeWith')}{' '}
|
||||
<Heart
|
||||
size={11}
|
||||
fill="currentColor"
|
||||
className="inline-block text-danger"
|
||||
style={{ verticalAlign: '-1px', animation: 'heartPulse 1.5s ease-in-out infinite' }}
|
||||
/>{' '}
|
||||
{t('settings.about.madeBy')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Ko-fi, Buy Me a Coffee, Discord, and the issue/discussion links assume
|
||||
the reader runs this install and can act on it. On a centrally
|
||||
@@ -65,237 +108,68 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
the source link below stay in both modes: AGPL §13 wants the source
|
||||
offered prominently to the people using it over a network, and that is
|
||||
not the part being trimmed here. */}
|
||||
{!managed && (<>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<a
|
||||
href="https://ko-fi.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#ff5e5b';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-[#ff5e5b15]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Coffee size={20} className="text-[#ff5e5b]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-content">Ko-fi</div>
|
||||
<div className="text-xs text-content-faint">{t('admin.github.support')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://buymeacoffee.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#ffdd00';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-[#ffdd0015]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Heart size={20} className="text-[#ffdd00]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-content">Buy Me a Coffee</div>
|
||||
<div className="text-xs text-content-faint">{t('admin.github.support')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.gg/NhZBDSd4qW"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#5865F2';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-[#5865F215]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-content">Discord</div>
|
||||
<div className="text-xs text-content-faint">Join the community</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<a
|
||||
href="https://github.com/liketrek/TREK/issues/new?template=bug_report.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#ef4444';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-[#ef444415]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Bug size={20} className="text-[#ef4444]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-content">{t('settings.about.reportBug')}</div>
|
||||
<div className="text-xs text-content-faint">{t('settings.about.reportBugHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/liketrek/TREK/discussions/new?category=feature-requests"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#f59e0b';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-[#f59e0b15]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Lightbulb size={20} className="text-[#f59e0b]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-content">{t('settings.about.featureRequest')}</div>
|
||||
<div className="text-xs text-content-faint">{t('settings.about.featureRequestHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/liketrek/TREK/wiki"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#6366f1';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-[#6366f115]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<BookOpen size={20} className="text-[#6366f1]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-content">Wiki</div>
|
||||
<div className="text-xs text-content-faint">{t('settings.about.wikiHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
</div>
|
||||
</>)}
|
||||
{!managed && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<LinkTile
|
||||
href="https://ko-fi.com/mauriceboe"
|
||||
brand={BRAND.kofi}
|
||||
icon={<Coffee size={19} />}
|
||||
title="Ko-fi"
|
||||
hint={t('admin.github.support')}
|
||||
/>
|
||||
<LinkTile
|
||||
href="https://buymeacoffee.com/mauriceboe"
|
||||
brand={BRAND.bmac}
|
||||
icon={<Heart size={19} />}
|
||||
title="Buy Me a Coffee"
|
||||
hint={t('admin.github.support')}
|
||||
/>
|
||||
<LinkTile
|
||||
href="https://discord.gg/NhZBDSd4qW"
|
||||
brand={BRAND.discord}
|
||||
icon={(
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d={DISCORD_PATH} />
|
||||
</svg>
|
||||
)}
|
||||
title="Discord"
|
||||
hint="Join the community"
|
||||
/>
|
||||
<LinkTile
|
||||
href="https://github.com/liketrek/TREK/issues/new?template=bug_report.yml"
|
||||
brand={BRAND.bug}
|
||||
icon={<Bug size={19} />}
|
||||
title={t('settings.about.reportBug')}
|
||||
hint={t('settings.about.reportBugHint')}
|
||||
/>
|
||||
<LinkTile
|
||||
href="https://github.com/liketrek/TREK/discussions/new?category=feature-requests"
|
||||
brand={BRAND.idea}
|
||||
icon={<Lightbulb size={19} />}
|
||||
title={t('settings.about.featureRequest')}
|
||||
hint={t('settings.about.featureRequestHint')}
|
||||
/>
|
||||
<LinkTile
|
||||
href="https://github.com/liketrek/TREK/wiki"
|
||||
brand={BRAND.wiki}
|
||||
icon={<BookOpen size={19} />}
|
||||
title="Wiki"
|
||||
hint={t('settings.about.wikiHint')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* What replaces the grids above. AGPL §13 asks for the source to be
|
||||
offered prominently to whoever uses the software over a network, and a
|
||||
customer of a hosted instance is exactly that reader. The support and
|
||||
bug-report links go; this does not. */}
|
||||
{managed && (
|
||||
<a
|
||||
<LinkTile
|
||||
href="https://github.com/liketrek/TREK"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-content">{t('settings.about.sourceTitle')}</div>
|
||||
<div className="text-xs text-content-faint">{t('settings.about.sourceHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
icon={<Github size={19} />}
|
||||
title={t('settings.about.sourceTitle')}
|
||||
hint={t('settings.about.sourceHint')}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
|
||||
@@ -822,7 +822,10 @@ describe('AccountTab – Modals and field edits', () => {
|
||||
await user.click(screen.getByText('Delete account'));
|
||||
await screen.findByText('Deletion not possible');
|
||||
|
||||
fireEvent.click(backdropOf('Deletion not possible'));
|
||||
// The shared dialog shell closes only on a press that starts and ends on the backdrop.
|
||||
const backdrop = backdropOf('Deletion not possible');
|
||||
fireEvent.mouseDown(backdrop);
|
||||
fireEvent.click(backdrop);
|
||||
|
||||
expect(screen.queryByText('Deletion not possible')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -862,21 +865,20 @@ describe('AccountTab – Modals and field edits', () => {
|
||||
expect(updateProfile).toHaveBeenCalledWith({ username: 'testuser', email: 'new@example.com' });
|
||||
});
|
||||
|
||||
it('FE-COMP-ACCOUNT-069: hover styling on the password and camera buttons is reverted on leave', () => {
|
||||
it('FE-COMP-ACCOUNT-069: hovering the password and camera buttons leaves no inline style behind', () => {
|
||||
render(<AccountTab />);
|
||||
|
||||
// Hover is CSS now; nothing is written onto the element that could stick after leave.
|
||||
const passwordBtn = screen.getByText('Update password').closest('button') as HTMLElement;
|
||||
fireEvent.mouseEnter(passwordBtn);
|
||||
expect(passwordBtn.style.background).toBe('var(--bg-hover)');
|
||||
fireEvent.mouseLeave(passwordBtn);
|
||||
expect(passwordBtn.style.background).toBe('var(--bg-card)');
|
||||
expect(passwordBtn.style.background).toBe('');
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const cameraBtn = fileInput.nextElementSibling as HTMLElement;
|
||||
expect(cameraBtn).toHaveAccessibleName('Upload Profile Picture');
|
||||
fireEvent.mouseEnter(cameraBtn);
|
||||
expect(cameraBtn.style.transform).toBe('scale(1.15)');
|
||||
fireEvent.mouseLeave(cameraBtn);
|
||||
expect(cameraBtn.style.transform).toBe('scale(1)');
|
||||
expect(cameraBtn.style.opacity).toBe('1');
|
||||
expect(cameraBtn.style.transform).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { User, Save, Lock, KeyRound, AlertTriangle, Shield, Camera, Trash2, Copy, Download, Printer } from 'lucide-react'
|
||||
import React, { useState, useEffect, useId } from 'react'
|
||||
import { User, Save, Lock, KeyRound, AlertTriangle, Shield, Camera, Trash2, Copy, Download, Printer, ShieldCheck } from 'lucide-react'
|
||||
import { useNavigate, useSearchParams } from 'react-router'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
@@ -12,9 +12,22 @@ import Section from './Section'
|
||||
import PasskeysSection from './PasskeysSection'
|
||||
import PasswordChecklist from '../shared/PasswordChecklist'
|
||||
import { passwordErrorKey } from '../../utils/passwordError'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import { EditorField, GRID_2, INPUT, PANEL } from '../shared/dialogParts'
|
||||
import {
|
||||
SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint, StatusPill,
|
||||
} from './settingsKit'
|
||||
|
||||
const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending'
|
||||
|
||||
/** The small turning ring a button shows while its request is on the way. */
|
||||
const SPINNER = 'h-3.5 w-3.5 flex-none animate-spin rounded-full border-2 border-current border-t-transparent'
|
||||
|
||||
/** The eyebrow over a block inside a card body. */
|
||||
const EYEBROW = 'm-0 font-geist font-bold uppercase tracking-[.08em] text-content-faint'
|
||||
|
||||
// Drops every trailing slash, like the `/\/+$/` replace it stands in for — as a scan,
|
||||
// because that pattern re-walks the slash run from each start position.
|
||||
const stripTrailingSlashes = (value: string): string => {
|
||||
@@ -30,6 +43,7 @@ export default function AccountTab(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const avatarInputRef = React.useRef<HTMLInputElement>(null)
|
||||
const blockedLabelId = useId()
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false)
|
||||
@@ -160,105 +174,194 @@ export default function AccountTab(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
const oidcIssuer = (user as UserWithOidc)?.oidc_issuer
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section title={t('settings.account')} icon={User}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.username')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
{/* Who is signed in: the picture with its two actions, the role and the SSO link. */}
|
||||
<div className="flex items-center gap-4 rounded-[14px] border border-edge-faint bg-surface-card px-4 py-3.5">
|
||||
<div className="relative flex-none">
|
||||
{user?.avatar_url ? (
|
||||
<img src={user.avatar_url} alt="" className="h-16 w-16 rounded-full object-cover ring-1 ring-edge-faint" />
|
||||
) : (
|
||||
<div className="grid h-16 w-16 place-items-center rounded-full bg-surface-tertiary font-bold text-content-secondary" style={fs(24, 'subtitle')}>
|
||||
{user?.username?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<input ref={avatarInputRef} type="file" accept="image/*" onChange={handleAvatarUpload} className="hidden" />
|
||||
<Tooltip label={t('settings.uploadAvatar')}>
|
||||
<button type="button"
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
aria-label={t('settings.uploadAvatar')}
|
||||
className="absolute -bottom-1 -right-1 grid h-7 w-7 place-items-center rounded-full border-2 border-[color:var(--bg-card)] bg-[color:var(--text-primary)] text-[color:var(--bg-card)] shadow-sm transition-transform hover:scale-110"
|
||||
>
|
||||
<Camera size={13} strokeWidth={2.2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{user?.avatar_url && (
|
||||
<Tooltip label={t('settings.removeAvatar')}>
|
||||
<button type="button"
|
||||
onClick={handleAvatarRemove}
|
||||
aria-label={t('settings.removeAvatar')}
|
||||
className="absolute -right-1 -top-1 grid h-6 w-6 place-items-center rounded-full border-2 border-[color:var(--bg-card)] bg-danger text-white shadow-sm transition-transform hover:scale-110" // theme-lint-disable: white glyph on the danger fill, as ConfirmDialog draws it
|
||||
>
|
||||
<Trash2 size={11} strokeWidth={2.2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="truncate font-bold text-content" style={fs(15, 'subtitle')}>{user?.username}</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{user?.role === 'admin'
|
||||
? <StatusPill tone="warning" icon={<Shield size={11} strokeWidth={2.4} />}>{t('settings.roleAdmin')}</StatusPill>
|
||||
: <StatusPill>{t('settings.roleUser')}</StatusPill>}
|
||||
{oidcIssuer && <StatusPill tone="accent">SSO</StatusPill>}
|
||||
</div>
|
||||
{oidcIssuer && (
|
||||
<p className="m-0 truncate text-content-faint" style={fs(11.5)}>
|
||||
{t('settings.oidcLinked')} {stripTrailingSlashes(oidcIssuer.replace('https://', ''))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Change Password */}
|
||||
{!oidcOnlyMode && (
|
||||
<div className="pt-4 mt-4 border-t border-edge-secondary">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">{t('settings.changePassword')}</label>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={e => setCurrentPassword(e.target.value)}
|
||||
placeholder={t('settings.currentPassword')}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<div className={GRID_2}>
|
||||
<EditorField label={t('settings.username')} htmlFor="account-username">
|
||||
<input
|
||||
id="account-username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
className={INPUT}
|
||||
/>
|
||||
</EditorField>
|
||||
<EditorField label={t('settings.email')} htmlFor="account-email">
|
||||
<input
|
||||
id="account-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className={INPUT}
|
||||
/>
|
||||
</EditorField>
|
||||
</div>
|
||||
|
||||
{/* The card's foot: the way out on the left, the save on the right, like a dialog's bar. */}
|
||||
<div className="-mx-4 -mb-4 flex items-center gap-2 border-t border-edge-faint bg-surface-card px-4 py-3" style={fs(13, 'body')}>
|
||||
<button type="button"
|
||||
onClick={async () => {
|
||||
if (user?.role === 'admin') {
|
||||
try {
|
||||
await adminApi.stats()
|
||||
const adminUsers = (await adminApi.users()).users.filter((u: { role: string }) => u.role === 'admin')
|
||||
if (adminUsers.length <= 1) {
|
||||
setShowDeleteConfirm('blocked')
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
setShowDeleteConfirm(true)
|
||||
}}
|
||||
className={SETTINGS_BUTTON_DANGER}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span className="hidden sm:inline">{t('settings.deleteAccount')}</span>
|
||||
<span className="sm:hidden">{t('common.delete')}</span>
|
||||
</button>
|
||||
<span className="flex-1" />
|
||||
<button type="button"
|
||||
onClick={saveProfile}
|
||||
disabled={saving}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
>
|
||||
{saving ? <span className={SPINNER} /> : <Save size={14} />}
|
||||
<span className="hidden sm:inline">{t('settings.saveProfile')}</span>
|
||||
<span className="sm:hidden">{t('common.save')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Change Password */}
|
||||
{!oidcOnlyMode && (
|
||||
<Section title={t('settings.changePassword')} icon={Lock}>
|
||||
<EditorField label={t('settings.currentPassword')} htmlFor="account-current-password">
|
||||
<input
|
||||
id="account-current-password"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={e => setCurrentPassword(e.target.value)}
|
||||
placeholder={t('settings.currentPassword')}
|
||||
className={INPUT}
|
||||
/>
|
||||
</EditorField>
|
||||
<div className={GRID_2}>
|
||||
<EditorField label={t('settings.newPassword')} htmlFor="account-new-password">
|
||||
<input
|
||||
id="account-new-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
placeholder={t('settings.newPassword')}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
className={INPUT}
|
||||
/>
|
||||
<PasswordChecklist password={newPassword} />
|
||||
</EditorField>
|
||||
<EditorField label={t('settings.confirmPassword')} htmlFor="account-confirm-password">
|
||||
<input
|
||||
id="account-confirm-password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
placeholder={t('settings.confirmPassword')}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
className={INPUT}
|
||||
/>
|
||||
<button type="button"
|
||||
onClick={async () => {
|
||||
if (!currentPassword) return toast.error(t('settings.currentPasswordRequired'))
|
||||
if (!newPassword) return toast.error(t('settings.passwordRequired'))
|
||||
const weak = passwordErrorKey(newPassword)
|
||||
if (weak) return toast.error(t(weak))
|
||||
if (newPassword !== confirmPassword) return toast.error(t('settings.passwordMismatch'))
|
||||
try {
|
||||
await authApi.changePassword({ current_password: currentPassword, new_password: newPassword })
|
||||
toast.success(t('settings.passwordChanged'))
|
||||
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('')
|
||||
await loadUser({ silent: true })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors border border-edge bg-surface-card text-content-secondary"
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<Lock size={14} />
|
||||
{t('settings.updatePassword')}
|
||||
</button>
|
||||
</div>
|
||||
</EditorField>
|
||||
</div>
|
||||
<PasswordChecklist password={newPassword} />
|
||||
<div className="flex justify-end" style={fs(13, 'body')}>
|
||||
<button type="button"
|
||||
onClick={async () => {
|
||||
if (!currentPassword) return toast.error(t('settings.currentPasswordRequired'))
|
||||
if (!newPassword) return toast.error(t('settings.passwordRequired'))
|
||||
const weak = passwordErrorKey(newPassword)
|
||||
if (weak) return toast.error(t(weak))
|
||||
if (newPassword !== confirmPassword) return toast.error(t('settings.passwordMismatch'))
|
||||
try {
|
||||
await authApi.changePassword({ current_password: currentPassword, new_password: newPassword })
|
||||
toast.success(t('settings.passwordChanged'))
|
||||
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('')
|
||||
await loadUser({ silent: true })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}}
|
||||
className={SETTINGS_BUTTON}
|
||||
>
|
||||
<Lock size={14} />
|
||||
{t('settings.updatePassword')}
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* MFA */}
|
||||
<Section title={t('settings.mfa.title')} icon={KeyRound}>
|
||||
{mfaRequiredByPolicy && (
|
||||
<div className="flex gap-3 rounded-[12px] bg-warning-soft px-3.5 py-3 text-content" style={fs(12.5, 'body')}>
|
||||
<AlertTriangle size={16} className="mt-px flex-none text-warning" />
|
||||
<p className="m-0 leading-relaxed">{t('settings.mfa.requiredByPolicy')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MFA */}
|
||||
<div className="pt-4 mt-4 border-t border-edge-secondary">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<KeyRound className="w-5 h-5 text-content-secondary" />
|
||||
<h3 className="font-semibold text-base m-0 text-content">{t('settings.mfa.title')}</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{mfaRequiredByPolicy && (
|
||||
<div className="flex gap-3 p-3 rounded-lg border text-sm bg-surface-secondary border-edge text-content">
|
||||
<AlertTriangle className="w-5 h-5 flex-shrink-0 text-amber-600" />
|
||||
<p className="m-0 leading-relaxed">{t('settings.mfa.requiredByPolicy')}</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm m-0 text-content-muted" style={{ lineHeight: 1.5 }}>{t('settings.mfa.description')}</p>
|
||||
{demoMode ? (
|
||||
<p className="text-sm text-amber-700 m-0">{t('settings.mfa.demoBlocked')}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium m-0 text-content-secondary">
|
||||
{user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')}
|
||||
</p>
|
||||
|
||||
{!user?.mfa_enabled && !mfaQr && (
|
||||
<SettingsHint>{t('settings.mfa.description')}</SettingsHint>
|
||||
{demoMode ? (
|
||||
<p className="m-0 font-medium text-warning" style={fs(12.5, 'body')}>{t('settings.mfa.demoBlocked')}</p>
|
||||
) : (
|
||||
<>
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
label={user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')}
|
||||
control={!user?.mfa_enabled && !mfaQr ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={mfaLoading}
|
||||
@@ -275,30 +378,50 @@ export default function AccountTab(): React.ReactElement {
|
||||
setMfaLoading(false)
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors border border-edge bg-surface-card text-content"
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{mfaLoading ? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" /> : <KeyRound size={14} />}
|
||||
{mfaLoading ? <span className={SPINNER} /> : <KeyRound size={14} />}
|
||||
{t('settings.mfa.setup')}
|
||||
</button>
|
||||
)}
|
||||
) : user?.mfa_enabled ? (
|
||||
<span className="grid h-8 w-8 place-items-center rounded-full bg-success-soft text-success">
|
||||
<ShieldCheck size={16} strokeWidth={2.2} />
|
||||
</span>
|
||||
) : undefined}
|
||||
/>
|
||||
</SettingRows>
|
||||
|
||||
{!user?.mfa_enabled && mfaQr && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-content-muted">{t('settings.mfa.scanQr')}</p>
|
||||
<div className="rounded-lg border mx-auto block overflow-hidden border-edge" style={{ width: 'fit-content' }} dangerouslySetInnerHTML={{ __html: mfaQr! }} />
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1 text-content-secondary">{t('settings.mfa.secretLabel')}</label>
|
||||
<code className="block text-xs p-2 rounded break-all bg-surface-hover text-content">{mfaSecret}</code>
|
||||
</div>
|
||||
{!user?.mfa_enabled && mfaQr && (
|
||||
<div className={PANEL}>
|
||||
<SettingsHint>{t('settings.mfa.scanQr')}</SettingsHint>
|
||||
<div className="flex flex-wrap items-start gap-4">
|
||||
{/* A QR code needs its light quiet zone in either theme to scan. */}
|
||||
<div
|
||||
className="flex-none overflow-hidden rounded-[12px] border border-edge-faint p-1.5"
|
||||
style={{ background: '#ffffff' }} // theme-lint-disable: a QR code needs a white quiet zone to scan
|
||||
dangerouslySetInnerHTML={{ __html: mfaQr! }}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 basis-56 flex-col gap-3">
|
||||
<EditorField label={t('settings.mfa.secretLabel')}>
|
||||
<code className="block break-all rounded-[10px] border border-edge-faint bg-surface-tertiary px-3 py-2 font-geist tracking-wide text-content" style={fs(12.5, 'body')}>{mfaSecret}</code>
|
||||
</EditorField>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={mfaSetupCode}
|
||||
onChange={e => setMfaSetupCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||
placeholder={t('settings.mfa.codePlaceholder')}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
className={`${INPUT} font-geist tabular-nums tracking-[.2em]`}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap justify-end gap-2" style={fs(13, 'body')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setMfaQr(null); setMfaSecret(null); setMfaSetupCode('') }}
|
||||
className={SETTINGS_BUTTON}
|
||||
>
|
||||
{t('settings.mfa.cancelSetup')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={mfaLoading || mfaSetupCode.length < 6}
|
||||
@@ -322,289 +445,151 @@ export default function AccountTab(): React.ReactElement {
|
||||
setMfaLoading(false)
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:opacity-50"
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
>
|
||||
{t('settings.mfa.enable')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setMfaQr(null); setMfaSecret(null); setMfaSetupCode('') }}
|
||||
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary"
|
||||
>
|
||||
{t('settings.mfa.cancelSetup')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user?.mfa_enabled && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-content-secondary">{t('settings.mfa.disableTitle')}</p>
|
||||
<p className="text-xs text-content-muted">{t('settings.mfa.disableHint')}</p>
|
||||
<input
|
||||
type="password"
|
||||
value={mfaDisablePwd}
|
||||
onChange={e => setMfaDisablePwd(e.target.value)}
|
||||
placeholder={t('settings.currentPassword')}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={mfaDisableCode}
|
||||
onChange={e => setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||
placeholder={t('settings.mfa.codePlaceholder')}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={mfaLoading || !mfaDisablePwd || mfaDisableCode.length < 6}
|
||||
onClick={async () => {
|
||||
setMfaLoading(true)
|
||||
try {
|
||||
await authApi.mfaDisable({ password: mfaDisablePwd, code: mfaDisableCode })
|
||||
toast.success(t('settings.mfa.toastDisabled'))
|
||||
setMfaDisablePwd('')
|
||||
setMfaDisableCode('')
|
||||
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
|
||||
setBackupCodes(null)
|
||||
await loadUser({ silent: true })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setMfaLoading(false)
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-red-600 border border-red-200 hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
{t('settings.mfa.disable')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backupCodes && backupCodes.length > 0 && (
|
||||
<div className="space-y-3 p-3 rounded-lg border border-edge bg-surface-hover">
|
||||
<p className="text-sm font-semibold m-0 text-content">{t('settings.mfa.backupTitle')}</p>
|
||||
<p className="text-xs m-0 text-content-muted">{t('settings.mfa.backupDescription')}</p>
|
||||
<pre className="text-xs m-0 p-2 rounded border overflow-auto border-edge bg-surface-card text-content" style={{ maxHeight: 220 }}>{backupCodesText}</pre>
|
||||
<p className="text-xs m-0 text-[#b45309]">{t('settings.mfa.backupWarning')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={copyBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5 border-edge text-content-secondary">
|
||||
<Copy size={13} /> {t('settings.mfa.backupCopy')}
|
||||
</button>
|
||||
<button type="button" onClick={downloadBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5 border-edge text-content-secondary">
|
||||
<Download size={13} /> {t('settings.mfa.backupDownload')}
|
||||
</button>
|
||||
<button type="button" onClick={printBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5 border-edge text-content-secondary">
|
||||
<Printer size={13} /> {t('settings.mfa.backupPrint')}
|
||||
</button>
|
||||
<button type="button" onClick={dismissBackupCodes} className="px-3 py-2 rounded-lg text-xs border border-edge text-content-secondary">
|
||||
{t('common.ok')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Passkeys */}
|
||||
<PasskeysSection demoMode={demoMode} />
|
||||
|
||||
{/* Avatar */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
{user?.avatar_url ? (
|
||||
<img src={user.avatar_url} alt="" style={{ width: 64, height: 64, borderRadius: '50%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<div className="bg-surface-hover text-content-secondary" style={{
|
||||
width: 64, height: 64, borderRadius: '50%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 'calc(24px * var(--fs-scale-title, 1))', fontWeight: 700,
|
||||
}}>
|
||||
{user?.username?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<input ref={avatarInputRef} type="file" accept="image/*" onChange={handleAvatarUpload} style={{ display: 'none' }} />
|
||||
<button type="button"
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
style={{
|
||||
position: 'absolute', bottom: -3, right: -3,
|
||||
width: 28, height: 28, borderRadius: '50%',
|
||||
background: 'var(--text-primary)', color: 'var(--bg-card)',
|
||||
border: '2px solid var(--bg-card)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', padding: 0, transition: 'transform 0.15s, opacity 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.15)'; e.currentTarget.style.opacity = '0.85' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.opacity = '1' }}
|
||||
>
|
||||
<Camera size={14} />
|
||||
</button>
|
||||
{user?.avatar_url && (
|
||||
<button type="button"
|
||||
onClick={handleAvatarRemove}
|
||||
className="bg-[#ef4444] text-white"
|
||||
style={{
|
||||
position: 'absolute', top: -2, right: -2,
|
||||
width: 20, height: 20, borderRadius: '50%',
|
||||
border: '2px solid var(--bg-card)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', padding: 0,
|
||||
}}
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-sm text-content-muted">
|
||||
<span className="font-medium text-content-secondary" style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
{user?.role === 'admin' ? <><Shield size={13} /> {t('settings.roleAdmin')}</> : t('settings.roleUser')}
|
||||
</span>
|
||||
{(user as UserWithOidc)?.oidc_issuer && (
|
||||
<span className="bg-[#dbeafe] text-[#1d4ed8]" style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '1px 8px', borderRadius: 99,
|
||||
marginLeft: 6,
|
||||
}}>
|
||||
SSO
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(user as UserWithOidc)?.oidc_issuer && (
|
||||
<p className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: -2 }}>
|
||||
{t('settings.oidcLinked')} {stripTrailingSlashes((user as UserWithOidc).oidc_issuer!.replace('https://', ''))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12 }}>
|
||||
<button type="button"
|
||||
onClick={saveProfile}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
>
|
||||
{saving ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
<span className="hidden sm:inline">{t('settings.saveProfile')}</span>
|
||||
<span className="sm:hidden">{t('common.save')}</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={async () => {
|
||||
if (user?.role === 'admin') {
|
||||
try {
|
||||
await adminApi.stats()
|
||||
const adminUsers = (await adminApi.users()).users.filter((u: { role: string }) => u.role === 'admin')
|
||||
if (adminUsers.length <= 1) {
|
||||
setShowDeleteConfirm('blocked')
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
setShowDeleteConfirm(true)
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors text-red-500 hover:bg-red-50 border border-[#fecaca]"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span className="hidden sm:inline">{t('settings.deleteAccount')}</span>
|
||||
<span className="sm:hidden">{t('common.delete')}</span>
|
||||
</button>
|
||||
</div>
|
||||
{user?.mfa_enabled && (
|
||||
<div className={PANEL}>
|
||||
<div>
|
||||
<p className={EYEBROW} style={fs(10)}>{t('settings.mfa.disableTitle')}</p>
|
||||
<SettingsHint className="mt-1">{t('settings.mfa.disableHint')}</SettingsHint>
|
||||
</div>
|
||||
<div className={GRID_2}>
|
||||
<input
|
||||
type="password"
|
||||
value={mfaDisablePwd}
|
||||
onChange={e => setMfaDisablePwd(e.target.value)}
|
||||
placeholder={t('settings.currentPassword')}
|
||||
aria-label={t('settings.currentPassword')}
|
||||
className={INPUT}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={mfaDisableCode}
|
||||
onChange={e => setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||
placeholder={t('settings.mfa.codePlaceholder')}
|
||||
aria-label={t('settings.mfa.codePlaceholder')}
|
||||
className={`${INPUT} font-geist tabular-nums`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end" style={fs(13, 'body')}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={mfaLoading || !mfaDisablePwd || mfaDisableCode.length < 6}
|
||||
onClick={async () => {
|
||||
setMfaLoading(true)
|
||||
try {
|
||||
await authApi.mfaDisable({ password: mfaDisablePwd, code: mfaDisableCode })
|
||||
toast.success(t('settings.mfa.toastDisabled'))
|
||||
setMfaDisablePwd('')
|
||||
setMfaDisableCode('')
|
||||
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
|
||||
setBackupCodes(null)
|
||||
await loadUser({ silent: true })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setMfaLoading(false)
|
||||
}
|
||||
}}
|
||||
className={SETTINGS_BUTTON_DANGER}
|
||||
>
|
||||
{t('settings.mfa.disable')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backupCodes && backupCodes.length > 0 && (
|
||||
<div className={PANEL}>
|
||||
<div>
|
||||
<p className="m-0 font-semibold text-content" style={fs(13, 'body')}>{t('settings.mfa.backupTitle')}</p>
|
||||
<SettingsHint className="mt-0.5">{t('settings.mfa.backupDescription')}</SettingsHint>
|
||||
</div>
|
||||
<pre className="m-0 max-h-[220px] overflow-auto rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3 font-geist tabular-nums leading-relaxed tracking-wide text-content" style={fs(12.5, 'body')}>{backupCodesText}</pre>
|
||||
<p className="m-0 flex items-center gap-1.5 font-medium text-warning" style={fs(11.5)}>
|
||||
<AlertTriangle size={13} className="flex-none" />{t('settings.mfa.backupWarning')}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2" style={fs(12.5, 'body')}>
|
||||
<button type="button" onClick={copyBackupCodes} className={SETTINGS_BUTTON}>
|
||||
<Copy size={13} /> {t('settings.mfa.backupCopy')}
|
||||
</button>
|
||||
<button type="button" onClick={downloadBackupCodes} className={SETTINGS_BUTTON}>
|
||||
<Download size={13} /> {t('settings.mfa.backupDownload')}
|
||||
</button>
|
||||
<button type="button" onClick={printBackupCodes} className={SETTINGS_BUTTON}>
|
||||
<Printer size={13} /> {t('settings.mfa.backupPrint')}
|
||||
</button>
|
||||
<span className="flex-1" />
|
||||
<button type="button" onClick={dismissBackupCodes} className={SETTINGS_BUTTON_PRIMARY}>
|
||||
{t('common.ok')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Passkeys */}
|
||||
<PasskeysSection demoMode={demoMode} />
|
||||
|
||||
{/* Delete Account Blocked */}
|
||||
{showDeleteConfirm === 'blocked' && (
|
||||
<div className="bg-[rgba(0,0,0,0.5)]" style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
backdropFilter: 'blur(4px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
|
||||
}} role="presentation" onClick={e => { if (e.target === e.currentTarget) setShowDeleteConfirm(false) }}>
|
||||
<div className="bg-surface-card" style={{
|
||||
borderRadius: 16, padding: '28px 24px',
|
||||
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||
<div className="bg-[#fef3c7]" style={{ width: 36, height: 36, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Shield size={18} className="text-[#d97706]" />
|
||||
</div>
|
||||
<h3 className="text-content" style={{ margin: 0, fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700 }}>{t('settings.deleteBlockedTitle')}</h3>
|
||||
</div>
|
||||
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.6, margin: '0 0 20px' }}>
|
||||
{t('settings.deleteBlockedMessage')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button type="button"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="border border-edge bg-surface-card text-content-secondary"
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('common.ok') || 'OK'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogShell
|
||||
open={showDeleteConfirm === 'blocked'}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
labelledBy={blockedLabelId}
|
||||
width="narrow"
|
||||
header={(
|
||||
<DialogHeader
|
||||
tile={<DialogTile><Shield size={20} strokeWidth={1.9} className="text-warning" /></DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={blockedLabelId}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
title={t('settings.deleteBlockedTitle')}
|
||||
/>
|
||||
)}
|
||||
footer={(
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton variant="primary" onClick={() => setShowDeleteConfirm(false)}>
|
||||
{t('common.ok') || 'OK'}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
)}
|
||||
>
|
||||
<p className="m-0 leading-relaxed text-content-secondary" style={fs(13.5, 'body')}>
|
||||
{t('settings.deleteBlockedMessage')}
|
||||
</p>
|
||||
</DialogShell>
|
||||
|
||||
{/* Delete Account Confirm */}
|
||||
{showDeleteConfirm === true && (
|
||||
<div className="bg-[rgba(0,0,0,0.5)]" style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
backdropFilter: 'blur(4px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
|
||||
}} role="presentation" onClick={e => { if (e.target === e.currentTarget) setShowDeleteConfirm(false) }}>
|
||||
<div className="bg-surface-card" style={{
|
||||
borderRadius: 16, padding: '28px 24px',
|
||||
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||
<div className="bg-[#fef2f2]" style={{ width: 36, height: 36, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Trash2 size={18} className="text-[#ef4444]" />
|
||||
</div>
|
||||
<h3 className="text-content" style={{ margin: 0, fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700 }}>{t('settings.deleteAccountTitle')}</h3>
|
||||
</div>
|
||||
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.6, margin: '0 0 20px' }}>
|
||||
{t('settings.deleteAccountWarning')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button type="button"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="border border-edge bg-surface-card text-content-secondary"
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await authApi.deleteOwnAccount()
|
||||
logout()
|
||||
navigate('/login', { state: { noRedirect: true } })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
setShowDeleteConfirm(false)
|
||||
}
|
||||
}}
|
||||
className="bg-[#ef4444] text-white"
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600,
|
||||
border: 'none',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('settings.deleteAccountConfirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
isOpen={showDeleteConfirm === true}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await authApi.deleteOwnAccount()
|
||||
logout()
|
||||
navigate('/login', { state: { noRedirect: true } })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
setShowDeleteConfirm(false)
|
||||
}
|
||||
}}
|
||||
title={t('settings.deleteAccountTitle')}
|
||||
message={t('settings.deleteAccountWarning')}
|
||||
confirmLabel={t('settings.deleteAccountConfirm')}
|
||||
danger
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,11 +31,9 @@ function renderSection() {
|
||||
);
|
||||
}
|
||||
|
||||
// The desktop ToggleSwitch has no accessible name, so reach it through the label
|
||||
// text it sits next to.
|
||||
// Each switch carries its row's label as its accessible name.
|
||||
function toggleNextTo(label: string): HTMLElement {
|
||||
const row = screen.getByText(label).parentElement as HTMLElement;
|
||||
return row.querySelector('button') as HTMLElement;
|
||||
return screen.getByRole('button', { name: label });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Plane, Save } from 'lucide-react'
|
||||
import { Loader2, Plane, Save } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { airtrailApi } from '../../api/client'
|
||||
import AirTrailIcon from '../shared/AirTrailIcon'
|
||||
import Section from './Section'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint, StatusPill } from './settingsKit'
|
||||
import { EditorField, GRID_2, INPUT } from '../shared/dialogParts'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
|
||||
/**
|
||||
* Settings → Integrations → AirTrail. Per-user connection to a self-hosted
|
||||
* AirTrail instance (URL + Bearer API key). Mirrors the photo-provider (Immich)
|
||||
* connection layout: stacked fields, a toggle, then Save / Test-connection with
|
||||
* a status badge. The key is stored encrypted and never prefilled.
|
||||
* connection layout: the two fields side by side, the switches as rows, then
|
||||
* Save / Test-connection, with the connection state as a pill in the card's band. The key is stored encrypted and never prefilled.
|
||||
*/
|
||||
export default function AirTrailConnectionSection(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
@@ -55,8 +58,9 @@ export default function AirTrailConnectionSection(): React.ReactElement {
|
||||
setApiKey('')
|
||||
if (d?.warning) toast.warning(d.warning)
|
||||
else toast.success(t('settings.airtrail.toast.saved'))
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.error || t('settings.airtrail.toast.saveError'))
|
||||
} catch (err) {
|
||||
const reason = (err as { response?: { data?: { error?: string } } } | null)?.response?.data?.error
|
||||
toast.error(reason || t('settings.airtrail.toast.saveError'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -78,81 +82,76 @@ export default function AirTrailConnectionSection(): React.ReactElement {
|
||||
|
||||
const canSave = !!url.trim() && (connected || !!apiKey.trim())
|
||||
|
||||
const fieldId = (name: string) => `airtrail-${name}`
|
||||
|
||||
return (
|
||||
<Section title={t('settings.airtrail.title')} icon={AirTrailIcon}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.airtrail.url')}</label>
|
||||
<Section
|
||||
title={t('settings.airtrail.title')}
|
||||
icon={AirTrailIcon}
|
||||
badge={
|
||||
<StatusPill tone={connected ? 'success' : 'neutral'} icon={<span className="h-1.5 w-1.5 rounded-full bg-current" />}>
|
||||
{connected ? t('settings.airtrail.connected') : t('settings.airtrail.notConnected')}
|
||||
</StatusPill>
|
||||
}
|
||||
>
|
||||
<div className={GRID_2}>
|
||||
<EditorField label={t('settings.airtrail.url')} htmlFor={fieldId('url')}>
|
||||
<input
|
||||
id={fieldId('url')}
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
placeholder="https://airtrail.example.com"
|
||||
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
|
||||
className={INPUT}
|
||||
/>
|
||||
</div>
|
||||
</EditorField>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.airtrail.apiKey')}</label>
|
||||
<EditorField label={t('settings.airtrail.apiKey')} htmlFor={fieldId('key')} hint={t('settings.airtrail.apiKeyHint')}>
|
||||
<input
|
||||
id={fieldId('key')}
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={connected && !apiKey ? '••••••••' : t('settings.airtrail.apiKeyPlaceholder')}
|
||||
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
|
||||
className={INPUT}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-500">{t('settings.airtrail.apiKeyHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<ToggleSwitch on={allowInsecureTls} onToggle={() => setAllowInsecureTls(v => !v)} />
|
||||
<span className="text-sm font-medium text-slate-700">{t('settings.airtrail.allowInsecureTls')}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ToggleSwitch on={writeEnabled} onToggle={() => setWriteEnabled(v => !v)} />
|
||||
<span className="text-sm font-medium text-slate-700">{t('settings.airtrail.writeBack')}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500">{t('settings.airtrail.writeBackHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || loading || !canSave}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
>
|
||||
<Save className="w-4 h-4" /> {t('common.save')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={handleTest}
|
||||
disabled={testing || loading || !url.trim()}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50"
|
||||
>
|
||||
{testing ? (
|
||||
<div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
|
||||
) : (
|
||||
<Plane className="w-4 h-4" />
|
||||
)}
|
||||
{t('settings.airtrail.test.button')}
|
||||
</button>
|
||||
{connected ? (
|
||||
<span className="basis-full sm:basis-auto text-xs font-medium text-green-600 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
||||
{t('settings.airtrail.connected')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="basis-full sm:basis-auto text-xs font-medium text-slate-400 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-slate-300 rounded-full" />
|
||||
{t('settings.airtrail.notConnected')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-500">{t('settings.airtrail.hint')}</p>
|
||||
</EditorField>
|
||||
</div>
|
||||
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
label={t('settings.airtrail.allowInsecureTls')}
|
||||
control={<ToggleSwitch on={allowInsecureTls} onToggle={() => setAllowInsecureTls(v => !v)} label={t('settings.airtrail.allowInsecureTls')} />}
|
||||
/>
|
||||
<SettingRow
|
||||
label={t('settings.airtrail.writeBack')}
|
||||
hint={t('settings.airtrail.writeBackHint')}
|
||||
control={<ToggleSwitch on={writeEnabled} onToggle={() => setWriteEnabled(v => !v)} label={t('settings.airtrail.writeBack')} />}
|
||||
/>
|
||||
</SettingRows>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2" style={fs(13, 'body')}>
|
||||
<button type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || loading || !canSave}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
>
|
||||
<Save size={14} strokeWidth={2.2} /> {t('common.save')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={handleTest}
|
||||
disabled={testing || loading || !url.trim()}
|
||||
className={SETTINGS_BUTTON}
|
||||
>
|
||||
{testing
|
||||
? <Loader2 size={14} strokeWidth={2.2} className="animate-spin" />
|
||||
: <Plane size={14} strokeWidth={2.2} />}
|
||||
{t('settings.airtrail.test.button')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SettingsHint>{t('settings.airtrail.hint')}</SettingsHint>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -155,14 +155,14 @@ describe('ApiKeysSection', () => {
|
||||
const user = userEvent.setup();
|
||||
renderSection();
|
||||
|
||||
await user.click(await screen.findByTitle('Delete key'));
|
||||
await user.click(await screen.findByRole('button', { name: 'Delete key' }));
|
||||
expect(deleted).toBe(0);
|
||||
expect(screen.getByText(/stops working immediately/)).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^Cancel$/ }));
|
||||
expect(deleted).toBe(0);
|
||||
|
||||
await user.click(await screen.findByTitle('Delete key'));
|
||||
await user.click(await screen.findByRole('button', { name: 'Delete key' }));
|
||||
const [, confirm] = screen.getAllByRole('button', { name: /Delete key/ });
|
||||
await user.click(confirm);
|
||||
await waitFor(() => expect(deleted).toBe(1));
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import React, { useEffect, useId, useRef, useState } from 'react'
|
||||
import {
|
||||
KeyRound, Plus, Trash2, Copy, Check, AlertTriangle, Briefcase, CalendarDays, MapPin,
|
||||
StickyNote, Ticket, Hotel, Users, Star, BarChart3, type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { PUBLIC_API_SCOPES, type PublicApiScope } from '@trek/shared'
|
||||
import Section from './Section'
|
||||
import Modal from '../shared/Modal'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import { EditorField, GRID_2, INPUT, LABEL } from '../shared/dialogParts'
|
||||
import { SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SettingsHint, StatusPill } from './settingsKit'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { authApi } from '../../api/client'
|
||||
@@ -69,7 +72,7 @@ function withCode(text: string): React.ReactNode[] {
|
||||
return text.split(/("[^"]+"|\/api\/v1)/).map((part, i) => {
|
||||
if (i % 2 === 0) return part
|
||||
return (
|
||||
<code key={i} className="rounded px-1 py-px font-mono bg-surface-hover text-content-secondary">
|
||||
<code key={i} className="rounded-[5px] bg-surface-tertiary px-1 py-px font-geist text-content-secondary">
|
||||
{part.replace(/^"|"$/g, '')}
|
||||
</code>
|
||||
)
|
||||
@@ -79,6 +82,7 @@ function withCode(text: string): React.ReactNode[] {
|
||||
export default function ApiKeysSection(): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const dialogLabelId = useId()
|
||||
const [keys, setKeys] = useState<ApiKey[]>([])
|
||||
/** A failed load must not read as "you have no keys": that sends people off minting duplicates. */
|
||||
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'failed'>('loading')
|
||||
@@ -201,144 +205,166 @@ export default function ApiKeysSection(): React.ReactElement {
|
||||
})
|
||||
}
|
||||
|
||||
const header = (
|
||||
<DialogHeader
|
||||
tile={<DialogTile><KeyRound size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={dialogLabelId}
|
||||
// Once the key is on screen the cross does what Done does: it is a deliberate
|
||||
// click, unlike the Escape and backdrop the shell ignores below.
|
||||
onClose={closeModal}
|
||||
title={created ? t('settings.apiKeys.modal.createdTitle') : t('settings.apiKeys.modal.createTitle')}
|
||||
/>
|
||||
)
|
||||
|
||||
const footer = created ? (
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton variant="primary" onClick={closeModal}>{t('settings.apiKeys.modal.done')}</DialogButton>
|
||||
</DialogFooter>
|
||||
) : (
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={closeModal}>{t('common.cancel')}</DialogButton>
|
||||
<DialogButton variant="primary" onClick={handleCreate} disabled={!canCreate}
|
||||
icon={creating ? <span aria-hidden className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" /> : undefined}>
|
||||
{creating ? t('settings.apiKeys.modal.creating') : t('settings.apiKeys.modal.create')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section title={t('settings.apiKeys.title')} icon={KeyRound}>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
|
||||
<p className="text-body text-content-secondary">{t('settings.apiKeys.description')}</p>
|
||||
<button type="button" onClick={openModal} disabled={atLimit}
|
||||
className="inline-flex flex-shrink-0 items-center gap-1.5 self-start rounded-lg px-3.5 py-2 text-body font-medium transition-colors bg-accent text-accent-text hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50">
|
||||
<Plus className="w-4 h-4" /> {t('settings.apiKeys.create')}
|
||||
<Section
|
||||
title={t('settings.apiKeys.title')}
|
||||
icon={KeyRound}
|
||||
badge={keys.length > 0 ? <StatusPill>{keys.length}/{MAX_KEYS}</StatusPill> : undefined}
|
||||
action={(
|
||||
<button type="button" onClick={openModal} disabled={atLimit} className={SETTINGS_BUTTON_PRIMARY} style={fs(12.5, 'body')}>
|
||||
<Plus size={14} /> {t('settings.apiKeys.create')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<SettingsHint>{t('settings.apiKeys.description')}</SettingsHint>
|
||||
|
||||
{atLimit && (
|
||||
<p className="text-caption text-content-muted">{t('settings.apiKeys.limitReached', { max: MAX_KEYS })}</p>
|
||||
<div className="flex gap-2.5 rounded-[12px] bg-warning-soft px-3.5 py-2.5 text-content" style={fs(12.5, 'body')}>
|
||||
<AlertTriangle size={15} className="mt-px flex-none text-warning" />
|
||||
<p className="m-0 leading-snug">{t('settings.apiKeys.limitReached', { max: MAX_KEYS })}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadState === 'loading' && (
|
||||
<div aria-hidden className="flex flex-col gap-2">
|
||||
<div className="h-[62px] animate-pulse rounded-[12px] bg-surface-tertiary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadState === 'failed' && (
|
||||
<p role="alert" className="rounded-lg px-3 py-2.5 text-caption bg-danger-soft text-danger">
|
||||
<p role="alert" className="m-0 rounded-[12px] bg-danger-soft px-3.5 py-2.5 text-danger" style={fs(12.5, 'body')}>
|
||||
{t('settings.apiKeys.loadFailed')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loadState === 'ready' && keys.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-2.5 rounded-lg border border-dashed px-4 py-6 text-center border-edge">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-surface-hover">
|
||||
<KeyRound className="w-5 h-5 text-content-muted" />
|
||||
<div className="flex flex-col items-center gap-2.5 rounded-[12px] border border-dashed border-edge bg-surface-card px-4 py-6 text-center">
|
||||
<span className="grid h-10 w-10 place-items-center rounded-[12px] bg-surface-tertiary text-content-muted">
|
||||
<KeyRound size={18} strokeWidth={1.9} />
|
||||
</span>
|
||||
<p className="text-body text-content-muted">{t('settings.apiKeys.empty')}</p>
|
||||
<p className="m-0 text-content-muted" style={fs(12.5, 'body')}>{t('settings.apiKeys.empty')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{keys.length > 0 && (
|
||||
<ul className="m-0 list-none space-y-2 p-0">
|
||||
<ul className="m-0 list-none divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card p-0">
|
||||
{keys.map(key => (
|
||||
<ApiKeyRow key={key.id} apiKey={key} locale={locale} onDelete={() => setDeleteId(key.id)} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 border-t pt-4 border-edge-secondary">
|
||||
<span className="block text-caption font-medium text-content-secondary">{t('settings.apiKeys.endpoint')}</span>
|
||||
<div className="flex flex-col gap-2 border-t border-edge-faint pt-4">
|
||||
<span className={LABEL + ' !mb-0'}>{t('settings.apiKeys.endpoint')}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 truncate rounded-lg border px-3 py-2 font-mono text-caption border-edge bg-surface-secondary text-content">
|
||||
<code className="block min-w-0 flex-1 truncate rounded-[10px] border border-edge-faint bg-surface-card px-3 py-2 font-geist text-content" style={fs(12.5, 'body')}>
|
||||
{endpoint}
|
||||
</code>
|
||||
<button type="button" onClick={() => handleCopy(endpoint, 'endpoint')}
|
||||
className="flex-shrink-0 rounded-lg border p-2 transition-colors border-edge text-content-secondary hover:bg-surface-hover"
|
||||
title={t('common.copy')} aria-label={t('common.copy')}>
|
||||
{copied === 'endpoint' ? <Check className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
<Tooltip label={t('common.copy')}>
|
||||
<button type="button" onClick={() => handleCopy(endpoint, 'endpoint')}
|
||||
className={`${SETTINGS_ICON_BUTTON} !h-9 !w-9`}
|
||||
aria-label={t('common.copy')}>
|
||||
{copied === 'endpoint' ? <Check size={15} className="text-success" /> : <Copy size={15} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="text-caption text-content-muted">{withCode(t('settings.apiKeys.docsHint'))}</p>
|
||||
<SettingsHint>{withCode(t('settings.apiKeys.docsHint'))}</SettingsHint>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
<DialogShell
|
||||
open={modalOpen}
|
||||
// Once the key is on screen, Done is the only way out: a stray Escape or
|
||||
// backdrop click would throw away the one copy there will ever be.
|
||||
onClose={created ? () => {} : closeModal}
|
||||
hideCloseButton={!!created}
|
||||
size="lg"
|
||||
title={created ? t('settings.apiKeys.modal.createdTitle') : t('settings.apiKeys.modal.createTitle')}
|
||||
footer={created ? (
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={closeModal}
|
||||
className="rounded-lg px-4 py-2 text-body font-medium transition-colors bg-accent text-accent-text hover:bg-accent-hover">
|
||||
{t('settings.apiKeys.modal.done')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={closeModal}
|
||||
className="rounded-lg border px-4 py-2 text-body font-medium transition-colors border-edge text-content-secondary hover:bg-surface-hover">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" onClick={handleCreate} disabled={!canCreate}
|
||||
className="inline-flex items-center gap-2 rounded-lg px-4 py-2 text-body font-medium transition-colors bg-accent text-accent-text hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50">
|
||||
{creating && <span aria-hidden className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />}
|
||||
{creating ? t('settings.apiKeys.modal.creating') : t('settings.apiKeys.modal.create')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
blocked={!!created}
|
||||
labelledBy={dialogLabelId}
|
||||
width="detail"
|
||||
header={header}
|
||||
footer={footer}
|
||||
>
|
||||
{created ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-2.5 rounded-lg p-3 bg-warning-soft">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-warning" />
|
||||
<p className="text-caption text-content">{t('settings.apiKeys.modal.createdWarning')}</p>
|
||||
<>
|
||||
<div className="flex items-start gap-2.5 rounded-[12px] bg-warning-soft px-3.5 py-3">
|
||||
<AlertTriangle size={15} className="mt-px flex-none text-warning" />
|
||||
<p className="m-0 text-content" style={fs(12.5, 'body')}>{t('settings.apiKeys.modal.createdWarning')}</p>
|
||||
</div>
|
||||
<div className="flex items-stretch overflow-hidden rounded-lg border border-edge bg-surface-secondary">
|
||||
<code className="min-w-0 flex-1 select-all break-all px-3 py-2.5 font-mono text-caption text-content">{created}</code>
|
||||
<div className="flex items-stretch overflow-hidden rounded-[12px] border border-edge-faint bg-surface-secondary">
|
||||
<code className="min-w-0 flex-1 select-all break-all px-3.5 py-3 font-geist text-content" style={fs(12.5, 'body')}>{created}</code>
|
||||
<button type="button" onClick={() => handleCopy(created, 'key')} title={t('settings.apiKeys.copy')}
|
||||
className="flex flex-shrink-0 items-center gap-1.5 border-l px-3 text-caption font-medium transition-colors border-edge text-content-secondary hover:bg-surface-hover">
|
||||
{copied === 'key' ? <Check className="w-3.5 h-3.5 text-success" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
className="flex flex-none items-center gap-1.5 border-l border-edge-faint bg-surface-card px-3.5 font-medium text-content-secondary transition-colors hover:text-content"
|
||||
style={fs(12.5, 'body')}>
|
||||
{copied === 'key' ? <Check size={14} className="text-success" /> : <Copy size={14} />}
|
||||
{copied === 'key' ? t('common.copied') : t('settings.apiKeys.copy')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="api-key-name" className="mb-1.5 block text-caption font-medium text-content-secondary">
|
||||
{t('settings.apiKeys.modal.name')}
|
||||
</label>
|
||||
<>
|
||||
<EditorField label={t('settings.apiKeys.modal.name')} htmlFor="api-key-name" hint={t('settings.apiKeys.modal.nameHint')}>
|
||||
<input id="api-key-name" type="text" value={newName} onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') void handleCreate() }}
|
||||
placeholder={t('settings.apiKeys.modal.namePlaceholder')}
|
||||
maxLength={100}
|
||||
className="w-full rounded-lg border px-3 py-2.5 text-body focus:outline-none focus:ring-2 ring-accent border-edge bg-surface-input text-content"
|
||||
className={INPUT}
|
||||
autoFocus />
|
||||
<p className="mt-1.5 text-caption text-content-muted">{t('settings.apiKeys.modal.nameHint')}</p>
|
||||
</div>
|
||||
</EditorField>
|
||||
|
||||
<div role="group" aria-labelledby="api-key-scopes-title">
|
||||
<div className="mb-1 flex items-baseline justify-between gap-3">
|
||||
<span id="api-key-scopes-title" className="text-caption font-medium text-content-secondary">
|
||||
<section role="group" aria-labelledby="api-key-scopes-title">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span id="api-key-scopes-title" className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5)}>
|
||||
{t('settings.apiScopes.title')}
|
||||
</span>
|
||||
<button type="button"
|
||||
onClick={() => setNewScopes(allSelected ? new Set() : new Set(PUBLIC_API_SCOPES))}
|
||||
className="flex-shrink-0 text-caption font-medium transition-colors text-content-muted hover:text-content">
|
||||
className="ml-auto flex-none rounded-full px-2 py-0.5 font-semibold text-content-muted transition-colors hover:bg-surface-tertiary hover:text-content"
|
||||
style={fs(11.5, 'body')}>
|
||||
{allSelected ? t('common.deselectAll') : t('common.selectAll')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mb-3 text-caption text-content-muted">{t('settings.apiScopes.hint')}</p>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<SettingsHint className="mb-3">{t('settings.apiScopes.hint')}</SettingsHint>
|
||||
<div className={GRID_2}>
|
||||
{PUBLIC_API_SCOPES.map(scope => (
|
||||
<ScopeOption key={scope} icon={SCOPE_ICONS[scope]} label={t(`settings.apiScopes.${scope}`)}
|
||||
checked={newScopes.has(scope)} onToggle={() => toggleScope(scope)} />
|
||||
))}
|
||||
</div>
|
||||
{newScopes.size === 0 && (
|
||||
<p className="mt-2 text-caption text-danger">{t('settings.apiScopes.noneSelected')}</p>
|
||||
<p className="m-0 mt-2 text-danger" style={fs(11.5)}>{t('settings.apiScopes.noneSelected')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</DialogShell>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={deleteId !== null}
|
||||
@@ -356,18 +382,18 @@ function ApiKeyRow({ apiKey, locale, onDelete }: { apiKey: ApiKey; locale: strin
|
||||
const { t } = useTranslation()
|
||||
const scopes = apiKey.scope_mode === 'limited' ? apiKey.scopes : undefined
|
||||
return (
|
||||
<li className="flex items-start gap-3 rounded-lg border p-3 border-edge">
|
||||
<span className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-surface-hover">
|
||||
<KeyRound className="w-4 h-4 text-content-secondary" />
|
||||
<li className="flex items-start gap-3 px-3.5 py-3">
|
||||
<span className="grid h-9 w-9 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary">
|
||||
<KeyRound size={16} strokeWidth={1.9} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="truncate text-body font-medium text-content">{apiKey.name}</span>
|
||||
<code className="rounded px-1.5 py-0.5 font-mono text-caption bg-surface-hover text-content-muted">
|
||||
<span className="min-w-0 max-w-full truncate font-semibold text-content" style={fs(13, 'body')}>{apiKey.name}</span>
|
||||
<code className="rounded-full border border-edge-faint bg-surface-secondary px-2 py-[1px] font-geist text-content-muted" style={fs(11)}>
|
||||
{apiKey.token_prefix}…
|
||||
</code>
|
||||
</div>
|
||||
<p className="mt-0.5 text-caption text-content-faint first-letter:uppercase">
|
||||
<p className="m-0 mt-0.5 tabular-nums text-content-faint first-letter:uppercase" style={fs(11.5)}>
|
||||
{t('settings.apiKeys.createdAt')} {formatStamp(apiKey.created_at, locale)}
|
||||
{' · '}
|
||||
{apiKey.last_used_at
|
||||
@@ -381,23 +407,22 @@ function ApiKeyRow({ apiKey, locale, onDelete }: { apiKey: ApiKey; locale: strin
|
||||
{scopes ? scopes.map(scope => {
|
||||
const Icon = SCOPE_ICONS[scope]
|
||||
return (
|
||||
<span key={scope} className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-caption bg-surface-hover text-content-secondary">
|
||||
{Icon && <Icon className="w-3 h-3" />}
|
||||
<StatusPill key={scope} icon={Icon ? <Icon size={11} strokeWidth={2.2} /> : undefined}>
|
||||
{t(`settings.apiScopes.${scope}`)}
|
||||
</span>
|
||||
</StatusPill>
|
||||
)
|
||||
}) : (
|
||||
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-caption bg-surface-hover text-content-secondary">
|
||||
{t('settings.apiScopes.all')}
|
||||
</span>
|
||||
<StatusPill tone="success">{t('settings.apiScopes.all')}</StatusPill>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onClick={onDelete}
|
||||
className="flex-shrink-0 rounded-lg p-1.5 transition-colors text-content-faint hover:bg-danger-soft hover:text-danger"
|
||||
title={t('settings.apiKeys.deleteTitle')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<Tooltip label={t('settings.apiKeys.deleteTitle')}>
|
||||
<button type="button" onClick={onDelete}
|
||||
className={`${SETTINGS_ICON_BUTTON} hover:!bg-danger-soft hover:!text-danger`}
|
||||
aria-label={t('settings.apiKeys.deleteTitle')}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -414,17 +439,17 @@ function ScopeOption({ icon: Icon, label, checked, onToggle }: {
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<button type="button" role="checkbox" aria-checked={checked} onClick={onToggle}
|
||||
className={`flex w-full items-center gap-2.5 rounded-lg border px-3 py-2.5 text-left transition-colors border-edge ${
|
||||
checked ? 'bg-accent-subtle' : 'hover:bg-surface-hover'
|
||||
className={`flex w-full min-w-0 items-center gap-2.5 rounded-[10px] border px-3 py-2.5 text-left transition-colors ${
|
||||
checked ? 'border-[color:var(--text-primary)] bg-surface-card shadow-sm' : 'border-edge-faint bg-surface-secondary hover:bg-surface-card'
|
||||
}`}>
|
||||
<span aria-hidden
|
||||
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded transition-colors ${
|
||||
className={`grid h-4 w-4 flex-none place-items-center rounded-[5px] transition-colors ${
|
||||
checked ? 'bg-accent' : 'border-[1.5px] border-edge'
|
||||
}`}>
|
||||
{checked && <Check className="w-2.5 h-2.5 text-accent-text" strokeWidth={3} />}
|
||||
{checked && <Check size={10} strokeWidth={3} className="text-accent-text" />}
|
||||
</span>
|
||||
<Icon aria-hidden className={`w-4 h-4 flex-shrink-0 ${checked ? 'text-content-secondary' : 'text-content-faint'}`} />
|
||||
<span className={`min-w-0 truncate text-body ${checked ? 'text-content' : 'text-content-muted'}`}>{label}</span>
|
||||
<Icon aria-hidden size={15} className={`flex-none ${checked ? 'text-content-secondary' : 'text-content-faint'}`} />
|
||||
<span className={`min-w-0 truncate font-medium ${checked ? 'text-content' : 'text-content-muted'}`} style={fs(12.5, 'body')}>{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import React, { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Paintbrush, Eye, LayoutDashboard, Sun, Moon, Monitor, RotateCcw } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
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 { applyAppearance } from '../../theme/applyAppearance'
|
||||
import { APPEARANCE_SCHEMES, CUSTOM_ACCENT_PRESETS } from '../../theme/schemes'
|
||||
import {
|
||||
@@ -61,17 +64,14 @@ const MOBILE_GROUPS: { id: string; fallback: string; keys: MobileWidgetKey[] }[]
|
||||
{ id: 'bottomOfPage', fallback: 'Bottom of page', keys: ['currency', 'collections', 'timezones', 'upcomingReservations'] },
|
||||
]
|
||||
|
||||
// shared segmented-button style (matches DisplaySettingsTab)
|
||||
function segStyle(active: boolean): React.CSSProperties {
|
||||
return {
|
||||
display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'center',
|
||||
padding: '10px 14px', borderRadius: 10, cursor: 'pointer', flex: '1 1 0', minWidth: 0,
|
||||
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
border: active ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: active ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)', transition: 'all 0.15s',
|
||||
}
|
||||
}
|
||||
/** 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-left 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 { settings, updateSetting } = useSettingsStore()
|
||||
@@ -141,284 +141,242 @@ export default function AppearanceSettingsTab(): React.ReactElement {
|
||||
const accentDark = cfg.accent?.dark ?? '#6366f1'
|
||||
const customRatio = contrastRatio(isDark ? accentDark : accentLight, '#ffffff')
|
||||
|
||||
// The stored mode, read the way the old boolean values meant it.
|
||||
const cur = settings.dark_mode
|
||||
const mode = cur === true ? 'dark' : cur === false ? 'light' : String(cur ?? '')
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Theme ───────────────────────────────────────────────── */}
|
||||
<Section title={tr('settings.appearance.theme', 'Theme')} icon={Paintbrush}>
|
||||
{/* Color mode */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-content-secondary">
|
||||
{tr('settings.colorMode', 'Color mode')}
|
||||
</label>
|
||||
<div className="flex gap-3" style={{ flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ value: 'light', label: tr('settings.light', 'Light'), icon: Sun },
|
||||
{ value: 'dark', label: tr('settings.dark', 'Dark'), icon: Moon },
|
||||
{ value: 'auto', label: tr('settings.auto', 'Auto'), icon: Monitor },
|
||||
].map((opt) => {
|
||||
const cur = settings.dark_mode
|
||||
const active =
|
||||
cur === opt.value ||
|
||||
(opt.value === 'light' && cur === false) ||
|
||||
(opt.value === 'dark' && cur === true)
|
||||
return (
|
||||
<button type="button" key={opt.value} onClick={() => setMode(opt.value)} style={segStyle(active)}>
|
||||
<span className="hidden sm:inline-flex"><opt.icon size={16} /></span>
|
||||
{opt.value === 'auto' ? (
|
||||
<>
|
||||
<span className="hidden sm:inline">{opt.label}</span>
|
||||
<span className="sm:hidden">Auto</span>
|
||||
</>
|
||||
) : opt.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<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 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-content-secondary">
|
||||
{tr('settings.appearance.scheme', 'Color scheme')}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
{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}
|
||||
onClick={() => update({ schemeId: s.id })}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px',
|
||||
borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
border: active ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: active ? 'var(--bg-hover)' : 'var(--bg-card)', color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 16, height: 16, borderRadius: '50%', background: dot, flexShrink: 0, boxShadow: 'inset 0 0 0 1px var(--border-faint)' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{tr(`settings.appearance.scheme.${s.id}`, schemeFallback(s.id))}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{/* Custom */}
|
||||
<button type="button"
|
||||
onClick={() => update({ schemeId: 'custom', accent: cfg.accent ?? { light: accentLight, dark: accentDark } })}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px', borderRadius: 10,
|
||||
cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
border: cfg.schemeId === 'custom' ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: cfg.schemeId === 'custom' ? 'var(--bg-hover)' : 'var(--bg-card)', color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 16, height: 16, borderRadius: '50%', flexShrink: 0, background: 'conic-gradient(#ef4444,#f59e0b,#22c55e,#3b82f6,#8b5cf6,#ef4444)' }} />
|
||||
{tr('settings.appearance.scheme.custom', 'Custom')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 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' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-content-secondary">
|
||||
{tr('settings.appearance.customAccent', 'Custom accent')}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{CUSTOM_ACCENT_PRESETS.map((c) => (
|
||||
<button type="button"
|
||||
key={c}
|
||||
aria-label={c}
|
||||
onClick={() => update({ accent: { light: c, dark: c } })}
|
||||
style={{ width: 28, height: 28, borderRadius: '50%', background: c, cursor: 'pointer', border: '2px solid var(--border-primary)' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<label className="flex items-center gap-2 text-sm text-content-secondary">
|
||||
{tr('settings.light', 'Light')}
|
||||
<input type="color" value={isHex(accentLight) ? accentLight : '#4f46e5'}
|
||||
onChange={(e) => update({ accent: { light: e.target.value, dark: accentDark } })}
|
||||
style={{ width: 36, height: 28, border: 'none', background: 'none', cursor: 'pointer' }} />
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-content-secondary">
|
||||
{tr('settings.dark', 'Dark')}
|
||||
<input type="color" value={isHex(accentDark) ? accentDark : '#6366f1'}
|
||||
onChange={(e) => update({ accent: { light: accentLight, dark: e.target.value } })}
|
||||
style={{ width: 36, height: 28, border: 'none', background: 'none', cursor: 'pointer' }} />
|
||||
</label>
|
||||
<span
|
||||
className="text-xs font-medium px-2 py-1 rounded-md"
|
||||
style={{ background: customRatio >= 4.5 ? 'var(--success-soft)' : 'var(--warning-soft)', color: customRatio >= 4.5 ? 'var(--success)' : 'var(--warning)' }}
|
||||
>
|
||||
{customRatio >= 4.5
|
||||
? `${tr('settings.appearance.contrastOk', 'Good contrast')} (${customRatio.toFixed(1)}:1)`
|
||||
: `${tr('settings.appearance.contrastLow', 'Low contrast')} (${customRatio.toFixed(1)}:1)`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 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={
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full bg-warning-soft text-warning">
|
||||
{tr('settings.appearance.experimental', 'Experimental')}
|
||||
</span>
|
||||
}
|
||||
badge={<StatusPill tone="warning">{tr('settings.appearance.experimental', 'Experimental')}</StatusPill>}
|
||||
>
|
||||
<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 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-content-secondary">
|
||||
{tr('settings.appearance.density', 'Density')}
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: 'comfortable', label: tr('settings.appearance.comfortable', 'Comfortable') },
|
||||
{ value: 'compact', label: tr('settings.appearance.compact', 'Compact') },
|
||||
].map((opt) => (
|
||||
<button type="button" key={opt.value} onClick={() => update({ density: opt.value as AppearanceConfig['density'] })} style={segStyle(cfg.density === opt.value)}>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-content-faint mt-2">
|
||||
{tr('settings.appearance.densityHint', 'Compact tightens spacing and padding for a denser layout that fits more on screen.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Text size — global, plus an always-visible row per size class with a
|
||||
live sample and an example of what each size affects. */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-content-secondary">
|
||||
{tr('settings.appearance.textSize', 'Text size')}
|
||||
</label>
|
||||
<SliderRow
|
||||
label={tr('settings.appearance.textSizeAll', 'Everything')}
|
||||
value={cfg.fontScale}
|
||||
onChange={(v) => update({ fontScale: v })}
|
||||
<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 })}
|
||||
/>
|
||||
<div className="space-y-4 mt-4 pt-4 border-t border-edge-secondary">
|
||||
<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 } })}
|
||||
<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 })}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
<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}>
|
||||
<p className="text-xs text-content-faint -mt-1">
|
||||
{tr('settings.appearance.dashboardWidgetsHint', 'Choose which widgets appear on the dashboard — independently for desktop and mobile.')}
|
||||
</p>
|
||||
|
||||
<div className="text-sm font-semibold text-content">{tr('settings.appearance.desktop', 'Desktop')}</div>
|
||||
{DESKTOP_GROUPS.map((g) => {
|
||||
const masterOn = g.master ? cfg.dashboard.desktop[g.master] : true
|
||||
return (
|
||||
<div key={g.id} className="rounded-lg border border-edge-secondary px-3 py-2">
|
||||
{g.master ? (
|
||||
<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
|
||||
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.')}
|
||||
on={masterOn}
|
||||
onToggle={() => setWidget('desktop', g.master as string, !masterOn)}
|
||||
key={k}
|
||||
label={tr(`settings.appearance.widget.${k}`, WIDGET_LABELS[k])}
|
||||
on={cfg.dashboard.desktop[k]}
|
||||
onToggle={() => setWidget('desktop', k, !cfg.dashboard.desktop[k])}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-content-faint mb-1">
|
||||
{tr(`settings.appearance.group.${g.id}`, g.fallback)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={g.master ? 'mt-1 pl-3 border-l-2 border-edge-secondary' : ''}
|
||||
style={g.master && !masterOn ? { opacity: 0.4, pointerEvents: 'none' } : undefined}
|
||||
>
|
||||
))
|
||||
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.desktop[k]}
|
||||
onToggle={() => setWidget('desktop', k, !cfg.dashboard.desktop[k])}
|
||||
on={cfg.dashboard.mobile[k]}
|
||||
onToggle={() => setWidget('mobile', k, !cfg.dashboard.mobile[k])}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="text-sm font-semibold text-content mt-3">{tr('settings.appearance.mobile', 'Mobile')}</div>
|
||||
{MOBILE_GROUPS.map((g) => (
|
||||
<div key={g.id} className="rounded-lg border border-edge-secondary px-3 py-2">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-content-faint mb-1">
|
||||
{tr(`settings.appearance.group.${g.id}`, g.fallback)}
|
||||
</div>
|
||||
{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="flex justify-end mb-6">
|
||||
<button type="button"
|
||||
onClick={resetAll}
|
||||
className="flex items-center gap-2 text-sm font-medium text-content-muted hover:text-content"
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '6px 4px' }}
|
||||
>
|
||||
<RotateCcw size={15} />
|
||||
<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>
|
||||
@@ -440,23 +398,43 @@ function schemeFallback(id: string): string {
|
||||
}
|
||||
|
||||
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 className="flex items-start justify-between gap-4 py-1.5">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-content-secondary">{label}</div>
|
||||
{hint && <div className="text-xs text-content-faint mt-0.5">{hint}</div>}
|
||||
</div>
|
||||
<ToggleSwitch on={on} onToggle={onToggle} label={label} />
|
||||
<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 pl-3 pr-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="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium text-content-secondary">{label}</span>
|
||||
<span className="text-xs text-content-muted tabular-nums">{Math.round(value * 100)}%</span>
|
||||
<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"
|
||||
@@ -466,7 +444,7 @@ function SliderRow({ label, value, onChange }: { label: string; value: number; o
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="trek-range"
|
||||
style={{ '--fill': `${((value - APPEARANCE_SCALE_MIN) / (APPEARANCE_SCALE_MAX - APPEARANCE_SCALE_MIN)) * 100}%` } as React.CSSProperties}
|
||||
style={fill(value)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -475,14 +453,14 @@ function SliderRow({ label, value, onChange }: { label: string; value: number; o
|
||||
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="flex items-end justify-between gap-3 mb-1.5">
|
||||
<div className="mb-1.5 flex items-end justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`${sampleClass} text-content leading-tight truncate`}>{sample}</div>
|
||||
<div className="text-xs text-content-faint mt-0.5">
|
||||
<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>
|
||||
<span className="text-xs text-content-muted tabular-nums shrink-0">{Math.round(value * 100)}%</span>
|
||||
<Percent value={value} />
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
@@ -492,7 +470,7 @@ function SizeRow({ sampleClass, name, example, sample, value, onChange }: { samp
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="trek-range"
|
||||
style={{ '--fill': `${((value - APPEARANCE_SCALE_MIN) / (APPEARANCE_SCALE_MAX - APPEARANCE_SCALE_MIN)) * 100}%` } as React.CSSProperties}
|
||||
style={fill(value)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import React from 'react'
|
||||
import { Save, Plug, RefreshCw, Unplug } from 'lucide-react'
|
||||
import { Loader2, Save, Plug, RefreshCw, Unplug } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useDawarichConnection } from '../../hooks/useDawarichConnection'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import Section from './Section'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint, StatusPill } from './settingsKit'
|
||||
import { EditorField, GRID_2, INPUT } from '../shared/dialogParts'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
|
||||
/**
|
||||
* Settings → Integrations → Dawarich.
|
||||
*
|
||||
* All of the behaviour is in `useDawarichConnection`, shared with the phone
|
||||
* twin; this file is markup. The layout follows the AirTrail and LLM sections
|
||||
* so the three read as one shelf, but the classes are the semantic tokens
|
||||
* rather than the raw `slate-*` those two still carry — a new surface has to
|
||||
* follow the user's accent and colour scheme.
|
||||
* so the three read as one shelf: fields side by side, the switches as rows,
|
||||
* the actions under them and the connection state as a pill in the band.
|
||||
*
|
||||
* The status block below the fields is the part that earns its space: a
|
||||
* connection to somebody else's server fails in ways they can act on — a wrong
|
||||
@@ -25,28 +27,30 @@ export default function DawarichConnectionSection(): React.ReactElement {
|
||||
const S = useDawarichConnection()
|
||||
|
||||
return (
|
||||
<Section title={t('dawarich.title')} icon={DawarichIcon}>
|
||||
<div className="space-y-3">
|
||||
<p className="text-caption text-content-secondary">{t('dawarich.intro')}</p>
|
||||
<Section
|
||||
title={t('dawarich.title')}
|
||||
icon={DawarichIcon}
|
||||
badge={
|
||||
<StatusPill tone={S.connected ? 'success' : 'neutral'} icon={<span className="h-1.5 w-1.5 rounded-full bg-current" />}>
|
||||
{S.connected ? t('dawarich.connected') : t('dawarich.notConnected')}
|
||||
</StatusPill>
|
||||
}
|
||||
>
|
||||
<SettingsHint>{t('dawarich.intro')}</SettingsHint>
|
||||
|
||||
<div>
|
||||
<label htmlFor="dawarich-url" className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.url')}
|
||||
</label>
|
||||
<div className={GRID_2}>
|
||||
<EditorField label={t('dawarich.url')} htmlFor="dawarich-url">
|
||||
<input
|
||||
id="dawarich-url"
|
||||
type="url"
|
||||
value={S.url}
|
||||
onChange={e => S.setUrl(e.target.value)}
|
||||
placeholder="https://dawarich.example.com"
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-body focus:outline-none focus:ring-2 ring-accent border-edge bg-surface-input text-content"
|
||||
className={INPUT}
|
||||
/>
|
||||
</div>
|
||||
</EditorField>
|
||||
|
||||
<div>
|
||||
<label htmlFor="dawarich-key" className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.apiKey')}
|
||||
</label>
|
||||
<EditorField label={t('dawarich.apiKey')} htmlFor="dawarich-key" hint={t('dawarich.apiKeyHint')}>
|
||||
<input
|
||||
id="dawarich-key"
|
||||
type="password"
|
||||
@@ -54,82 +58,72 @@ export default function DawarichConnectionSection(): React.ReactElement {
|
||||
onChange={e => S.setApiKey(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={S.connected && !S.apiKey ? '••••••••' : t('dawarich.apiKeyPlaceholder')}
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-body focus:outline-none focus:ring-2 ring-accent border-edge bg-surface-input text-content"
|
||||
className={INPUT}
|
||||
/>
|
||||
<p className="mt-1 text-caption text-content-muted">{t('dawarich.apiKeyHint')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ToggleSwitch on={S.syncEnabled} onToggle={S.toggleSync} label={t('dawarich.syncEnabled')} />
|
||||
<span className="text-body font-medium text-content-secondary">{t('dawarich.syncEnabled')}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-caption text-content-muted">{t('dawarich.syncEnabledHint')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ToggleSwitch on={S.allowInsecureTls} onToggle={S.toggleInsecureTls} label={t('dawarich.allowInsecureTls')} />
|
||||
<span className="text-body font-medium text-content-secondary">{t('dawarich.allowInsecureTls')}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-caption text-content-muted">{t('dawarich.allowInsecureTlsHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={S.save}
|
||||
disabled={S.saving || S.loading || !S.canSave}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-body font-medium bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" /> {t('common.save')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={S.test}
|
||||
disabled={S.testing || S.loading || !S.url.trim()}
|
||||
className="flex items-center gap-2 px-4 py-2 border rounded-lg text-body border-edge text-content-secondary hover:bg-surface-hover disabled:opacity-50"
|
||||
>
|
||||
{S.testing
|
||||
? <span className="w-4 h-4 border-2 rounded-full animate-spin border-edge border-t-transparent" />
|
||||
: <Plug className="w-4 h-4" />}
|
||||
{t('dawarich.test.button')}
|
||||
</button>
|
||||
|
||||
{S.connected && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={S.syncNow}
|
||||
disabled={S.syncing}
|
||||
className="flex items-center gap-2 px-4 py-2 border rounded-lg text-body border-edge text-content-secondary hover:bg-surface-hover disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${S.syncing ? 'animate-spin' : ''}`} />
|
||||
{t('dawarich.syncNow')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<span className="basis-full sm:basis-auto text-caption font-medium flex items-center gap-1.5">
|
||||
<span className={`w-2 h-2 rounded-full ${S.connected ? 'bg-success' : 'bg-surface-tertiary'}`} />
|
||||
<span className={S.connected ? 'text-success' : 'text-content-muted'}>
|
||||
{S.connected ? t('dawarich.connected') : t('dawarich.notConnected')}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{S.connected && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={S.disconnect}
|
||||
disabled={S.saving}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-caption text-danger hover:bg-danger-soft disabled:opacity-50"
|
||||
>
|
||||
<Unplug className="w-3.5 h-3.5" /> {t('dawarich.disconnect')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DawarichConnectionStatus state={S} locale={locale} />
|
||||
</EditorField>
|
||||
</div>
|
||||
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
label={t('dawarich.syncEnabled')}
|
||||
hint={t('dawarich.syncEnabledHint')}
|
||||
control={<ToggleSwitch on={S.syncEnabled} onToggle={S.toggleSync} label={t('dawarich.syncEnabled')} />}
|
||||
/>
|
||||
<SettingRow
|
||||
label={t('dawarich.allowInsecureTls')}
|
||||
hint={t('dawarich.allowInsecureTlsHint')}
|
||||
control={<ToggleSwitch on={S.allowInsecureTls} onToggle={S.toggleInsecureTls} label={t('dawarich.allowInsecureTls')} />}
|
||||
/>
|
||||
</SettingRows>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2" style={fs(13, 'body')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={S.save}
|
||||
disabled={S.saving || S.loading || !S.canSave}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
>
|
||||
<Save size={14} strokeWidth={2.2} /> {t('common.save')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={S.test}
|
||||
disabled={S.testing || S.loading || !S.url.trim()}
|
||||
className={SETTINGS_BUTTON}
|
||||
>
|
||||
{S.testing
|
||||
? <Loader2 size={14} strokeWidth={2.2} className="animate-spin" />
|
||||
: <Plug size={14} strokeWidth={2.2} />}
|
||||
{t('dawarich.test.button')}
|
||||
</button>
|
||||
|
||||
{S.connected && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={S.syncNow}
|
||||
disabled={S.syncing}
|
||||
className={SETTINGS_BUTTON}
|
||||
>
|
||||
<RefreshCw size={14} strokeWidth={2.2} className={S.syncing ? 'animate-spin' : ''} />
|
||||
{t('dawarich.syncNow')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* The way out sits apart from the everyday actions, on the far right. */}
|
||||
{S.connected && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={S.disconnect}
|
||||
disabled={S.saving}
|
||||
className={`${SETTINGS_BUTTON_DANGER} ml-auto`}
|
||||
>
|
||||
<Unplug size={14} strokeWidth={2.2} /> {t('dawarich.disconnect')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DawarichConnectionStatus state={S} locale={locale} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -161,11 +155,11 @@ function DawarichConnectionStatus({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border p-3 space-y-1.5 border-edge bg-surface-secondary">
|
||||
{state.probeMessage && <p className="text-caption text-content">{state.probeMessage}</p>}
|
||||
<div className="flex flex-col gap-1 rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3" style={fs(12, 'body')}>
|
||||
{state.probeMessage && <p className="m-0 font-medium text-content">{state.probeMessage}</p>}
|
||||
|
||||
{state.connected && (
|
||||
<p className="text-caption text-content-secondary">
|
||||
<p className="m-0 text-content-secondary">
|
||||
{state.lastSyncAt
|
||||
? t('dawarich.lastSync', { when: new Date(state.lastSyncAt).toLocaleString(locale) })
|
||||
: t('dawarich.neverSynced')}
|
||||
@@ -174,17 +168,17 @@ function DawarichConnectionStatus({
|
||||
)}
|
||||
|
||||
{state.lastSyncError && (
|
||||
<p className="text-caption text-danger">{state.lastSyncError}</p>
|
||||
<p className="m-0 text-danger">{state.lastSyncError}</p>
|
||||
)}
|
||||
|
||||
{state.capabilities?.serverVersion && (
|
||||
<p className="text-caption text-content-muted">
|
||||
<p className="m-0 font-geist tabular-nums text-content-muted">
|
||||
{t('dawarich.serverVersion', { version: state.capabilities.serverVersion })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{missing.length > 0 && (
|
||||
<p className="text-caption text-content-muted">
|
||||
<p className="m-0 text-content-muted">
|
||||
{t('dawarich.capability.missing', { features: missing.join(', ') })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,11 @@ import { buildUser, buildSettings } from '../../../tests/helpers/factories';
|
||||
import DisplaySettingsTab from './DisplaySettingsTab';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
|
||||
/** The switch of a yes-or-no row, named by the row's label. */
|
||||
function toggleFor(label: RegExp): HTMLElement {
|
||||
return screen.getByRole('button', { name: label });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
server.use(
|
||||
@@ -64,11 +69,8 @@ describe('DisplaySettingsTab', () => {
|
||||
it('FE-COMP-DISPLAY-016: active language button is visually highlighted', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }) });
|
||||
render(<DisplaySettingsTab />);
|
||||
// Multiple elements contain "English" (desktop grid button + mobile dropdown trigger).
|
||||
// The desktop grid button is the one with the active border style.
|
||||
const englishMatches = screen.getAllByText('English').map(el => el.closest('button')!).filter(Boolean);
|
||||
const activeBtn = englishMatches.find(btn => (btn.style.border || '').includes('var(--text-primary)'));
|
||||
expect(activeBtn).toBeDefined();
|
||||
// "English" is on the chip and on the compact trigger; only the chip is a pressed choice.
|
||||
expect(screen.getByRole('button', { name: 'English', pressed: true })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-017: shows Temperature section label', () => {
|
||||
@@ -80,7 +82,7 @@ describe('DisplaySettingsTab', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ temperature_unit: 'celsius' }) });
|
||||
render(<DisplaySettingsTab />);
|
||||
const celsiusBtn = screen.getByText('°C Celsius').closest('button')!;
|
||||
expect(celsiusBtn.style.border).toContain('var(--text-primary)');
|
||||
expect(celsiusBtn).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-019: clicking fahrenheit button calls updateSetting with fahrenheit', async () => {
|
||||
@@ -96,7 +98,7 @@ describe('DisplaySettingsTab', () => {
|
||||
seedStore(useSettingsStore, { settings: { temperature_unit: 'celsius' } });
|
||||
render(<DisplaySettingsTab />);
|
||||
const metricBtn = screen.getByText('km Metric').closest('button')!;
|
||||
expect(metricBtn.style.border).toContain('var(--text-primary)');
|
||||
expect(metricBtn).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-029: clicking imperial distance calls updateSetting with imperial', async () => {
|
||||
@@ -124,38 +126,33 @@ describe('DisplaySettingsTab', () => {
|
||||
expect(screen.getByText(/blur booking codes/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-025: blur booking codes On button is active when blur_booking_codes is true', () => {
|
||||
it('FE-COMP-DISPLAY-025: blur booking codes switch is on when blur_booking_codes is true', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ blur_booking_codes: true }) });
|
||||
render(<DisplaySettingsTab />);
|
||||
const block = screen.getByText(/blur booking codes/i).closest('div')!;
|
||||
const blurOnBtn = within(block).getByText(/^On$/i).closest('button')!;
|
||||
expect(blurOnBtn.style.border).toContain('var(--text-primary)');
|
||||
expect(toggleFor(/blur booking codes/i)).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-030: shows Always show booking routes next to Booking route labels', () => {
|
||||
render(<DisplaySettingsTab />);
|
||||
const bookingLabels = screen.getByText(/booking route labels/i);
|
||||
const alwaysShow = screen.getByText(/always show booking routes/i);
|
||||
expect(alwaysShow).toBeInTheDocument();
|
||||
// Adjacent siblings within the Travel & Map section: alwaysShow's block
|
||||
// immediately follows bookingLabels' block.
|
||||
expect(bookingLabels.closest('div')!.nextElementSibling).toBe(alwaysShow.closest('div'));
|
||||
expect(screen.getByText(/always show booking routes/i)).toBeInTheDocument();
|
||||
// Adjacent rows within the Travel & Map section: the always-show switch
|
||||
// immediately follows the booking-labels switch.
|
||||
const switches = Array.from(document.querySelectorAll('button[aria-label]'));
|
||||
const labels = switches.indexOf(toggleFor(/booking route labels/i));
|
||||
expect(switches.indexOf(toggleFor(/always show booking routes/i))).toBe(labels + 1);
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-031: always-show-routes Off button is active by default (unset)', () => {
|
||||
it('FE-COMP-DISPLAY-031: always-show-routes switch is off by default (unset)', () => {
|
||||
render(<DisplaySettingsTab />);
|
||||
const block = screen.getByText(/always show booking routes/i).closest('div')!;
|
||||
const offBtn = within(block).getByText(/^Off$/i).closest('button')!;
|
||||
expect(offBtn.style.border).toContain('var(--text-primary)');
|
||||
expect(toggleFor(/always show booking routes/i)).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-032: clicking On for always-show-routes calls updateSetting with map_always_show_routes true', async () => {
|
||||
it('FE-COMP-DISPLAY-032: switching always-show-routes on calls updateSetting with map_always_show_routes true', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, { settings: buildSettings(), updateSetting });
|
||||
render(<DisplaySettingsTab />);
|
||||
const block = screen.getByText(/always show booking routes/i).closest('div')!;
|
||||
await user.click(within(block).getByText(/^On$/i));
|
||||
await user.click(toggleFor(/always show booking routes/i));
|
||||
expect(updateSetting).toHaveBeenCalledWith('map_always_show_routes', true);
|
||||
});
|
||||
|
||||
@@ -175,7 +172,7 @@ describe('DisplaySettingsTab', () => {
|
||||
render(<DisplaySettingsTab />);
|
||||
await user.click(screen.getByText('°F Fahrenheit'));
|
||||
const fahrenheitBtn = screen.getByText('°F Fahrenheit').closest('button')!;
|
||||
expect(fahrenheitBtn.style.border).toContain('var(--text-primary)');
|
||||
expect(fahrenheitBtn).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -187,9 +184,11 @@ function seedFailing(message = 'Server error') {
|
||||
return updateSetting;
|
||||
}
|
||||
|
||||
/** The block of buttons belonging to one labelled setting. */
|
||||
function optionBlock(label: RegExp): HTMLElement {
|
||||
return screen.getByText(label).closest('div') as HTMLElement;
|
||||
/** The row of one labelled setting: climbs from its label until the row's control is inside. */
|
||||
function optionBlock(label: RegExp | string): HTMLElement {
|
||||
let el: HTMLElement | null = screen.getByText(label);
|
||||
while (el && !el.querySelector('button')) el = el.parentElement;
|
||||
return el as HTMLElement;
|
||||
}
|
||||
|
||||
describe('DisplaySettingsTab – Display currency', () => {
|
||||
@@ -236,7 +235,7 @@ describe('DisplaySettingsTab – Display currency', () => {
|
||||
// ── Compact language dropdown (035–039) ───────────────────────────────────────
|
||||
|
||||
function mobileLangWrap(): HTMLElement {
|
||||
return screen.getByText('Language').closest('div')!.querySelector('.sm\\:hidden') as HTMLElement;
|
||||
return optionBlock('Language').querySelector('.sm\\:hidden') as HTMLElement;
|
||||
}
|
||||
|
||||
describe('DisplaySettingsTab – Compact language picker', () => {
|
||||
@@ -323,7 +322,7 @@ describe('DisplaySettingsTab – Map and privacy toggles', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ map_booking_labels: true }), updateSetting });
|
||||
render(<DisplaySettingsTab />);
|
||||
|
||||
await user.click(within(optionBlock(/booking route labels/i)).getByText(/^Off$/));
|
||||
await user.click(toggleFor(/booking route labels/i));
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledWith('map_booking_labels', false);
|
||||
});
|
||||
@@ -333,7 +332,7 @@ describe('DisplaySettingsTab – Map and privacy toggles', () => {
|
||||
seedFailing('Labels locked');
|
||||
render(<><ToastContainer /><DisplaySettingsTab /></>);
|
||||
|
||||
await user.click(within(optionBlock(/booking route labels/i)).getByText(/^On$/));
|
||||
await user.click(toggleFor(/booking route labels/i));
|
||||
|
||||
expect(await screen.findByText('Labels locked')).toBeInTheDocument();
|
||||
});
|
||||
@@ -343,7 +342,7 @@ describe('DisplaySettingsTab – Map and privacy toggles', () => {
|
||||
seedFailing('Routes locked');
|
||||
render(<><ToastContainer /><DisplaySettingsTab /></>);
|
||||
|
||||
await user.click(within(optionBlock(/always show booking routes/i)).getByText(/^On$/));
|
||||
await user.click(toggleFor(/always show booking routes/i));
|
||||
|
||||
expect(await screen.findByText('Routes locked')).toBeInTheDocument();
|
||||
});
|
||||
@@ -353,10 +352,8 @@ describe('DisplaySettingsTab – Map and privacy toggles', () => {
|
||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, { settings: buildSettings(), updateSetting });
|
||||
render(<DisplaySettingsTab />);
|
||||
const block = optionBlock(/explore places on the map/i);
|
||||
|
||||
expect(within(block).getByText(/^On$/).closest('button')!.style.border).toContain('var(--text-primary)');
|
||||
await user.click(within(block).getByText(/^Off$/));
|
||||
expect(toggleFor(/explore places on the map/i)).toHaveAttribute('aria-pressed', 'true');
|
||||
await user.click(toggleFor(/explore places on the map/i));
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledWith('map_poi_pill_enabled', false);
|
||||
});
|
||||
@@ -366,7 +363,7 @@ describe('DisplaySettingsTab – Map and privacy toggles', () => {
|
||||
seedFailing('POI locked');
|
||||
render(<><ToastContainer /><DisplaySettingsTab /></>);
|
||||
|
||||
await user.click(within(optionBlock(/explore places on the map/i)).getByText(/^Off$/));
|
||||
await user.click(toggleFor(/explore places on the map/i));
|
||||
|
||||
expect(await screen.findByText('POI locked')).toBeInTheDocument();
|
||||
});
|
||||
@@ -377,7 +374,7 @@ describe('DisplaySettingsTab – Map and privacy toggles', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ blur_booking_codes: false }), updateSetting });
|
||||
render(<DisplaySettingsTab />);
|
||||
|
||||
await user.click(within(optionBlock(/blur booking codes/i)).getByText(/^On$/));
|
||||
await user.click(toggleFor(/blur booking codes/i));
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledWith('blur_booking_codes', true);
|
||||
});
|
||||
@@ -387,10 +384,8 @@ describe('DisplaySettingsTab – Map and privacy toggles', () => {
|
||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, { settings: buildSettings(), updateSetting });
|
||||
render(<DisplaySettingsTab />);
|
||||
const block = optionBlock(/optimize route from accommodation/i);
|
||||
|
||||
expect(within(block).getByText(/^On$/).closest('button')!.style.border).toContain('var(--text-primary)');
|
||||
await user.click(within(block).getByText(/^Off$/));
|
||||
expect(toggleFor(/optimize route from accommodation/i)).toHaveAttribute('aria-pressed', 'true');
|
||||
await user.click(toggleFor(/optimize route from accommodation/i));
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledWith('optimize_from_accommodation', false);
|
||||
});
|
||||
@@ -400,10 +395,10 @@ describe('DisplaySettingsTab – Map and privacy toggles', () => {
|
||||
seedFailing('Nope');
|
||||
render(<><ToastContainer /><DisplaySettingsTab /></>);
|
||||
|
||||
await user.click(within(optionBlock(/blur booking codes/i)).getByText(/^On$/));
|
||||
await user.click(toggleFor(/blur booking codes/i));
|
||||
await screen.findByText('Nope');
|
||||
|
||||
await user.click(within(optionBlock(/optimize route from accommodation/i)).getByText(/^Off$/));
|
||||
await user.click(toggleFor(/optimize route from accommodation/i));
|
||||
await user.click(screen.getByText('mi Imperial'));
|
||||
await user.click(screen.getByRole('button', { name: /24h/ }));
|
||||
|
||||
@@ -417,7 +412,7 @@ describe('DisplaySettingsTab – startup destination', () => {
|
||||
render(<DisplaySettingsTab />);
|
||||
const block = optionBlock(/^Start page$/);
|
||||
|
||||
expect(within(block).getByText('Dashboard').closest('button')!.style.border).toContain('var(--text-primary)');
|
||||
expect(within(block).getByText('Dashboard').closest('button')).toHaveAttribute('aria-pressed', 'true');
|
||||
// Nothing to pick a tab for while TREK opens on the dashboard.
|
||||
expect(screen.queryByText('Start tab')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { Languages, Map, ChevronDown, Check, Rocket } from 'lucide-react'
|
||||
import { Languages, Map, ChevronDown, Rocket } from 'lucide-react'
|
||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../../i18n'
|
||||
import { useSettingsStore, DEFAULT_SETTINGS } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { Segmented } from '../shared/dialogParts'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { POPOVER } from '../Packing/packingPopoverStyles'
|
||||
import { PopoverItem } from '../Packing/PackingPopover'
|
||||
import { preferredNavAppOptions } from '../Planner/placeNavigation'
|
||||
import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants'
|
||||
import Section from './Section'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import { ChoiceChips, SettingRow, SettingRows } from './settingsKit'
|
||||
import { TRIP_TAB_IDS, TRIP_TAB_LABEL_KEYS } from '../../constants/tripTabs'
|
||||
import { DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB } from '../../utils/startDestination'
|
||||
import type { DistanceUnit } from '../../types'
|
||||
import { DEFAULT_WEEK_START } from '@trek/shared'
|
||||
import { weekStartOptions } from '../../utils/calendarWeek'
|
||||
|
||||
/** A select on the right of a row: wide enough for a currency or a weekday, never wider than the row. */
|
||||
const SELECT_BOX = 'w-[260px] max-w-full'
|
||||
|
||||
export default function DisplaySettingsTab(): React.ReactElement {
|
||||
const { settings, updateSetting } = useSettingsStore()
|
||||
const { t, locale } = useTranslation()
|
||||
@@ -46,322 +55,269 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
|
||||
const startPage = settings.start_page === 'active_trip' ? 'active_trip' : DEFAULT_START_PAGE
|
||||
const startTripTab = settings.start_trip_tab || DEFAULT_START_TRIP_TAB
|
||||
const currentLanguage = SUPPORTED_LANGUAGES.find(o => o.value === settings.language) || SUPPORTED_LANGUAGES[0]
|
||||
|
||||
const pickLanguage = async (value: string) => {
|
||||
try { await updateSetting('language', value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section title={t('settings.general.startup')} icon={Rocket}>
|
||||
{/* Where opening TREK lands */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.startPage')}</label>
|
||||
<div className="flex gap-3">
|
||||
{([
|
||||
{ value: 'dashboard', label: t('settings.startPageDashboard') },
|
||||
{ value: 'active_trip', label: t('settings.startPageActiveTrip') },
|
||||
] as const).map(opt => (
|
||||
<button type="button"
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('start_page', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
border: startPage === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: startPage === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.startPageHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Which planner tab the trip opens on — only meaningful for 'active_trip' */}
|
||||
{startPage === 'active_trip' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.startTripTab')}</label>
|
||||
<CustomSelect
|
||||
value={startTripTab}
|
||||
onChange={async v => {
|
||||
try { await updateSetting('start_trip_tab', String(v)) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
options={TRIP_TAB_IDS.map(id => ({ value: id, label: t(TRIP_TAB_LABEL_KEYS[id]) }))}
|
||||
<SettingRows>
|
||||
{/* Where opening TREK lands */}
|
||||
<SettingRow
|
||||
label={t('settings.startPage')}
|
||||
hint={t('settings.startPageHint')}
|
||||
control={
|
||||
<Segmented
|
||||
label={t('settings.startPage')}
|
||||
value={startPage}
|
||||
options={[
|
||||
{ value: 'dashboard', label: t('settings.startPageDashboard') },
|
||||
{ value: 'active_trip', label: t('settings.startPageActiveTrip') },
|
||||
]}
|
||||
onChange={async value => {
|
||||
try { await updateSetting('start_page', value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-content-faint mt-2">{t('settings.startTripTabHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Which planner tab the trip opens on — only meaningful for 'active_trip' */}
|
||||
{startPage === 'active_trip' && (
|
||||
<SettingRow
|
||||
label={t('settings.startTripTab')}
|
||||
hint={t('settings.startTripTabHint')}
|
||||
control={
|
||||
<div className={SELECT_BOX}>
|
||||
<CustomSelect
|
||||
value={startTripTab}
|
||||
onChange={async v => {
|
||||
try { await updateSetting('start_trip_tab', String(v)) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
options={TRIP_TAB_IDS.map(id => ({ value: id, label: t(TRIP_TAB_LABEL_KEYS[id]) }))}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SettingRows>
|
||||
</Section>
|
||||
|
||||
<Section title={t('settings.general.languageRegion')} icon={Languages}>
|
||||
{/* Display currency */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.currency')}</label>
|
||||
{/* Unset ('') means "no personal preference": Costs then shows each trip in its
|
||||
own currency, instead of forcing every trip through one display currency. */}
|
||||
<CustomSelect
|
||||
value={settings.default_currency || ''}
|
||||
onChange={async v => {
|
||||
try { await updateSetting('default_currency', String(v)) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
options={[
|
||||
{ value: '', label: t('settings.currencyTrip') },
|
||||
...currenciesWith(settings.default_currency || '').map(c => ({ value: c, label: `${c} — ${SYMBOLS[c] || c}` })),
|
||||
]}
|
||||
searchable
|
||||
/>
|
||||
<p className="text-xs text-content-faint mt-2">{t('settings.currencyHint')}</p>
|
||||
</div>
|
||||
<SettingRows>
|
||||
{/* Display currency. Unset ('') means "no personal preference": Costs then shows
|
||||
each trip in its own currency, instead of forcing every trip through one. */}
|
||||
<SettingRow
|
||||
label={t('settings.currency')}
|
||||
hint={t('settings.currencyHint')}
|
||||
control={
|
||||
<div className={SELECT_BOX}>
|
||||
<CustomSelect
|
||||
value={settings.default_currency || ''}
|
||||
onChange={async v => {
|
||||
try { await updateSetting('default_currency', String(v)) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
options={[
|
||||
{ value: '', label: t('settings.currencyTrip') },
|
||||
...currenciesWith(settings.default_currency || '').map(c => ({ value: c, label: `${c} — ${SYMBOLS[c] || c}` })),
|
||||
]}
|
||||
searchable
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Language */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.language')}</label>
|
||||
{/* Desktop: Button grid */}
|
||||
<div className="hidden sm:flex flex-wrap gap-3">
|
||||
{SUPPORTED_LANGUAGES.map(opt => (
|
||||
<button type="button"
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('language', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
border: settings.language === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: settings.language === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Mobile: Custom dropdown */}
|
||||
<div ref={langDropdownRef} className="sm:hidden" style={{ position: 'relative' }}>
|
||||
{(() => {
|
||||
const current = SUPPORTED_LANGUAGES.find(o => o.value === settings.language) || SUPPORTED_LANGUAGES[0]
|
||||
return (
|
||||
{/* Language: every option at a glance on a wide window, a compact list on a narrow one */}
|
||||
<SettingRow label={t('settings.language')} stacked>
|
||||
<div className="hidden sm:block">
|
||||
<ChoiceChips
|
||||
label={t('settings.language')}
|
||||
value={settings.language}
|
||||
options={SUPPORTED_LANGUAGES.map(opt => ({ value: opt.value, label: opt.label }))}
|
||||
onChange={pickLanguage}
|
||||
/>
|
||||
</div>
|
||||
<div ref={langDropdownRef} className="relative sm:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLangOpen(v => !v)}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '10px 14px', borderRadius: 10,
|
||||
border: '2px solid var(--border-primary)',
|
||||
background: 'var(--bg-card)', color: 'var(--text-primary)',
|
||||
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500, fontFamily: 'inherit', cursor: 'pointer',
|
||||
}}
|
||||
aria-expanded={langOpen}
|
||||
className="flex w-full items-center justify-between gap-2 rounded-[10px] border border-edge bg-surface-input px-3 py-2 text-left font-medium text-content"
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{current?.label}</span>
|
||||
<ChevronDown size={14} className="text-content-faint" style={{ flexShrink: 0, transform: langOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
||||
<span className="min-w-0 truncate">{currentLanguage?.label}</span>
|
||||
<ChevronDown size={14} className={`flex-none text-content-faint transition-transform ${langOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
)
|
||||
})()}
|
||||
{langOpen && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 'calc(100% + 4px)', left: 0, right: 0, zIndex: 50,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.15)', padding: 4, maxHeight: 280, overflowY: 'auto',
|
||||
}}>
|
||||
{SUPPORTED_LANGUAGES.map(opt => {
|
||||
const active = settings.language === opt.value
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setLangOpen(false)
|
||||
try { await updateSetting('language', opt.value) }
|
||||
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
padding: '9px 12px', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||
background: active ? 'var(--bg-hover)' : 'transparent',
|
||||
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', color: 'var(--text-primary)',
|
||||
textAlign: 'left', fontWeight: active ? 600 : 500,
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1 }}>{opt.label}</span>
|
||||
{active && <Check size={14} strokeWidth={2.5} color="var(--accent)" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{langOpen && (
|
||||
<div className="absolute inset-x-0 top-[calc(100%+4px)] z-50 max-h-[280px] overflow-y-auto" style={POPOVER}>
|
||||
{SUPPORTED_LANGUAGES.map(opt => (
|
||||
<PopoverItem
|
||||
key={opt.value}
|
||||
icon={null}
|
||||
label={opt.label}
|
||||
active={settings.language === opt.value}
|
||||
onClick={async () => {
|
||||
setLangOpen(false)
|
||||
await pickLanguage(opt.value)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
{/* Place names (#1799): searches can answer in another language than the app. */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.placeLanguage')}</label>
|
||||
<CustomSelect
|
||||
value={settings.place_language || ''}
|
||||
onChange={async v => {
|
||||
try { await updateSetting('place_language', String(v)) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
options={[
|
||||
{ value: '', label: t('settings.placeLanguageApp') },
|
||||
...SUPPORTED_LANGUAGES.map(opt => ({ value: opt.value, label: opt.label })),
|
||||
]}
|
||||
searchable
|
||||
/>
|
||||
<p className="text-xs text-content-faint mt-2">{t('settings.placeLanguageHint')}</p>
|
||||
</div>
|
||||
{/* Place names (#1799): searches can answer in another language than the app. */}
|
||||
<SettingRow
|
||||
label={t('settings.placeLanguage')}
|
||||
hint={t('settings.placeLanguageHint')}
|
||||
control={
|
||||
<div className={SELECT_BOX}>
|
||||
<CustomSelect
|
||||
value={settings.place_language || ''}
|
||||
onChange={async v => {
|
||||
try { await updateSetting('place_language', String(v)) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
options={[
|
||||
{ value: '', label: t('settings.placeLanguageApp') },
|
||||
...SUPPORTED_LANGUAGES.map(opt => ({ value: opt.value, label: opt.label })),
|
||||
]}
|
||||
searchable
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Temperature */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.temperature')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: 'celsius', label: '°C Celsius' },
|
||||
{ value: 'fahrenheit', label: '°F Fahrenheit' },
|
||||
].map(opt => (
|
||||
<button type="button"
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
setTempUnit(opt.value)
|
||||
try { await updateSetting('temperature_unit', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
border: tempUnit === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: tempUnit === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Temperature */}
|
||||
<SettingRow
|
||||
label={t('settings.temperature')}
|
||||
control={
|
||||
<Segmented
|
||||
label={t('settings.temperature')}
|
||||
value={tempUnit}
|
||||
options={[
|
||||
{ value: 'celsius', label: '°C Celsius' },
|
||||
{ value: 'fahrenheit', label: '°F Fahrenheit' },
|
||||
]}
|
||||
onChange={async value => {
|
||||
setTempUnit(value)
|
||||
try { await updateSetting('temperature_unit', value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Distance */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.distance')}</label>
|
||||
<div className="flex gap-3">
|
||||
{([
|
||||
{ value: 'metric', label: 'km Metric' },
|
||||
{ value: 'imperial', label: 'mi Imperial' },
|
||||
] as const).map(opt => (
|
||||
<button type="button"
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
setDistanceUnit(opt.value)
|
||||
try { await updateSetting('distance_unit', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
border: distanceUnit === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: distanceUnit === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Distance */}
|
||||
<SettingRow
|
||||
label={t('settings.distance')}
|
||||
control={
|
||||
<Segmented<DistanceUnit>
|
||||
label={t('settings.distance')}
|
||||
value={distanceUnit}
|
||||
options={[
|
||||
{ value: 'metric', label: 'km Metric' },
|
||||
{ value: 'imperial', label: 'mi Imperial' },
|
||||
]}
|
||||
onChange={async value => {
|
||||
setDistanceUnit(value)
|
||||
try { await updateSetting('distance_unit', value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Time Format */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.timeFormat')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: '24h', short: '24h', example: '14:30' },
|
||||
{ value: '12h', short: '12h', example: '2:30 PM' },
|
||||
].map(opt => (
|
||||
<button type="button"
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('time_format', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
border: settings.time_format === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: settings.time_format === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.short}
|
||||
<span className="hidden sm:inline">{` (${opt.example})`}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Time Format */}
|
||||
<SettingRow
|
||||
label={t('settings.timeFormat')}
|
||||
control={
|
||||
<Segmented
|
||||
label={t('settings.timeFormat')}
|
||||
value={settings.time_format || ''}
|
||||
options={[
|
||||
{ value: '24h', short: '24h', example: '14:30' },
|
||||
{ value: '12h', short: '12h', example: '2:30 PM' },
|
||||
].map(opt => ({
|
||||
value: opt.value,
|
||||
label: <>{opt.short}<span className="hidden font-geist tabular-nums text-content-faint sm:inline">{` (${opt.example})`}</span></>,
|
||||
}))}
|
||||
onChange={async value => {
|
||||
try { await updateSetting('time_format', value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Week start: the first column of every date picker (#2029) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.weekStart')}</label>
|
||||
<CustomSelect
|
||||
value={settings.week_start || DEFAULT_WEEK_START}
|
||||
onChange={async v => {
|
||||
try { await updateSetting('week_start', String(v)) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
options={weekStartOptions(locale)}
|
||||
/>
|
||||
<p className="text-xs text-content-faint mt-2">{t('settings.weekStartHint')}</p>
|
||||
</div>
|
||||
{/* Week start: the first column of every date picker (#2029) */}
|
||||
<SettingRow
|
||||
label={t('settings.weekStart')}
|
||||
hint={t('settings.weekStartHint')}
|
||||
control={
|
||||
<div className={SELECT_BOX}>
|
||||
<CustomSelect
|
||||
value={settings.week_start || DEFAULT_WEEK_START}
|
||||
onChange={async v => {
|
||||
try { await updateSetting('week_start', String(v)) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
options={weekStartOptions(locale)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</SettingRows>
|
||||
</Section>
|
||||
|
||||
<Section title={t('settings.general.travelMap')} icon={Map}>
|
||||
{/* Preferred map app (#2423): opt-in, the picker stays the default */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.preferredNavApp')}</label>
|
||||
<CustomSelect
|
||||
value={settings.preferred_nav_app || ''}
|
||||
onChange={async v => {
|
||||
try { await updateSetting('preferred_nav_app', String(v)) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
options={preferredNavAppOptions(t)}
|
||||
/>
|
||||
<p className="text-xs text-content-faint mt-2">{t('settings.preferredNavAppHint')}</p>
|
||||
</div>
|
||||
<SettingRows>
|
||||
{/* Preferred map app (#2423): opt-in, the picker stays the default */}
|
||||
<SettingRow
|
||||
label={t('settings.preferredNavApp')}
|
||||
hint={t('settings.preferredNavAppHint')}
|
||||
control={
|
||||
<div className={SELECT_BOX}>
|
||||
<CustomSelect
|
||||
value={settings.preferred_nav_app || ''}
|
||||
onChange={async v => {
|
||||
try { await updateSetting('preferred_nav_app', String(v)) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
options={preferredNavAppOptions(t)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Date first in day headings (#1953) */}
|
||||
<OnOffSetting label={t('settings.dayDateFirst')} hint={t('settings.dayDateFirstHint')} on={settings.day_date_first === true} onChange={value => saveOnOff('day_date_first', value)} />
|
||||
{/* Date first in day headings (#1953) */}
|
||||
<OnOffSetting label={t('settings.dayDateFirst')} hint={t('settings.dayDateFirstHint')} on={settings.day_date_first === true} onChange={value => saveOnOff('day_date_first', value)} />
|
||||
|
||||
{/* Booking route labels */}
|
||||
<OnOffSetting label={t('settings.bookingLabels')} hint={t('settings.bookingLabelsHint')} on={settings.map_booking_labels === true} onChange={value => saveOnOff('map_booking_labels', value)} />
|
||||
{/* Booking route labels */}
|
||||
<OnOffSetting label={t('settings.bookingLabels')} hint={t('settings.bookingLabelsHint')} on={settings.map_booking_labels === true} onChange={value => saveOnOff('map_booking_labels', value)} />
|
||||
|
||||
{/* Always show booking routes */}
|
||||
<OnOffSetting label={t('settings.alwaysShowRoutes')} hint={t('settings.alwaysShowRoutesHint')} on={settings.map_always_show_routes === true} onChange={value => saveOnOff('map_always_show_routes', value)} />
|
||||
{/* Always show booking routes */}
|
||||
<OnOffSetting label={t('settings.alwaysShowRoutes')} hint={t('settings.alwaysShowRoutesHint')} on={settings.map_always_show_routes === true} onChange={value => saveOnOff('map_always_show_routes', value)} />
|
||||
|
||||
{/* Unplanned places as small markers (#2024) */}
|
||||
<OnOffSetting label={t('settings.compactUnplanned')} hint={t('settings.compactUnplannedHint')} on={settings.map_compact_unplanned === true} onChange={value => saveOnOff('map_compact_unplanned', value)} />
|
||||
{/* Unplanned places as small markers (#2024) */}
|
||||
<OnOffSetting label={t('settings.compactUnplanned')} hint={t('settings.compactUnplannedHint')} on={settings.map_compact_unplanned === true} onChange={value => saveOnOff('map_compact_unplanned', value)} />
|
||||
|
||||
{/* Explore places on the map (POI category pill) */}
|
||||
<OnOffSetting label={t('settings.mapPoiPill')} hint={t('settings.mapPoiPillHint')} on={settings.map_poi_pill_enabled !== false} onChange={value => saveOnOff('map_poi_pill_enabled', value)} />
|
||||
{/* Explore places on the map (POI category pill) */}
|
||||
<OnOffSetting label={t('settings.mapPoiPill')} hint={t('settings.mapPoiPillHint')} on={settings.map_poi_pill_enabled !== false} onChange={value => saveOnOff('map_poi_pill_enabled', value)} />
|
||||
|
||||
{/* Blur Booking Codes */}
|
||||
<OnOffSetting label={t('settings.blurBookingCodes')} on={!!settings.blur_booking_codes} onChange={value => saveOnOff('blur_booking_codes', value)} />
|
||||
{/* Blur Booking Codes */}
|
||||
<OnOffSetting label={t('settings.blurBookingCodes')} on={!!settings.blur_booking_codes} onChange={value => saveOnOff('blur_booking_codes', value)} />
|
||||
|
||||
{/* Optimize route from accommodation */}
|
||||
<OnOffSetting label={t('settings.optimizeFromAccommodation')} hint={t('settings.optimizeFromAccommodationHint')} on={settings.optimize_from_accommodation !== false} onChange={value => saveOnOff('optimize_from_accommodation', value)} />
|
||||
{/* Optimize route from accommodation */}
|
||||
<OnOffSetting label={t('settings.optimizeFromAccommodation')} hint={t('settings.optimizeFromAccommodationHint')} on={settings.optimize_from_accommodation !== false} onChange={value => saveOnOff('optimize_from_accommodation', value)} />
|
||||
</SettingRows>
|
||||
</Section>
|
||||
</>
|
||||
)
|
||||
@@ -369,35 +325,13 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
|
||||
type OnOffKey = 'day_date_first' | 'map_compact_unplanned' | 'map_booking_labels' | 'map_always_show_routes' | 'map_poi_pill_enabled' | 'blur_booking_codes' | 'optimize_from_accommodation'
|
||||
|
||||
/** One preference as a pair of On / Off buttons, the way this tab asks every yes-or-no question. */
|
||||
/** One yes-or-no preference as a row with a switch, the way the planner's settings dialogs ask it. */
|
||||
function OnOffSetting({ label, hint, on, onChange }: { label: string; hint?: string; on: boolean; onChange: (value: boolean) => void }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-content-secondary">{label}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
].map(opt => (
|
||||
<button type="button"
|
||||
key={String(opt.value)}
|
||||
onClick={() => onChange(opt.value)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
border: on === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: on === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{hint && <p className="text-xs mt-1 text-content-faint">{hint}</p>}
|
||||
</div>
|
||||
<SettingRow
|
||||
label={label}
|
||||
hint={hint}
|
||||
control={<ToggleSwitch on={on} onToggle={() => onChange(!on)} label={label} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -240,7 +240,7 @@ describe('IntegrationsTab', () => {
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await screen.findByText('Delete Me');
|
||||
await user.click(screen.getByTitle('Delete Token'));
|
||||
await user.click(screen.getByLabelText('Delete Token'));
|
||||
await screen.findByText('This token will stop working immediately. Any MCP client using it will lose access.');
|
||||
expect(screen.getByRole('button', { name: /^Cancel$/i })).toBeInTheDocument();
|
||||
});
|
||||
@@ -266,12 +266,11 @@ describe('IntegrationsTab', () => {
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await screen.findByText('Delete Me');
|
||||
await user.click(screen.getByTitle('Delete Token'));
|
||||
// There are two "Delete Token" buttons: the trash icon (title) and the confirm button in modal
|
||||
await user.click(screen.getByLabelText('Delete Token'));
|
||||
// There are two "Delete Token" buttons: the trash icon and the confirm button in the modal
|
||||
const deleteButtons = await screen.findAllByRole('button', { name: /^Delete Token$/i });
|
||||
// Click the one in the modal (last one, or the standalone one without title attribute)
|
||||
const confirmBtn = deleteButtons.find(btn => !btn.title);
|
||||
await user.click(confirmBtn ?? deleteButtons[deleteButtons.length - 1]);
|
||||
// The modal renders after the list, so its confirm button comes last
|
||||
await user.click(deleteButtons[deleteButtons.length - 1]);
|
||||
expect(deleteCalled).toBe(true);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Delete Me')).toBeNull();
|
||||
@@ -298,9 +297,9 @@ describe('IntegrationsTab', () => {
|
||||
const { copy } = mcpEndpointRow();
|
||||
await user.click(copy);
|
||||
await waitFor(() => {
|
||||
// After copy, icon changes to Check (green). The button should contain an svg with text-green-500
|
||||
// After copy, icon changes to Check in the success colour
|
||||
const svg = copy.querySelector('svg');
|
||||
expect(svg).toHaveClass('text-green-500');
|
||||
expect(svg).toHaveClass('text-success');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -325,7 +324,7 @@ describe('IntegrationsTab', () => {
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await screen.findByText('Cancel Token');
|
||||
await user.click(screen.getByTitle('Delete Token'));
|
||||
await user.click(screen.getByLabelText('Delete Token'));
|
||||
await screen.findByRole('button', { name: /^Cancel$/i });
|
||||
await user.click(screen.getByRole('button', { name: /^Cancel$/i }));
|
||||
await waitFor(() => {
|
||||
@@ -568,7 +567,7 @@ describe('IntegrationsTab', () => {
|
||||
enableMcp();
|
||||
render(<><ToastContainer /><IntegrationsTab /></>);
|
||||
await screen.findByText('Delete Me');
|
||||
await user.click(screen.getByTitle('Delete Client'));
|
||||
await user.click(screen.getByLabelText('Delete Client'));
|
||||
// Confirmation modal
|
||||
await screen.findByRole('heading', { name: 'Delete Client' });
|
||||
const confirmBtns = screen.getAllByRole('button', { name: /Delete Client/i });
|
||||
@@ -596,7 +595,7 @@ describe('IntegrationsTab', () => {
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('Rotate Me');
|
||||
await user.click(screen.getByTitle('Rotate Secret'));
|
||||
await user.click(screen.getByLabelText('Rotate Secret'));
|
||||
await screen.findByText('Rotate Secret');
|
||||
// Confirm — button text is 'Rotate'
|
||||
const rotateBtns = screen.getAllByRole('button', { name: /^Rotate$/i });
|
||||
@@ -768,7 +767,7 @@ describe('IntegrationsTab – copy actions and cancels', () => {
|
||||
await screen.findByText('Token Created');
|
||||
|
||||
const writeSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
|
||||
const copyBtns = screen.getAllByTitle('Copy');
|
||||
const copyBtns = screen.getAllByLabelText('Copy');
|
||||
await user.click(copyBtns[copyBtns.length - 1]);
|
||||
|
||||
expect(writeSpy).toHaveBeenCalledWith('tk_fresh_raw_secret');
|
||||
@@ -845,7 +844,7 @@ describe('IntegrationsTab – copy actions and cancels', () => {
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('Existing Client');
|
||||
|
||||
await user.click(screen.getByTitle('Delete Client'));
|
||||
await user.click(screen.getByLabelText('Delete Client'));
|
||||
await screen.findByRole('heading', { name: 'Delete Client' });
|
||||
await user.click(screen.getByRole('button', { name: /^Cancel$/i }));
|
||||
|
||||
@@ -860,7 +859,7 @@ describe('IntegrationsTab – copy actions and cancels', () => {
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('Existing Client');
|
||||
|
||||
await user.click(screen.getByTitle('Rotate Secret'));
|
||||
await user.click(screen.getByLabelText('Rotate Secret'));
|
||||
await screen.findByRole('heading', { name: 'Rotate Secret' });
|
||||
await user.click(screen.getByRole('button', { name: /^Cancel$/i }));
|
||||
|
||||
@@ -876,7 +875,7 @@ describe('IntegrationsTab – copy actions and cancels', () => {
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('Existing Client');
|
||||
await user.click(screen.getByTitle('Rotate Secret'));
|
||||
await user.click(screen.getByLabelText('Rotate Secret'));
|
||||
const rotateBtns = await screen.findAllByRole('button', { name: /^Rotate$/i });
|
||||
await user.click(rotateBtns[rotateBtns.length - 1]);
|
||||
await screen.findByText('New Secret Generated');
|
||||
@@ -932,9 +931,9 @@ describe('IntegrationsTab – failure toasts', () => {
|
||||
await openTokensTab(user);
|
||||
await screen.findByText('Token One');
|
||||
|
||||
await user.click(screen.getByTitle('Delete Token'));
|
||||
await user.click(screen.getByLabelText('Delete Token'));
|
||||
const confirmBtns = await screen.findAllByRole('button', { name: /^Delete Token$/i });
|
||||
await user.click(confirmBtns.find(b => !b.title) ?? confirmBtns[confirmBtns.length - 1]);
|
||||
await user.click(confirmBtns[confirmBtns.length - 1]);
|
||||
|
||||
await screen.findByText('Failed to delete token');
|
||||
expect(screen.getByText('Token One')).toBeInTheDocument();
|
||||
@@ -951,12 +950,12 @@ describe('IntegrationsTab – failure toasts', () => {
|
||||
render(<><ToastContainer /><IntegrationsTab /></>);
|
||||
await screen.findByText('Existing Client');
|
||||
|
||||
await user.click(screen.getByTitle('Delete Client'));
|
||||
await user.click(screen.getByLabelText('Delete Client'));
|
||||
const deleteBtns = await screen.findAllByRole('button', { name: /^Delete Client$/i });
|
||||
await user.click(deleteBtns[deleteBtns.length - 1]);
|
||||
await screen.findByText('Failed to delete OAuth client');
|
||||
|
||||
await user.click(screen.getByTitle('Rotate Secret'));
|
||||
await user.click(screen.getByLabelText('Rotate Secret'));
|
||||
const rotateBtns = await screen.findAllByRole('button', { name: /^Rotate$/i });
|
||||
await user.click(rotateBtns[rotateBtns.length - 1]);
|
||||
expect(await screen.findByText('Failed to rotate client secret')).toBeInTheDocument();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,9 +39,9 @@ function renderSection() {
|
||||
);
|
||||
}
|
||||
|
||||
// The switch carries its row's label as its accessible name.
|
||||
function multimodalToggle(): HTMLElement {
|
||||
const row = screen.getByText('Model reads images').parentElement as HTMLElement;
|
||||
return row.querySelector('button') as HTMLElement;
|
||||
return screen.getByRole('button', { name: 'Model reads images' });
|
||||
}
|
||||
|
||||
async function pickProvider(user: ReturnType<typeof userEvent.setup>, current: RegExp, next: string) {
|
||||
|
||||
@@ -6,7 +6,9 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import type { Settings } from '../../types'
|
||||
import Section from './Section'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint } from './settingsKit'
|
||||
import { EditorField, GRID_2, INPUT, Segmented } from '../shared/dialogParts'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
|
||||
type Provider = NonNullable<Settings['llm_provider']>
|
||||
|
||||
@@ -56,8 +58,8 @@ export default function LlmConnectionSection(): React.ReactElement {
|
||||
|
||||
const providerOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'openai', label: t('settings.aiParsing.providerOpenai') },
|
||||
{ value: 'anthropic', label: t('settings.aiParsing.providerAnthropic') },
|
||||
{ value: 'openai' as const, label: t('settings.aiParsing.providerOpenai') },
|
||||
{ value: 'anthropic' as const, label: t('settings.aiParsing.providerAnthropic') },
|
||||
],
|
||||
[t],
|
||||
)
|
||||
@@ -95,60 +97,61 @@ export default function LlmConnectionSection(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<Section title={t('settings.aiParsing.title')} icon={Sparkles}>
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-content-secondary">{t('settings.aiParsing.hint')}</p>
|
||||
<SettingsHint>{t('settings.aiParsing.hint')}</SettingsHint>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.aiParsing.provider')}</label>
|
||||
<CustomSelect
|
||||
value={provider}
|
||||
onChange={v => setProvider(v as Provider)}
|
||||
options={providerOptions}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-content-faint">{t('settings.aiParsing.localAdminOnly')}</p>
|
||||
</div>
|
||||
{/* Two hosted providers only (#1772), so a segmented pair rather than a list. */}
|
||||
<EditorField label={t('settings.aiParsing.provider')} hint={t('settings.aiParsing.localAdminOnly')}>
|
||||
<Segmented<Provider>
|
||||
label={t('settings.aiParsing.provider')}
|
||||
value={provider}
|
||||
onChange={setProvider}
|
||||
options={providerOptions}
|
||||
/>
|
||||
</EditorField>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.aiParsing.model')}</label>
|
||||
<div className={GRID_2}>
|
||||
<EditorField label={t('settings.aiParsing.model')} htmlFor="llm-model">
|
||||
<input
|
||||
id="llm-model"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
value={model}
|
||||
onChange={e => setModel(e.target.value)}
|
||||
placeholder="qwen3:8b"
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 border-edge bg-surface-secondary text-content"
|
||||
className={INPUT}
|
||||
/>
|
||||
</div>
|
||||
</EditorField>
|
||||
|
||||
{/* Both remaining providers are hosted and need a key, so this is no
|
||||
longer conditional (#1772). */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.aiParsing.apiKey')}</label>
|
||||
<EditorField label={t('settings.aiParsing.apiKey')} htmlFor="llm-api-key" hint={t('settings.aiParsing.apiKeyHint')}>
|
||||
<input
|
||||
id="llm-api-key"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={hasStoredKey && !apiKey ? '••••••••' : t('settings.aiParsing.apiKey')}
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 border-edge bg-surface-secondary text-content"
|
||||
className={INPUT}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-content-faint">{t('settings.aiParsing.apiKeyHint')}</p>
|
||||
</div>
|
||||
</EditorField>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ToggleSwitch on={multimodal} onToggle={() => setMultimodal(v => !v)} />
|
||||
<span className="text-sm font-medium text-content-secondary">{t('settings.aiParsing.multimodal')}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-content-faint">{t('settings.aiParsing.multimodalHint')}</p>
|
||||
</div>
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
label={t('settings.aiParsing.multimodal')}
|
||||
hint={t('settings.aiParsing.multimodalHint')}
|
||||
control={<ToggleSwitch on={multimodal} onToggle={() => setMultimodal(v => !v)} label={t('settings.aiParsing.multimodal')} />}
|
||||
/>
|
||||
</SettingRows>
|
||||
|
||||
<div className="flex" style={fs(13, 'body')}>
|
||||
<button type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !isLoaded}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50"
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
>
|
||||
<Save className="w-4 h-4" /> {t('common.save')}
|
||||
<Save size={14} strokeWidth={2.2} /> {t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import React, { useState, useEffect, useMemo, useRef, Suspense } from 'react'
|
||||
import { Map, Save, Layers, Box, ChevronDown, Check, Globe2 } from 'lucide-react'
|
||||
import React, { useState, useEffect, useMemo, useRef, useId, Suspense, type ReactNode } from 'react'
|
||||
import { Map, Save, Layers, Box, ChevronDown, Check, Globe2, type LucideIcon } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { EditorField, INPUT } from '../shared/dialogParts'
|
||||
import { POPOVER } from '../Packing/packingPopoverStyles'
|
||||
import { MapView } from '../Map/MapView'
|
||||
// The preview loads on demand, and paired with a single engine — a Leaflet-only
|
||||
// install pays for neither, and a GL install pays for one instead of both.
|
||||
@@ -11,6 +14,7 @@ import ErrorBoundary from '../shared/ErrorBoundary'
|
||||
import { GlMapPreviewMapbox, GlMapPreviewMaplibre } from '../Map/glLazy'
|
||||
import Section from './Section'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import { SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint, StatusPill } from './settingsKit'
|
||||
import { withTileApiKey } from '../../utils/tileUrl'
|
||||
import { AMAP_ROAD, AMAP_SATELLITE } from '../../constants/mapDefaults'
|
||||
import type { Place } from '../../types'
|
||||
@@ -48,30 +52,13 @@ const MAP_PRESETS: MapPreset[] = [
|
||||
{ name: '高德卫星 (Amap Satellite)', url: AMAP_SATELLITE },
|
||||
]
|
||||
|
||||
// Tag → chip color mapping. Keeps the dropdown readable at a glance so a
|
||||
// user scanning the list can spot 3D / Satellite / Apple-like styles.
|
||||
const TAG_STYLES: Record<string, string> = {
|
||||
'3D': 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300',
|
||||
'2D': 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
|
||||
'Satellite': 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
'Apple-like': 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300',
|
||||
'Modern': 'bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300',
|
||||
'Dark': 'bg-zinc-800 text-zinc-100 dark:bg-zinc-900 dark:text-zinc-300',
|
||||
'Minimal': 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
||||
'Hillshading': 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
'Terrain': 'bg-lime-100 text-lime-800 dark:bg-lime-900/40 dark:text-lime-300',
|
||||
'Realistic': 'bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300',
|
||||
'Navigation': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
'Classic': 'bg-stone-100 text-stone-700 dark:bg-stone-800 dark:text-stone-300',
|
||||
'Hybrid': 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300',
|
||||
'No labels': 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300',
|
||||
'OpenFreeMap': 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||
}
|
||||
/** A field holding a URL, token or key: the box look in Geist, so the characters read apart. */
|
||||
const CODE_INPUT = `${INPUT} font-geist`
|
||||
|
||||
/** A style's tag (3D, Satellite, Terrain…): a small quiet chip, so a scan of the list finds the kind of map. */
|
||||
function TagChip({ tag }: { tag: string }) {
|
||||
const cls = TAG_STYLES[tag] || 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300'
|
||||
return (
|
||||
<span className={`text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded leading-none ${cls}`}>
|
||||
<span className="flex-none rounded-[5px] bg-surface-tertiary px-1.5 py-[3px] font-geist font-bold uppercase leading-none tracking-[.06em] text-content-muted" style={fs(9)}>
|
||||
{tag}
|
||||
</span>
|
||||
)
|
||||
@@ -102,22 +89,24 @@ function StyleDropdown({ value, provider, onChange }: { value: string; provider:
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="w-full flex items-center justify-between gap-2 px-3 py-2 border border-slate-300 dark:border-slate-700 rounded-lg text-sm bg-white dark:bg-slate-900 hover:border-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
aria-expanded={open}
|
||||
className="flex w-full items-center justify-between gap-2 rounded-[10px] border border-edge bg-surface-input px-3 py-2 text-left hover:border-content-faint focus:outline-none focus:ring-2 focus:ring-[color:var(--text-primary)]"
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-slate-900 dark:text-white truncate">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className={`truncate ${selected ? 'font-medium text-content' : 'text-content-faint'}`}>
|
||||
{selected ? selected.name : placeholder}
|
||||
</span>
|
||||
{selected && (
|
||||
<span className="flex items-center gap-1 flex-shrink-0">
|
||||
<span className="flex flex-none items-center gap-1">
|
||||
{(selected.tags || []).map(t => <TagChip key={t} tag={t} />)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown size={14} className="flex-shrink-0 text-slate-400" />
|
||||
<ChevronDown size={14} className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute z-20 mt-1 w-full max-h-80 overflow-auto rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-lg py-1">
|
||||
<div className="absolute inset-x-0 top-[calc(100%+4px)] z-20 max-h-64 overflow-y-auto" style={POPOVER}>
|
||||
{presets.map(preset => {
|
||||
const isActive = preset.url === value
|
||||
return (
|
||||
@@ -125,13 +114,14 @@ function StyleDropdown({ value, provider, onChange }: { value: string; provider:
|
||||
key={preset.url}
|
||||
type="button"
|
||||
onClick={() => { onChange(preset.url); setOpen(false) }}
|
||||
className={`w-full flex items-center justify-between gap-2 px-3 py-2 text-left text-sm hover:bg-slate-50 dark:hover:bg-slate-800 ${isActive ? 'bg-slate-50 dark:bg-slate-800' : ''}`}
|
||||
className={`flex w-full items-center justify-between gap-2 rounded-[9px] px-2.5 py-2 text-left hover:bg-surface-tertiary ${isActive ? 'bg-surface-tertiary' : ''}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<span className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-slate-900 dark:text-white font-medium">{preset.name}</span>
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<span className="font-medium text-content">{preset.name}</span>
|
||||
{(preset.tags || []).map(t => <TagChip key={t} tag={t} />)}
|
||||
</span>
|
||||
{isActive && <Check size={14} className="flex-shrink-0 text-slate-900 dark:text-white" />}
|
||||
{isActive && <Check size={13} className="flex-none text-content-muted" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@@ -141,6 +131,36 @@ function StyleDropdown({ value, provider, onChange }: { value: string; provider:
|
||||
)
|
||||
}
|
||||
|
||||
/** One engine to pick: an icon tile, its name and what it is, outlined while chosen. */
|
||||
function ProviderTile({ active, onClick, icon: Icon, name, subtitle, badge }: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
icon: LucideIcon
|
||||
name: ReactNode
|
||||
subtitle: string
|
||||
badge?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={`flex min-w-0 items-start gap-3 rounded-[12px] border bg-surface-card p-3 text-left transition-colors ${active ? 'border-[color:var(--text-primary)] shadow-sm' : 'border-edge hover:border-content-faint'}`}
|
||||
>
|
||||
<span className={`grid h-8 w-8 flex-none place-items-center rounded-[10px] ${active ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-secondary'}`}>
|
||||
<Icon size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<span className="font-semibold text-content" style={fs(13, 'body')}>{name}</span>
|
||||
{badge}
|
||||
</span>
|
||||
<span className="mt-0.5 hidden leading-snug text-content-faint sm:block" style={fs(11.5)}>{subtitle}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
type Provider = 'leaflet' | GlMapProvider
|
||||
|
||||
function normalizeProvider(value: unknown): Provider {
|
||||
@@ -183,6 +203,8 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
const [mapboxQuality, setMapboxQuality] = useState<boolean>(settings.mapbox_quality_mode === true)
|
||||
// One chunk per engine — see components/Map/glLazy.tsx.
|
||||
const GlMapPreview = provider === 'maplibre-gl' ? GlMapPreviewMaplibre : GlMapPreviewMapbox
|
||||
// Ties each eyebrow label to its field, so a click on the label focuses it.
|
||||
const fieldId = useId()
|
||||
|
||||
useEffect(() => {
|
||||
const nextProvider = normalizeProvider(settings.map_provider)
|
||||
@@ -248,262 +270,239 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
}
|
||||
// Only CARTO burns a watermark into keyless tiles, so the nudge is scoped to its hosts.
|
||||
const cartoNeedsKey = mapTileUrl.includes('basemaps.cartocdn.com') && !cartoKey.trim()
|
||||
const link = 'font-medium text-content-secondary underline decoration-edge underline-offset-2 hover:text-content'
|
||||
|
||||
return (
|
||||
<Section title={t('settings.map')} icon={Map}>
|
||||
{/* Provider picker — big cards so the choice is obvious */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">{t('settings.mapProvider')}</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeProvider('leaflet')}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${
|
||||
provider === 'leaflet'
|
||||
? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200'
|
||||
: 'border-slate-200 hover:border-slate-400 dark:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Layers size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-900 dark:text-white">Leaflet</div>
|
||||
<div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapLeafletSubtitle')}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeProvider('mapbox-gl')}
|
||||
className={`relative flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${
|
||||
provider === 'mapbox-gl'
|
||||
? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200'
|
||||
: 'border-slate-200 hover:border-slate-400 dark:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Box size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-slate-900 dark:text-white">
|
||||
<span className="sm:hidden">Mapbox</span>
|
||||
<span className="hidden sm:inline">Mapbox GL</span>
|
||||
</div>
|
||||
<div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapMapboxSubtitle')}</div>
|
||||
</div>
|
||||
{/* Experimental badge only on ≥sm; on mobile there's no room next to the title. */}
|
||||
<span className="hidden sm:inline-block absolute top-2 right-2 text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 leading-none">
|
||||
{t('settings.mapExperimental')}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeProvider('maplibre-gl')}
|
||||
className={`relative flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${
|
||||
provider === 'maplibre-gl'
|
||||
? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200'
|
||||
: 'border-slate-200 hover:border-slate-400 dark:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Globe2 size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-slate-900 dark:text-white">
|
||||
<span className="sm:hidden">MapLibre</span>
|
||||
<span className="hidden sm:inline">MapLibre GL</span>
|
||||
</div>
|
||||
<div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapMapLibreSubtitle')}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-2">
|
||||
{t('settings.mapProviderHint')}
|
||||
</p>
|
||||
</div>
|
||||
{/* Provider picker — big tiles so the choice is obvious */}
|
||||
<SettingRows>
|
||||
<SettingRow label={t('settings.mapProvider')} hint={t('settings.mapProviderHint')} stacked>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<ProviderTile
|
||||
active={provider === 'leaflet'}
|
||||
onClick={() => changeProvider('leaflet')}
|
||||
icon={Layers}
|
||||
name="Leaflet"
|
||||
subtitle={t('settings.mapLeafletSubtitle')}
|
||||
/>
|
||||
<ProviderTile
|
||||
active={provider === 'mapbox-gl'}
|
||||
onClick={() => changeProvider('mapbox-gl')}
|
||||
icon={Box}
|
||||
name={<><span className="sm:hidden">Mapbox</span><span className="hidden sm:inline">Mapbox GL</span></>}
|
||||
subtitle={t('settings.mapMapboxSubtitle')}
|
||||
// Only on ≥sm; on a narrow window there's no room next to the title.
|
||||
badge={<span className="hidden sm:inline-flex"><StatusPill tone="warning">{t('settings.mapExperimental')}</StatusPill></span>}
|
||||
/>
|
||||
<ProviderTile
|
||||
active={provider === 'maplibre-gl'}
|
||||
onClick={() => changeProvider('maplibre-gl')}
|
||||
icon={Globe2}
|
||||
name={<><span className="sm:hidden">MapLibre</span><span className="hidden sm:inline">MapLibre GL</span></>}
|
||||
subtitle={t('settings.mapMapLibreSubtitle')}
|
||||
/>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</SettingRows>
|
||||
|
||||
{/* Leaflet settings */}
|
||||
{provider === 'leaflet' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapTemplate')}</label>
|
||||
<CustomSelect
|
||||
value={mapTileUrl}
|
||||
onChange={(value: string) => { if (value) setMapTileUrl(value) }}
|
||||
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||
size="sm"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mapTileUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
|
||||
placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('settings.mapDefaultHint')}</p>
|
||||
</div>
|
||||
<EditorField label={t('settings.mapTemplate')} htmlFor={`${fieldId}-tile`} hint={t('settings.mapDefaultHint')}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<CustomSelect
|
||||
value={mapTileUrl}
|
||||
onChange={(value: string) => { if (value) setMapTileUrl(value) }}
|
||||
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||
/>
|
||||
<input
|
||||
id={`${fieldId}-tile`}
|
||||
type="text"
|
||||
value={mapTileUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
|
||||
placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
className={CODE_INPUT}
|
||||
/>
|
||||
</div>
|
||||
</EditorField>
|
||||
)}
|
||||
|
||||
{/* Same deal as the Mapbox token: a managed install brings its own key. */}
|
||||
{provider === 'leaflet' && !managed && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapCartoKey')}</label>
|
||||
<EditorField
|
||||
label={t('settings.mapCartoKey')}
|
||||
htmlFor={`${fieldId}-carto`}
|
||||
hint={
|
||||
<>
|
||||
{t('settings.mapCartoKeyHint')}{' '}
|
||||
<a href="https://carto.com/basemaps/apikey/" target="_blank" rel="noreferrer" className={link}>
|
||||
{t('settings.mapCartoKeyLink')}
|
||||
</a>
|
||||
{cartoNeedsKey && (
|
||||
<span className="mt-1 block font-medium text-warning">{t('settings.mapCartoKeyMissing')}</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<input
|
||||
id={`${fieldId}-carto`}
|
||||
type="text"
|
||||
value={cartoKey}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCartoKey(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
className={CODE_INPUT}
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
{t('settings.mapCartoKeyHint')}{' '}
|
||||
<a href="https://carto.com/basemaps/apikey/" target="_blank" rel="noreferrer" className="underline">
|
||||
{t('settings.mapCartoKeyLink')}
|
||||
</a>
|
||||
</p>
|
||||
{cartoNeedsKey && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 mt-1">{t('settings.mapCartoKeyMissing')}</p>
|
||||
)}
|
||||
</div>
|
||||
</EditorField>
|
||||
)}
|
||||
|
||||
{/* GL settings */}
|
||||
{provider !== 'leaflet' && (
|
||||
<div className="space-y-3">
|
||||
<>
|
||||
{/* The token comes with the instance on a managed install, injected when the
|
||||
settings are read. A field here would only let somebody save a worse one. */}
|
||||
{provider === 'mapbox-gl' && !managed && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapMapboxToken')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={mapboxToken}
|
||||
onChange={(e) => setMapboxToken(e.target.value)}
|
||||
placeholder="pk.eyJ1Ijoi..."
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
{t('settings.mapMapboxTokenHint')}{' '}
|
||||
<a href="https://account.mapbox.com/access-tokens/" target="_blank" rel="noreferrer" className="underline">
|
||||
{t('settings.mapMapboxTokenLink')}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<EditorField
|
||||
label={t('settings.mapMapboxToken')}
|
||||
htmlFor={`${fieldId}-token`}
|
||||
hint={
|
||||
<>
|
||||
{t('settings.mapMapboxTokenHint')}{' '}
|
||||
<a href="https://account.mapbox.com/access-tokens/" target="_blank" rel="noreferrer" className={link}>
|
||||
{t('settings.mapMapboxTokenLink')}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<input
|
||||
id={`${fieldId}-token`}
|
||||
type="text"
|
||||
value={mapboxToken}
|
||||
onChange={(e) => setMapboxToken(e.target.value)}
|
||||
placeholder="pk.eyJ1Ijoi..."
|
||||
className={CODE_INPUT}
|
||||
/>
|
||||
</EditorField>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapStyle')}</label>
|
||||
<div className="mb-2">
|
||||
<EditorField
|
||||
label={t('settings.mapStyle')}
|
||||
htmlFor={`${fieldId}-style`}
|
||||
hint={provider === 'maplibre-gl' ? t('settings.mapOpenFreeMapStyleHint') : t('settings.mapStyleHint')}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<StyleDropdown value={mapboxStyle} provider={provider} onChange={setMapboxStyle} />
|
||||
<input
|
||||
id={`${fieldId}-style`}
|
||||
type="text"
|
||||
value={mapboxStyle}
|
||||
onChange={(e) => setMapboxStyle(e.target.value)}
|
||||
placeholder={defaultStyleForProvider(provider)}
|
||||
className={CODE_INPUT}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={mapboxStyle}
|
||||
onChange={(e) => setMapboxStyle(e.target.value)}
|
||||
placeholder={defaultStyleForProvider(provider)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
{provider === 'maplibre-gl' ? t('settings.mapOpenFreeMapStyleHint') : t('settings.mapStyleHint')}
|
||||
</p>
|
||||
</div>
|
||||
</EditorField>
|
||||
|
||||
{provider === 'mapbox-gl' && (
|
||||
<>
|
||||
<div className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${
|
||||
supports3d
|
||||
? 'border-slate-200 dark:border-slate-700'
|
||||
: 'border-slate-200 opacity-60 dark:border-slate-700'
|
||||
}`}>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-slate-900 dark:text-white">{t('settings.map3dBuildings')}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
{t('settings.map3dHint')}
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
on={mapbox3d && supports3d}
|
||||
onToggle={() => { if (supports3d) setMapbox3d(!mapbox3d) }}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
label={t('settings.map3dBuildings')}
|
||||
hint={t('settings.map3dHint')}
|
||||
dimmed={!supports3d}
|
||||
control={
|
||||
<ToggleSwitch
|
||||
on={mapbox3d && supports3d}
|
||||
onToggle={() => { if (supports3d) setMapbox3d(!mapbox3d) }}
|
||||
label={t('settings.map3dBuildings')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label={
|
||||
<span className="inline-flex flex-wrap items-center gap-2">
|
||||
<span>{t('settings.mapHighQuality')}</span>
|
||||
<StatusPill tone="warning">{t('settings.mapExperimental')}</StatusPill>
|
||||
</span>
|
||||
}
|
||||
hint={
|
||||
<>
|
||||
{t('settings.mapHighQualityHint')}{' '}
|
||||
<span className="text-warning">{t('settings.mapHighQualityWarning')}</span>
|
||||
</>
|
||||
}
|
||||
control={<ToggleSwitch on={mapboxQuality} onToggle={() => setMapboxQuality(!mapboxQuality)} label={t('settings.mapHighQuality')} />}
|
||||
/>
|
||||
</SettingRows>
|
||||
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg border border-slate-200 dark:border-slate-700">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-slate-900 dark:text-white flex flex-col items-start gap-1 sm:flex-row sm:items-center sm:gap-2">
|
||||
<span className="order-2 sm:order-1">{t('settings.mapHighQuality')}</span>
|
||||
<span className="order-1 sm:order-2 text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 leading-none">
|
||||
{t('settings.mapExperimental')}
|
||||
</span>
|
||||
<div className="rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3">
|
||||
<SettingsHint>
|
||||
<strong className="font-semibold text-content-secondary">{t('settings.mapTipLabel')}</strong> {t('settings.mapTip')}
|
||||
</SettingsHint>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
{t('settings.mapHighQualityHint')}{' '}
|
||||
<span className="text-amber-600 dark:text-amber-400">{t('settings.mapHighQualityWarning')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch on={mapboxQuality} onToggle={() => setMapboxQuality(!mapboxQuality)} />
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-400 p-3 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
|
||||
<strong className="text-slate-600 dark:text-slate-300">{t('settings.mapTipLabel')}</strong> {t('settings.mapTip')}
|
||||
</div>
|
||||
</>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div style={{ position: 'relative', inset: 0, height: '200px', width: '100%' }}>
|
||||
{provider !== 'leaflet' ? (
|
||||
/* A net of its own: the preview is the one place a user flips providers
|
||||
live, so it is the likeliest chunk to fail — and a broken preview must
|
||||
not take the rest of the settings tab with it. */
|
||||
<ErrorBoundary boundaryId="settings:map-preview" resetKeys={[provider]} fallback={<div className="h-full w-full bg-surface-secondary" />}>
|
||||
<Suspense fallback={<div className="h-full w-full bg-surface-secondary animate-pulse" />}>
|
||||
<GlMapPreview
|
||||
provider={provider}
|
||||
token={mapboxToken}
|
||||
style={mapboxStyle}
|
||||
lat={PREVIEW_CENTER[0]}
|
||||
lng={PREVIEW_CENTER[1]}
|
||||
// Zoom in close so the style's character (3D buildings,
|
||||
// satellite texture, label density) is immediately visible.
|
||||
zoom={PREVIEW_ZOOM}
|
||||
enable3d={provider === 'mapbox-gl' && mapbox3d && supports3d}
|
||||
quality={provider === 'mapbox-gl' && mapboxQuality}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
) : (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
React.createElement(MapView as any, {
|
||||
places: previewPlaces,
|
||||
dayPlaces: [],
|
||||
route: null,
|
||||
routeSegments: null,
|
||||
selectedPlaceId: null,
|
||||
onMarkerClick: null,
|
||||
onMapClick: null,
|
||||
onMapContextMenu: null,
|
||||
// With the key on it, or the preview resolves the template as a
|
||||
// keyless CARTO one and quietly shows the app default instead of
|
||||
// the basemap being configured. The fields hold what the user is
|
||||
// editing rather than what useTileUrl already resolved, so the key
|
||||
// has to be put back on here.
|
||||
tileUrl: withTileApiKey(mapTileUrl, cartoKey),
|
||||
fitKey: null,
|
||||
dayOrderMap: [],
|
||||
leftWidth: 0,
|
||||
rightWidth: 0,
|
||||
hasInspector: false,
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="relative h-[200px] w-full overflow-hidden rounded-[14px] border border-edge-faint bg-surface-tertiary">
|
||||
{provider !== 'leaflet' ? (
|
||||
/* A net of its own: the preview is the one place a user flips providers
|
||||
live, so it is the likeliest chunk to fail — and a broken preview must
|
||||
not take the rest of the settings tab with it. */
|
||||
<ErrorBoundary boundaryId="settings:map-preview" resetKeys={[provider]} fallback={<div className="h-full w-full bg-surface-secondary" />}>
|
||||
<Suspense fallback={<div className="h-full w-full animate-pulse bg-surface-secondary" />}>
|
||||
<GlMapPreview
|
||||
provider={provider}
|
||||
token={mapboxToken}
|
||||
style={mapboxStyle}
|
||||
lat={PREVIEW_CENTER[0]}
|
||||
lng={PREVIEW_CENTER[1]}
|
||||
// Zoom in close so the style's character (3D buildings,
|
||||
// satellite texture, label density) is immediately visible.
|
||||
zoom={PREVIEW_ZOOM}
|
||||
enable3d={provider === 'mapbox-gl' && mapbox3d && supports3d}
|
||||
quality={provider === 'mapbox-gl' && mapboxQuality}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
) : (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
React.createElement(MapView as any, {
|
||||
places: previewPlaces,
|
||||
dayPlaces: [],
|
||||
route: null,
|
||||
routeSegments: null,
|
||||
selectedPlaceId: null,
|
||||
onMarkerClick: null,
|
||||
onMapClick: null,
|
||||
onMapContextMenu: null,
|
||||
// With the key on it, or the preview resolves the template as a
|
||||
// keyless CARTO one and quietly shows the app default instead of
|
||||
// the basemap being configured. The fields hold what the user is
|
||||
// editing rather than what useTileUrl already resolved, so the key
|
||||
// has to be put back on here.
|
||||
tileUrl: withTileApiKey(mapTileUrl, cartoKey),
|
||||
fitKey: null,
|
||||
dayOrderMap: [],
|
||||
leftWidth: 0,
|
||||
rightWidth: 0,
|
||||
hasInspector: false,
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button type="button"
|
||||
onClick={saveMapSettings}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
>
|
||||
{saving ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
{t('settings.saveMap')}
|
||||
</button>
|
||||
<div className="flex justify-end">
|
||||
<button type="button"
|
||||
onClick={saveMapSettings}
|
||||
disabled={saving}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{saving ? <span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" /> : <Save size={14} strokeWidth={2} />}
|
||||
{t('settings.saveMap')}
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { isStandardFamily, supportsCustom3d, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
|
||||
import { MAPBOX_DEFAULT_STYLE, normalizeStyleForProvider, type GlMapProvider } from '../Map/glProviders'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
|
||||
interface Props {
|
||||
provider?: GlMapProvider
|
||||
@@ -83,11 +84,11 @@ export default function GlMapPreview({ provider = 'mapbox-gl', token = '', style
|
||||
|
||||
if (!isMapLibre && !token) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full bg-slate-100 dark:bg-slate-800 text-xs text-slate-500 rounded-lg border border-slate-200 dark:border-slate-700">
|
||||
<div className="flex h-full items-center justify-center bg-surface-tertiary px-4 text-center font-medium text-content-muted" style={fs(12)}>
|
||||
Enter a Mapbox access token to preview
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <div ref={containerRef} style={{ width: '100%', height: '100%', borderRadius: '8px', overflow: 'hidden' }} />
|
||||
return <div ref={containerRef} className="h-full w-full overflow-hidden" />
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Plus, MoreHorizontal } from 'lucide-react'
|
||||
import type { NavItemDef } from '../Layout/navItems'
|
||||
|
||||
/** One circle of the dock: the card surface on a hairline ring, the icon in the secondary ink. */
|
||||
const SLOT = 'flex h-8 w-8 flex-none items-center justify-center rounded-full bg-surface-card text-content-secondary shadow-[inset_0_0_0_1px_var(--border-faint)]'
|
||||
|
||||
/**
|
||||
* A live, non-interactive mock of the mobile bottom dock that mirrors the
|
||||
* customizer's current split: Dashboard + bar items around the raised centre
|
||||
@@ -29,8 +32,7 @@ export default function MobileNavPreview({
|
||||
<span
|
||||
key={item.id}
|
||||
title={item.label}
|
||||
className="flex h-8 w-8 flex-none items-center justify-center rounded-full"
|
||||
style={{ background: 'var(--bg-card)', color: 'var(--text-secondary)', boxShadow: 'inset 0 0 0 1px var(--border-faint)' }}
|
||||
className={SLOT}
|
||||
>
|
||||
<Icon size={16} strokeWidth={1.9} />
|
||||
</span>
|
||||
@@ -38,16 +40,10 @@ export default function MobileNavPreview({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center gap-1.5 rounded-full px-3 py-2"
|
||||
style={{ background: 'var(--bg-hover)', border: '1px solid var(--border-faint)' }}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1.5 rounded-full border border-edge-faint bg-surface-hover px-3 py-2">
|
||||
{left.map(circle)}
|
||||
|
||||
<span
|
||||
className="mx-1 flex h-9 w-9 flex-none items-center justify-center rounded-full"
|
||||
style={{ background: 'var(--text-primary)', color: 'var(--bg-card)' }}
|
||||
>
|
||||
<span className="mx-1 flex h-9 w-9 flex-none items-center justify-center rounded-full bg-content text-surface-card shadow-sm">
|
||||
<Plus size={18} strokeWidth={2.4} />
|
||||
</span>
|
||||
|
||||
@@ -55,8 +51,7 @@ export default function MobileNavPreview({
|
||||
{hasMore && (
|
||||
<span
|
||||
title={moreLabel}
|
||||
className="flex h-8 w-8 flex-none items-center justify-center rounded-full"
|
||||
style={{ background: 'var(--bg-card)', color: 'var(--text-secondary)', boxShadow: 'inset 0 0 0 1px var(--border-faint)' }}
|
||||
className={SLOT}
|
||||
>
|
||||
<MoreHorizontal size={16} strokeWidth={1.9} />
|
||||
</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import { Lock } from 'lucide-react'
|
||||
import { Bell, Lock, Plug, Send, Webhook } from 'lucide-react'
|
||||
import { WEB_PUSH_CHANNEL_ID } from '@trek/shared'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { notificationsApi, settingsApi } from '../../api/client'
|
||||
@@ -10,6 +10,9 @@ import Section from './Section'
|
||||
import WebPushCard from './WebPushCard'
|
||||
import { EVENT_LABEL_KEYS, channelLabel, isLockedCell } from './notificationLabels'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { EditorField, GRID_2, INPUT } from '../shared/dialogParts'
|
||||
import { SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY } from './settingsKit'
|
||||
|
||||
interface ChannelDescriptor {
|
||||
id: string
|
||||
@@ -203,199 +206,215 @@ export default function NotificationsTab(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
if (!matrix) return <p style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', fontStyle: 'italic' }}>{t('common.loading')}</p>
|
||||
const renderMatrix = () => {
|
||||
if (!matrix) return <MatrixSkeleton label={t('common.loading')} />
|
||||
|
||||
if (visibleChannels.length === 0) {
|
||||
return (
|
||||
<p style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', fontStyle: 'italic' }}>
|
||||
{t('settings.notificationPreferences.noChannels')}
|
||||
</p>
|
||||
)
|
||||
return <SettingsHint>{t('settings.notificationPreferences.noChannels')}</SettingsHint>
|
||||
}
|
||||
|
||||
// The event column takes what is left; every channel gets the same narrow
|
||||
// column, so the switches line up under their headers.
|
||||
const columns = `minmax(0, 1fr) ${visibleChannels.map(() => '72px').join(' ')}`
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{saving && <p style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginBottom: 8 }}>{t('common.saving')}</p>}
|
||||
{hasChannel('webhook') && (
|
||||
<div style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
|
||||
<label style={{ display: 'block', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
|
||||
{t('settings.webhookUrl.label')}
|
||||
</label>
|
||||
<p style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginBottom: 8 }}>{t('settings.webhookUrl.hint')}</p>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={webhookUrl}
|
||||
onChange={e => setWebhookUrl(e.target.value)}
|
||||
placeholder={webhookIsSet ? '••••••••' : t('settings.webhookUrl.placeholder')}
|
||||
style={{ flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<button type="button"
|
||||
onClick={saveWebhookUrl}
|
||||
disabled={webhookSaving}
|
||||
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', padding: '6px 12px', background: 'var(--text-primary)', color: 'var(--bg-primary)', border: 'none', borderRadius: 6, cursor: webhookSaving ? 'not-allowed' : 'pointer', opacity: webhookSaving ? 0.6 : 1 }}
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={testWebhookUrl}
|
||||
disabled={(!webhookUrl && !webhookIsSet) || webhookTesting}
|
||||
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', padding: '6px 12px', background: 'transparent', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)', borderRadius: 6, cursor: ((!webhookUrl && !webhookIsSet) || webhookTesting) ? 'not-allowed' : 'pointer', opacity: ((!webhookUrl && !webhookIsSet) || webhookTesting) ? 0.5 : 1 }}
|
||||
>
|
||||
{t('settings.webhookUrl.test')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-[12px] border border-edge-faint bg-surface-card">
|
||||
<div className="min-w-fit divide-y divide-edge-faint">
|
||||
{/* Header row */}
|
||||
<div className="grid items-center gap-1 bg-surface-secondary px-3.5 py-2.5" style={{ gridTemplateColumns: columns }}>
|
||||
<span />
|
||||
{visibleChannels.map(ch => (
|
||||
<Tooltip key={ch.id} label={t('settings.notificationPreferences.notConfigured')} disabled={ch.configured}>
|
||||
<span
|
||||
className={`line-clamp-2 break-words text-center font-geist font-bold uppercase leading-tight tracking-[.08em] ${ch.configured ? 'text-content-faint' : 'text-warning'}`}
|
||||
style={fs(10)}
|
||||
>
|
||||
{channelLabel(ch, t)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasChannel('ntfy') && (
|
||||
<div style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
|
||||
<label style={{ display: 'block', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
|
||||
{t('settings.ntfyUrl.topicLabel')}
|
||||
</label>
|
||||
<p style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginBottom: 8 }}>{t('settings.ntfyUrl.hint')}</p>
|
||||
{/* Event rows */}
|
||||
{matrix.event_types.map(eventType => {
|
||||
const implementedForEvent = matrix.implemented_combos[eventType] ?? []
|
||||
const relevantChannels = visibleChannels.filter(ch => implementedForEvent.includes(ch.id))
|
||||
if (relevantChannels.length === 0) return null
|
||||
return (
|
||||
<div key={eventType} className="grid items-center gap-1 px-3.5 py-3" style={{ gridTemplateColumns: columns }}>
|
||||
<span className="min-w-0 pr-2 font-medium text-content" style={fs(13, 'body')}>
|
||||
{t(EVENT_LABEL_KEYS[eventType]) || eventType}
|
||||
</span>
|
||||
{visibleChannels.map(ch => {
|
||||
if (!implementedForEvent.includes(ch.id)) {
|
||||
return <span key={ch.id} className="text-center text-content-faint" style={fs(13, 'body')}>—</span>
|
||||
}
|
||||
const isOn = matrix.preferences[eventType]?.[ch.id] ?? true
|
||||
// Switched off for everyone by the admin (#1536): the same footprint as
|
||||
// the switch, so the grid keeps its columns, and a lock that says why.
|
||||
if (isLockedCell(matrix.locked, eventType, ch.id)) {
|
||||
return (
|
||||
<div key={ch.id} className="flex justify-center">
|
||||
<Tooltip label={t('settings.notificationPreferences.lockedByAdmin')}>
|
||||
<span role="img" aria-label={t('settings.notificationPreferences.lockedByAdmin')} data-locked
|
||||
className="inline-flex h-6 w-11 items-center justify-center rounded-full bg-surface-tertiary text-content-faint">
|
||||
<Lock size={12} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div key={ch.id} className="flex justify-center">
|
||||
<ToggleSwitch on={isOn} onToggle={() => toggle(eventType, ch.id)} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const webhookTestDisabled = (!webhookUrl && !webhookIsSet) || webhookTesting
|
||||
// The channel cards only make sense once there is a matrix to send through.
|
||||
const ready = !!matrix && visibleChannels.length > 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Section
|
||||
title={t('settings.notifications')}
|
||||
icon={Bell}
|
||||
badge={saving ? <StatusPill>{t('common.saving')}</StatusPill> : undefined}
|
||||
>
|
||||
{renderMatrix()}
|
||||
</Section>
|
||||
|
||||
{ready && hasChannel('webhook') && (
|
||||
<SettingsCard icon={Webhook} title={t('settings.webhookUrl.label')} hint={t('settings.webhookUrl.hint')}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
id="notifications-webhook-url"
|
||||
type="text"
|
||||
value={ntfyTopic}
|
||||
onChange={e => setNtfyTopic(e.target.value)}
|
||||
placeholder={t('settings.ntfyUrl.topicPlaceholder')}
|
||||
style={{ width: '100%', boxSizing: 'border-box', fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)', marginBottom: 6 }}
|
||||
value={webhookUrl}
|
||||
onChange={e => setWebhookUrl(e.target.value)}
|
||||
placeholder={webhookIsSet ? '••••••••' : t('settings.webhookUrl.placeholder')}
|
||||
aria-label={t('settings.webhookUrl.label')}
|
||||
className={`${INPUT} min-w-[220px] flex-1`}
|
||||
/>
|
||||
<label style={{ display: 'block', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
|
||||
{t('settings.ntfyUrl.serverLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ntfyServer}
|
||||
onChange={e => setNtfyServer(e.target.value)}
|
||||
placeholder={matrix.defaults?.ntfyServer || t('settings.ntfyUrl.serverPlaceholder')}
|
||||
style={{ width: '100%', boxSizing: 'border-box', fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)', marginBottom: 6 }}
|
||||
/>
|
||||
<label style={{ display: 'block', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
|
||||
{t('settings.ntfyUrl.tokenLabel')}
|
||||
</label>
|
||||
<p style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginBottom: 4 }}>{t('settings.ntfyUrl.tokenHint')}</p>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button type="button" onClick={testWebhookUrl} disabled={webhookTestDisabled} className={SETTINGS_BUTTON} style={fs(13, 'body')}>
|
||||
{t('settings.webhookUrl.test')}
|
||||
</button>
|
||||
<button type="button" onClick={saveWebhookUrl} disabled={webhookSaving} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
{ready && hasChannel('ntfy') && (
|
||||
<SettingsCard icon={Send} title={t('settings.notificationPreferences.ntfy')} hint={t('settings.ntfyUrl.hint')}>
|
||||
<div className={GRID_2}>
|
||||
<EditorField label={t('settings.ntfyUrl.topicLabel')} htmlFor="notifications-ntfy-topic">
|
||||
<input
|
||||
id="notifications-ntfy-topic"
|
||||
type="text"
|
||||
value={ntfyTopic}
|
||||
onChange={e => setNtfyTopic(e.target.value)}
|
||||
placeholder={t('settings.ntfyUrl.topicPlaceholder')}
|
||||
className={INPUT}
|
||||
/>
|
||||
</EditorField>
|
||||
<EditorField label={t('settings.ntfyUrl.serverLabel')} htmlFor="notifications-ntfy-server">
|
||||
<input
|
||||
id="notifications-ntfy-server"
|
||||
type="text"
|
||||
value={ntfyServer}
|
||||
onChange={e => setNtfyServer(e.target.value)}
|
||||
placeholder={matrix?.defaults?.ntfyServer || t('settings.ntfyUrl.serverPlaceholder')}
|
||||
className={INPUT}
|
||||
/>
|
||||
</EditorField>
|
||||
</div>
|
||||
<EditorField label={t('settings.ntfyUrl.tokenLabel')} htmlFor="notifications-ntfy-token" hint={t('settings.ntfyUrl.tokenHint')}>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="notifications-ntfy-token"
|
||||
type="password"
|
||||
value={ntfyToken}
|
||||
onChange={e => setNtfyToken(e.target.value)}
|
||||
placeholder={ntfyTokenIsSet ? '••••••••' : ''}
|
||||
style={{ flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)' }}
|
||||
className={`${INPUT} flex-1`}
|
||||
/>
|
||||
{ntfyTokenIsSet && (
|
||||
<button type="button"
|
||||
onClick={clearNtfyToken}
|
||||
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', padding: '6px 12px', background: 'transparent', color: 'var(--color-danger, #e53e3e)', border: '1px solid var(--color-danger, #e53e3e)', borderRadius: 6, cursor: 'pointer' }}
|
||||
>
|
||||
<button type="button" onClick={clearNtfyToken} className={SETTINGS_BUTTON_DANGER} style={fs(13, 'body')}>
|
||||
{t('common.clear')}
|
||||
</button>
|
||||
)}
|
||||
<button type="button"
|
||||
onClick={saveNtfySettings}
|
||||
disabled={ntfySaving}
|
||||
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', padding: '6px 12px', background: 'var(--text-primary)', color: 'var(--bg-primary)', border: 'none', borderRadius: 6, cursor: ntfySaving ? 'not-allowed' : 'pointer', opacity: ntfySaving ? 0.6 : 1 }}
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={testNtfySettings}
|
||||
disabled={!ntfyTopic || ntfyTesting}
|
||||
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', padding: '6px 12px', background: 'transparent', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)', borderRadius: 6, cursor: (!ntfyTopic || ntfyTesting) ? 'not-allowed' : 'pointer', opacity: (!ntfyTopic || ntfyTesting) ? 0.5 : 1 }}
|
||||
>
|
||||
{t('settings.ntfyUrl.test')}
|
||||
</button>
|
||||
</div>
|
||||
</EditorField>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<button type="button" onClick={testNtfySettings} disabled={!ntfyTopic || ntfyTesting} className={SETTINGS_BUTTON} style={fs(13, 'body')}>
|
||||
{t('settings.ntfyUrl.test')}
|
||||
</button>
|
||||
<button type="button" onClick={saveNtfySettings} disabled={ntfySaving} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Per device rather than per account: the column below picks the events,
|
||||
this card decides whether this browser is one of the places they go. */}
|
||||
{hasChannel(WEB_PUSH_CHANNEL_ID) && <WebPushCard />}
|
||||
{pluginChannels.map(ch => (
|
||||
<div key={ch.id} style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
|
||||
<label style={{ display: 'block', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
|
||||
{channelLabel(ch, t)}
|
||||
</label>
|
||||
<p style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginBottom: 8 }}>
|
||||
{ch.configured
|
||||
? t('settings.notificationPreferences.pluginConfigured')
|
||||
: t('settings.notificationPreferences.notConfigured')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
{/* Per device rather than per account: the matrix above picks the events,
|
||||
this card decides whether this browser is one of the places they go. */}
|
||||
{ready && hasChannel(WEB_PUSH_CHANNEL_ID) && <WebPushCard />}
|
||||
|
||||
{ready && pluginChannels.map(ch => (
|
||||
<SettingsCard
|
||||
key={ch.id}
|
||||
icon={Plug}
|
||||
title={channelLabel(ch, t)}
|
||||
hint={ch.configured
|
||||
? t('settings.notificationPreferences.pluginConfigured')
|
||||
: t('settings.notificationPreferences.notConfigured')}
|
||||
action={
|
||||
<>
|
||||
{/* Unconfigured is the common case on first use — send them to where the
|
||||
credentials actually live rather than just naming the place. */}
|
||||
{!ch.configured && ch.settingsPath && (
|
||||
<Link
|
||||
to={ch.settingsPath}
|
||||
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', padding: '6px 12px', background: 'var(--text-primary)', color: 'var(--bg-primary)', border: 'none', borderRadius: 6, textDecoration: 'none' }}
|
||||
>
|
||||
<Link to={ch.settingsPath} className={`${SETTINGS_BUTTON_PRIMARY} no-underline`} style={fs(13, 'body')}>
|
||||
{t('settings.notificationPreferences.configure')}
|
||||
</Link>
|
||||
)}
|
||||
<button type="button"
|
||||
onClick={() => testChannel(ch)}
|
||||
disabled={!ch.configured || channelTesting === ch.id}
|
||||
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', padding: '6px 12px', background: 'transparent', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)', borderRadius: 6, cursor: (!ch.configured || channelTesting === ch.id) ? 'not-allowed' : 'pointer', opacity: (!ch.configured || channelTesting === ch.id) ? 0.5 : 1 }}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{t('settings.notificationPreferences.sendTest')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Header row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '64px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4, borderBottom: '1px solid var(--border-primary)' }}>
|
||||
<span />
|
||||
{visibleChannels.map(ch => (
|
||||
<span key={ch.id} title={!ch.configured ? t('settings.notificationPreferences.notConfigured') : undefined} style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: ch.configured ? 'var(--text-faint)' : 'var(--color-warning, #d69e2e)', textAlign: 'center', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||
{channelLabel(ch, t)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Event rows */}
|
||||
{matrix.event_types.map(eventType => {
|
||||
const implementedForEvent = matrix.implemented_combos[eventType] ?? []
|
||||
const relevantChannels = visibleChannels.filter(ch => implementedForEvent.includes(ch.id))
|
||||
if (relevantChannels.length === 0) return null
|
||||
return (
|
||||
<div key={eventType} style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '64px').join(' ')}`, gap: 4, alignItems: 'center', padding: '6px 0', borderBottom: '1px solid var(--border-primary)' }}>
|
||||
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-primary)' }}>
|
||||
{t(EVENT_LABEL_KEYS[eventType]) || eventType}
|
||||
</span>
|
||||
{visibleChannels.map(ch => {
|
||||
if (!implementedForEvent.includes(ch.id)) {
|
||||
return <span key={ch.id} style={{ textAlign: 'center', color: 'var(--text-faint)', fontSize: 'calc(14px * var(--fs-scale-body, 1))' }}>—</span>
|
||||
}
|
||||
const isOn = matrix.preferences[eventType]?.[ch.id] ?? true
|
||||
// Switched off for everyone by the admin (#1536): the same footprint as
|
||||
// the switch, so the grid keeps its columns, and a lock that says why.
|
||||
if (isLockedCell(matrix.locked, eventType, ch.id)) {
|
||||
return (
|
||||
<div key={ch.id} style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Tooltip label={t('settings.notificationPreferences.lockedByAdmin')}>
|
||||
<span role="img" aria-label={t('settings.notificationPreferences.lockedByAdmin')} data-locked
|
||||
className="inline-flex h-6 w-11 items-center justify-center rounded-full bg-surface-tertiary text-content-faint">
|
||||
<Lock size={12} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div key={ch.id} style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<ToggleSwitch on={isOn} onToggle={() => toggle(eventType, ch.id)} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t('settings.notifications')} icon={Lock}>
|
||||
{renderContent()}
|
||||
</Section>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A calm placeholder for the matrix while the preferences load. */
|
||||
function MatrixSkeleton({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2" aria-busy="true">
|
||||
<SettingsHint>{label}</SettingsHint>
|
||||
<div className="divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
|
||||
{[0, 1, 2].map(i => (
|
||||
<div key={i} className="flex items-center gap-4 px-3.5 py-3">
|
||||
<span className="h-3 flex-1 animate-pulse rounded-full bg-surface-tertiary" />
|
||||
<span className="h-6 w-11 animate-pulse rounded-full bg-surface-tertiary" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ function setOnLine(value: boolean): void {
|
||||
}
|
||||
|
||||
/** The Section wrapper card that carries the given heading. */
|
||||
const card = (title: string) => screen.getByText(title).closest('div.rounded-xl') as HTMLElement;
|
||||
const card = (title: string) => screen.getByText(title).closest('section') as HTMLElement;
|
||||
|
||||
/** The Stat tile that carries the given label. */
|
||||
const stat = (label: string) => screen.getByText(label).parentElement as HTMLElement;
|
||||
@@ -272,12 +272,14 @@ describe('OfflineTab', () => {
|
||||
render(<OfflineTab />);
|
||||
|
||||
await screen.findByText('When a conflict happens');
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
expect(select.value).toBe('ask');
|
||||
|
||||
await user.selectOptions(select, 'server');
|
||||
// A CustomSelect: the row's label names the trigger, the options open in a portal.
|
||||
const trigger = screen.getByRole('button', { name: 'When a conflict happens' });
|
||||
expect(trigger).toHaveTextContent('Ask me each time');
|
||||
await user.click(trigger);
|
||||
const choices = await screen.findAllByRole('button', { name: 'Always keep the server version' });
|
||||
await user.click(choices[choices.length - 1]);
|
||||
expect(getOfflinePrefs().conflictStrategy).toBe('server');
|
||||
await waitFor(() => expect(select.value).toBe('server'));
|
||||
await waitFor(() => expect(trigger).toHaveTextContent('Always keep the server version'));
|
||||
});
|
||||
|
||||
it('FE-COMP-OFFLINETAB-011: Prepare renders live progress and the done marker', async () => {
|
||||
|
||||
@@ -14,11 +14,16 @@ import React from 'react'
|
||||
import { RefreshCw, Trash2, Database, CloudOff, Download, Check, GitMerge, Map as MapIcon, AlertTriangle } from 'lucide-react'
|
||||
import Section from './Section'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import { SettingRow, SettingRows, SettingsHint, StatusPill, SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER } from './settingsKit'
|
||||
import { useOfflineSettings, offlineNoticeKey, isOfflineNoticeWarning } from './useOfflineSettings'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import type { ConflictStrategy } from '../../sync/offlinePrefs'
|
||||
import type { QueuedMutation } from '../../db/offlineDb'
|
||||
|
||||
const EYEBROW = 'm-0 mb-2 font-geist font-bold uppercase tracking-[.08em] text-content-faint'
|
||||
|
||||
function conflictName(m: QueuedMutation): string {
|
||||
const body = (m.body ?? {}) as { name?: unknown }
|
||||
const server = (m.conflictServer ?? {}) as { name?: unknown }
|
||||
@@ -49,242 +54,219 @@ export default function OfflineTab(): React.ReactElement {
|
||||
<div>
|
||||
{/* Offline mode + prepare */}
|
||||
<Section title={t('settings.offline.mode.title')} icon={CloudOff}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<Row
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
label={t('settings.offline.mode.force')}
|
||||
hint={t('settings.offline.mode.forceHint')}
|
||||
control={<ToggleSwitch on={forced} onToggle={handleToggleForce} label={t('settings.offline.mode.force')} />}
|
||||
/>
|
||||
{forced && (
|
||||
<p className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', margin: 0 }}>
|
||||
{t('settings.offline.mode.active')}
|
||||
</p>
|
||||
)}
|
||||
>
|
||||
{forced && (
|
||||
<p className="m-0 rounded-[10px] bg-warning-soft px-3 py-2 text-warning" style={fs(12, 'body')}>
|
||||
{t('settings.offline.mode.active')}
|
||||
</p>
|
||||
)}
|
||||
</SettingRow>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border-secondary, #e5e7eb)', paddingTop: 16 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 'calc(14px * var(--fs-scale-body, 1))', marginBottom: 4 }} className="text-content">
|
||||
{t('settings.offline.prepare.title')}
|
||||
</div>
|
||||
<p className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 0, marginBottom: 12 }}>
|
||||
{t('settings.offline.prepare.hint')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button type="button"
|
||||
onClick={runPrepare}
|
||||
disabled={preparing || offline}
|
||||
className="border border-edge bg-surface-secondary text-content"
|
||||
style={btnStyle(preparing || offline)}
|
||||
>
|
||||
{preparing
|
||||
? <RefreshCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
: <Download size={14} />}
|
||||
{preparing ? t('settings.offline.prepare.running') : t('settings.offline.prepare.button')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={handleResync}
|
||||
disabled={syncing || offline}
|
||||
className="border border-edge bg-surface-secondary text-content"
|
||||
style={btnStyle(syncing || offline)}
|
||||
>
|
||||
<RefreshCw size={14} style={syncing ? { animation: 'spin 1s linear infinite' } : {}} />
|
||||
{syncing ? t('settings.offline.resyncing') : t('settings.offline.resync')}
|
||||
</button>
|
||||
</div>
|
||||
{preparing && progress && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div style={{ height: 6, borderRadius: 3, overflow: 'hidden', background: 'var(--border-primary, #e5e7eb)' }}>
|
||||
<div style={{
|
||||
height: '100%', borderRadius: 3, background: 'var(--accent, #4F46E5)',
|
||||
width: `${progress.total ? Math.round((progress.current / progress.total) * 100) : 100}%`,
|
||||
transition: 'width 0.2s',
|
||||
}} />
|
||||
</div>
|
||||
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 4 }}>
|
||||
{progressLabel}{progress.label ? ` · ${progress.label}` : ''}
|
||||
</div>
|
||||
<SettingRow label={t('settings.offline.prepare.title')} hint={t('settings.offline.prepare.hint')} stacked>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button type="button"
|
||||
onClick={runPrepare}
|
||||
disabled={preparing || offline}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{preparing
|
||||
? <RefreshCw size={14} className="animate-spin" />
|
||||
: <Download size={14} />}
|
||||
{preparing ? t('settings.offline.prepare.running') : t('settings.offline.prepare.button')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={handleResync}
|
||||
disabled={syncing || offline}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
<RefreshCw size={14} className={syncing ? 'animate-spin' : undefined} />
|
||||
{syncing ? t('settings.offline.resyncing') : t('settings.offline.resync')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!preparing && notice && notice.kind !== 'load-failed' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 10, color: isOfflineNoticeWarning(notice) ? 'var(--warning)' : 'var(--success)' }}>
|
||||
{isOfflineNoticeWarning(notice) ? <AlertTriangle size={14} /> : <Check size={14} />}
|
||||
{t(offlineNoticeKey(notice), notice.kind === 'stored' ? { count: notice.trips } : undefined)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{preparing && progress && (
|
||||
<div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-surface-tertiary">
|
||||
<div
|
||||
className="h-full rounded-full bg-accent transition-[width] duration-200"
|
||||
style={{ width: `${progress.total ? Math.round((progress.current / progress.total) * 100) : 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1.5 font-geist tabular-nums text-content-muted" style={fs(11)}>
|
||||
{progressLabel}{progress.label ? ` · ${progress.label}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!preparing && notice && notice.kind !== 'load-failed' && (
|
||||
<div
|
||||
className={`flex items-center gap-1.5 ${isOfflineNoticeWarning(notice) ? 'text-warning' : 'text-success'}`}
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{isOfflineNoticeWarning(notice) ? <AlertTriangle size={14} className="flex-none" /> : <Check size={14} className="flex-none" />}
|
||||
{t(offlineNoticeKey(notice), notice.kind === 'stored' ? { count: notice.trips } : undefined)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
</SettingRows>
|
||||
</Section>
|
||||
|
||||
{/* Conflicts (only when there are any) */}
|
||||
{conflicts.length > 0 && (
|
||||
<Section title={t('settings.offline.conflicts.title')} icon={GitMerge}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', margin: 0 }}>
|
||||
{t('settings.offline.conflicts.hint')}
|
||||
</p>
|
||||
<Section
|
||||
title={t('settings.offline.conflicts.title')}
|
||||
icon={GitMerge}
|
||||
badge={<StatusPill tone="danger">{conflicts.length}</StatusPill>}
|
||||
>
|
||||
<SettingsHint>{t('settings.offline.conflicts.hint')}</SettingsHint>
|
||||
<SettingRows>
|
||||
{conflicts.map(c => (
|
||||
<div key={c.id} className="border border-edge bg-surface-secondary" style={{ padding: '10px 14px', borderRadius: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<span className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500 }}>
|
||||
<div key={c.id} className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-3.5 py-3">
|
||||
<span className="min-w-0 flex-1 basis-48 truncate font-medium text-content" style={fs(13, 'body')}>
|
||||
{t('settings.offline.conflicts.item', { name: conflictName(c) })}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" onClick={() => resolveConflict(c.id, true)} className="border border-edge bg-surface-card text-content" style={smallBtnStyle()}>
|
||||
<div className="flex flex-none gap-2">
|
||||
<button type="button" onClick={() => resolveConflict(c.id, true)} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
|
||||
{t('settings.offline.conflicts.keepMine')}
|
||||
</button>
|
||||
<button type="button" onClick={() => resolveConflict(c.id, false)} className="border border-edge bg-surface-card text-content" style={smallBtnStyle()}>
|
||||
<button type="button" onClick={() => resolveConflict(c.id, false)} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
|
||||
{t('settings.offline.conflicts.keepServer')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Row
|
||||
</SettingRows>
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
label={t('settings.offline.conflicts.strategyTitle')}
|
||||
htmlFor="offline-conflict-strategy"
|
||||
control={
|
||||
<select
|
||||
<CustomSelect
|
||||
id="offline-conflict-strategy"
|
||||
value={prefs.conflictStrategy}
|
||||
onChange={e => handleConflictStrategy(e.target.value as ConflictStrategy)}
|
||||
className="border border-edge bg-surface-secondary text-content"
|
||||
style={{ padding: '6px 10px', borderRadius: 8, fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}
|
||||
>
|
||||
<option value="ask">{t('settings.offline.conflicts.strategy.ask')}</option>
|
||||
<option value="mine">{t('settings.offline.conflicts.strategy.mine')}</option>
|
||||
<option value="server">{t('settings.offline.conflicts.strategy.server')}</option>
|
||||
</select>
|
||||
onChange={v => handleConflictStrategy(v as ConflictStrategy)}
|
||||
options={[
|
||||
{ value: 'ask', label: t('settings.offline.conflicts.strategy.ask') },
|
||||
{ value: 'mine', label: t('settings.offline.conflicts.strategy.mine') },
|
||||
{ value: 'server', label: t('settings.offline.conflicts.strategy.server') },
|
||||
]}
|
||||
menuFit="content"
|
||||
style={{ minWidth: 240 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SettingRows>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* What to store offline */}
|
||||
<Section title={t('settings.offline.storage.title')} icon={MapIcon}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<Row
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
label={t('settings.offline.storage.tiles')}
|
||||
hint={t('settings.offline.storage.tilesHint')}
|
||||
control={<ToggleSwitch on={prefs.cacheTiles} onToggle={handleToggleTiles} label={t('settings.offline.storage.tiles')} />}
|
||||
/>
|
||||
{allTrips.length > 0 && (
|
||||
<div style={{ borderTop: '1px solid var(--border-secondary, #e5e7eb)', paddingTop: 16 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', marginBottom: 8 }} className="text-content">
|
||||
{t('settings.offline.storage.tripsTitle')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{allTrips.map((trip) => {
|
||||
const { on, dateEligible } = tripStorageState(trip)
|
||||
return (
|
||||
<div key={trip.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{trip.title}
|
||||
</div>
|
||||
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>
|
||||
{on ? t('settings.offline.storage.tripOn') : t('settings.offline.storage.tripOff')}
|
||||
{!dateEligible && ` · ${t('settings.offline.storage.tripFinished')}`}
|
||||
</div>
|
||||
</SettingRows>
|
||||
{allTrips.length > 0 && (
|
||||
<div>
|
||||
<p className={EYEBROW} style={fs(10)}>{t('settings.offline.storage.tripsTitle')}</p>
|
||||
<SettingRows>
|
||||
{allTrips.map((trip) => {
|
||||
const { on, dateEligible } = tripStorageState(trip)
|
||||
return (
|
||||
<div key={trip.id} className="flex items-center justify-between gap-4 px-3.5 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-content" style={fs(13, 'body')}>
|
||||
{trip.title}
|
||||
</div>
|
||||
<div className={on ? 'text-success' : 'text-content-faint'} style={fs(11.5)}>
|
||||
{on ? t('settings.offline.storage.tripOn') : t('settings.offline.storage.tripOff')}
|
||||
{!dateEligible && ` · ${t('settings.offline.storage.tripFinished')}`}
|
||||
</div>
|
||||
<ToggleSwitch on={on} onToggle={() => handleToggleTrip(trip)} label={trip.title} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ToggleSwitch on={on} onToggle={() => handleToggleTrip(trip)} label={trip.title} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</SettingRows>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Cache stats + list + clear */}
|
||||
<Section title={t('settings.offline.cache.title')} icon={Database}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Stat label={t('settings.offline.stats.trips')} value={rows.length} />
|
||||
<Stat label={t('settings.offline.stats.pending')} value={pendingCount} />
|
||||
{conflicts.length > 0 && <Stat label={t('settings.offline.stats.conflicts')} value={conflicts.length} danger />}
|
||||
{failedCount > 0 && <Stat label={t('settings.offline.stats.failed')} value={failedCount} danger />}
|
||||
</div>
|
||||
<Section
|
||||
title={t('settings.offline.cache.title')}
|
||||
icon={Database}
|
||||
action={
|
||||
<button type="button"
|
||||
// ConfirmDialog would be the planner's way; the tab's tests pin window.confirm.
|
||||
onClick={() => { if (window.confirm(t('settings.offline.clearConfirm'))) void handleClear() }}
|
||||
disabled={clearing || !canClear}
|
||||
className={SETTINGS_BUTTON_DANGER}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('settings.offline.clear')}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Stat label={t('settings.offline.stats.trips')} value={rows.length} />
|
||||
<Stat label={t('settings.offline.stats.pending')} value={pendingCount} />
|
||||
{conflicts.length > 0 && <Stat label={t('settings.offline.stats.conflicts')} value={conflicts.length} danger />}
|
||||
{failedCount > 0 && <Stat label={t('settings.offline.stats.failed')} value={failedCount} danger />}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button"
|
||||
onClick={() => { if (window.confirm(t('settings.offline.clearConfirm'))) void handleClear() }}
|
||||
disabled={clearing || !canClear}
|
||||
className="border border-edge bg-surface-secondary text-[#ef4444]"
|
||||
style={btnStyle(clearing || !canClear)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('settings.offline.clear')}
|
||||
</button>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-content-faint" style={fs(12, 'body')}>
|
||||
<RefreshCw size={13} className="animate-spin" />
|
||||
<span>{t('settings.offline.loading')}</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{t('settings.offline.loading')}</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
|
||||
{t(notice?.kind === 'load-failed' ? 'settings.offline.notice.loadFailed' : 'settings.offline.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{rows.map(({ trip, meta, placeCount, fileCount }) => (
|
||||
<div
|
||||
key={trip.id}
|
||||
className="border border-edge bg-surface-secondary"
|
||||
style={{ padding: '10px 14px', borderRadius: 8, display: 'flex', flexDirection: 'column', gap: 2 }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span className="text-content" style={{ fontWeight: 600, fontSize: 'calc(14px * var(--fs-scale-body, 1))' }}>
|
||||
{trip.title}
|
||||
</span>
|
||||
<span className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>
|
||||
{meta.lastSyncedAt
|
||||
? new Date(meta.lastSyncedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
|
||||
) : rows.length === 0 ? (
|
||||
<SettingsHint>
|
||||
{t(notice?.kind === 'load-failed' ? 'settings.offline.notice.loadFailed' : 'settings.offline.empty')}
|
||||
</SettingsHint>
|
||||
) : (
|
||||
<SettingRows>
|
||||
{rows.map(({ trip, meta, placeCount, fileCount }) => (
|
||||
<div key={trip.id} className="flex items-center justify-between gap-4 px-3.5 py-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="truncate font-semibold text-content" style={fs(13, 'body')}>
|
||||
{trip.title}
|
||||
</span>
|
||||
<span className="truncate font-geist tabular-nums text-content-faint" style={fs(11.5)}>
|
||||
{formatDate(trip.start_date)} – {formatDate(trip.end_date)}
|
||||
{' · '}{placeCount}{' · '}{fileCount}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="flex-none font-geist tabular-nums text-content-muted" style={fs(11.5)}>
|
||||
{meta.lastSyncedAt
|
||||
? new Date(meta.lastSyncedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</SettingRows>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function btnStyle(disabled: boolean): React.CSSProperties {
|
||||
return {
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px', borderRadius: 8,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, opacity: disabled ? 0.5 : 1,
|
||||
}
|
||||
}
|
||||
|
||||
function smallBtnStyle(): React.CSSProperties {
|
||||
return {
|
||||
padding: '6px 12px', borderRadius: 8, cursor: 'pointer',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
}
|
||||
}
|
||||
|
||||
function Row({ label, hint, control }: { label: string; hint?: string; control: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontWeight: 500, fontSize: 'calc(14px * var(--fs-scale-body, 1))' }}>{label}</div>
|
||||
{hint && <div className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 2 }}>{hint}</div>}
|
||||
</div>
|
||||
<div style={{ flexShrink: 0 }}>{control}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One counter tile: the number big, what it counts under it. */
|
||||
function Stat({ label, value, danger }: { label: string; value: number; danger?: boolean }) {
|
||||
return (
|
||||
<div className="border border-edge bg-surface-secondary" style={{ padding: '8px 14px', borderRadius: 8, minWidth: 100 }}>
|
||||
<div style={{ fontSize: 'calc(20px * var(--fs-scale-title, 1))', fontWeight: 700, color: danger ? '#ef4444' : undefined }}
|
||||
className={danger ? undefined : 'text-content'}>{value}</div>
|
||||
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>{label}</div>
|
||||
<div className="min-w-0 rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3">
|
||||
<div className={`font-geist font-bold tabular-nums ${danger ? 'text-danger' : 'text-content'}`} style={fs(20, 'subtitle')}>{value}</div>
|
||||
<div className="truncate text-content-faint" style={fs(11.5)}>{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Fingerprint, Plus, Trash2, Pencil, Check, X } from 'lucide-react'
|
||||
import { Fingerprint, Plus, Trash2, Pencil, Check, X, AlertTriangle } from 'lucide-react'
|
||||
import { startRegistration } from '@simplewebauthn/browser'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { authApi, type PasskeyCredential } from '../../api/client'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import Section from './Section'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { GRID_2, INPUT, PANEL } from '../shared/dialogParts'
|
||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SettingsHint, StatusPill } from './settingsKit'
|
||||
|
||||
/** The step-up's final answer: the danger fill, white on red like ConfirmDialog's. */
|
||||
const DANGER_FILL = 'inline-flex items-center justify-center gap-1.5 rounded-[10px] bg-danger px-4 py-2 font-medium text-white hover:opacity-90 disabled:cursor-default disabled:opacity-50' // theme-lint-disable: white on the danger fill, as ConfirmDialog draws it
|
||||
|
||||
/** Parse a SQLite UTC timestamp ("YYYY-MM-DD HH:MM:SS") into a local date string. */
|
||||
function fmtDate(ts: string | null): string | null {
|
||||
@@ -113,159 +121,183 @@ export default function PasskeysSection({ demoMode }: { demoMode?: boolean }): R
|
||||
if (!loading && !enabled && creds.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="pt-4 mt-4 border-t border-edge-secondary">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Fingerprint className="w-5 h-5 text-content-secondary" />
|
||||
<h3 className="font-semibold text-base m-0 text-content">{t('settings.passkey.title')}</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm m-0 text-content-muted" style={{ lineHeight: 1.5 }}>{t('settings.passkey.description')}</p>
|
||||
<Section
|
||||
title={t('settings.passkey.title')}
|
||||
icon={Fingerprint}
|
||||
badge={creds.length > 0 ? <StatusPill>{creds.length}</StatusPill> : undefined}
|
||||
// The way in sits on the band, like the planner's "add" actions; the step-up opens in the body.
|
||||
action={canAdd && !addOpen ? (
|
||||
<button type="button" onClick={() => setAddOpen(true)} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
|
||||
<Plus size={14} />
|
||||
{t('settings.passkey.add')}
|
||||
</button>
|
||||
) : undefined}
|
||||
>
|
||||
<SettingsHint>{t('settings.passkey.description')}</SettingsHint>
|
||||
|
||||
{enabled && !configured && (
|
||||
<p className="text-sm m-0 text-amber-700">{t('settings.passkey.notConfigured')}</p>
|
||||
)}
|
||||
{enabled && !configured && (
|
||||
<div className="flex gap-2.5 rounded-[12px] bg-warning-soft px-3.5 py-2.5 text-content" style={fs(12.5, 'body')}>
|
||||
<AlertTriangle size={15} className="mt-px flex-none text-warning" />
|
||||
<p className="m-0 leading-snug">{t('settings.passkey.notConfigured')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{creds.length > 0 && (
|
||||
<ul className="space-y-2 list-none p-0 m-0">
|
||||
{creds.map(c => (
|
||||
<li key={c.id} className="flex items-center gap-3 p-3 rounded-lg border border-edge bg-surface-card">
|
||||
<Fingerprint className="w-4 h-4 flex-shrink-0 text-content-secondary" />
|
||||
<div className="flex-1 min-w-0">
|
||||
{renamingId === c.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={renameVal}
|
||||
onChange={e => setRenameVal(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') void handleRename(c.id); if (e.key === 'Escape') setRenamingId(null) }}
|
||||
className="flex-1 px-2 py-1 border border-slate-300 rounded text-sm"
|
||||
/>
|
||||
<button type="button" onClick={() => handleRename(c.id)} className="p-1 text-emerald-600" aria-label={t('common.save')}><Check size={16} /></button>
|
||||
<button type="button" onClick={() => setRenamingId(null)} className="p-1 text-content-muted" aria-label={t('common.cancel')}><X size={16} /></button>
|
||||
{creds.length > 0 && (
|
||||
<ul className="m-0 list-none divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card p-0">
|
||||
{creds.map(c => (
|
||||
<li key={c.id} className="flex items-center gap-3 px-3.5 py-3">
|
||||
<span className="grid h-9 w-9 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary">
|
||||
<Fingerprint size={16} strokeWidth={1.9} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
{renamingId === c.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={renameVal}
|
||||
onChange={e => setRenameVal(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') void handleRename(c.id); if (e.key === 'Escape') setRenamingId(null) }}
|
||||
aria-label={t('settings.passkey.rename')}
|
||||
className={`${INPUT} flex-1`}
|
||||
/>
|
||||
<Tooltip label={t('common.save')}>
|
||||
<button type="button" onClick={() => handleRename(c.id)} className={`${SETTINGS_ICON_BUTTON} !text-success`} aria-label={t('common.save')}>
|
||||
<Check size={15} strokeWidth={2.4} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('common.cancel')}>
|
||||
<button type="button" onClick={() => setRenamingId(null)} className={SETTINGS_ICON_BUTTON} aria-label={t('common.cancel')}>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 truncate font-semibold text-content" style={fs(13, 'body')}>{c.name || t('settings.passkey.defaultName')}</span>
|
||||
<StatusPill tone={c.backed_up ? 'success' : 'neutral'}>
|
||||
{c.backed_up ? t('settings.passkey.synced') : t('settings.passkey.deviceBound')}
|
||||
</StatusPill>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-content truncate">{c.name || t('settings.passkey.defaultName')}</span>
|
||||
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-surface-hover text-content-secondary">
|
||||
{c.backed_up ? t('settings.passkey.synced') : t('settings.passkey.deviceBound')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs m-0 mt-0.5 text-content-faint">
|
||||
{t('settings.passkey.added')}: {fmtDate(c.created_at) || '—'}
|
||||
{' · '}
|
||||
{c.last_used_at
|
||||
? `${t('settings.passkey.lastUsed')}: ${fmtDate(c.last_used_at)}`
|
||||
: t('settings.passkey.neverUsed')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{renamingId !== c.id && (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<p className="m-0 mt-0.5 truncate tabular-nums text-content-faint" style={fs(11.5)}>
|
||||
{t('settings.passkey.added')}: {fmtDate(c.created_at) || '—'}
|
||||
{' · '}
|
||||
{c.last_used_at
|
||||
? `${t('settings.passkey.lastUsed')}: ${fmtDate(c.last_used_at)}`
|
||||
: t('settings.passkey.neverUsed')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{renamingId !== c.id && (
|
||||
<div className="flex flex-none items-center gap-1.5">
|
||||
<Tooltip label={t('settings.passkey.rename')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setRenamingId(c.id); setRenameVal(c.name || '') }}
|
||||
className="p-1.5 rounded text-content-muted hover:text-content"
|
||||
className={SETTINGS_ICON_BUTTON}
|
||||
aria-label={t('settings.passkey.rename')}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('common.delete')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setDeletingId(c.id); setDeletePwd('') }}
|
||||
className="p-1.5 rounded text-red-500 hover:bg-red-50"
|
||||
className={`${SETTINGS_ICON_BUTTON} hover:!bg-danger-soft hover:!text-danger`}
|
||||
aria-label={t('common.delete')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation (password step-up) */}
|
||||
{deletingId !== null && (
|
||||
<div className="space-y-2 p-3 rounded-lg border border-red-200 bg-red-50/40">
|
||||
<p className="text-sm font-medium m-0 text-content">{t('settings.passkey.deleteConfirm')}</p>
|
||||
<input
|
||||
type="password"
|
||||
value={deletePwd}
|
||||
onChange={e => setDeletePwd(e.target.value)}
|
||||
placeholder={t('settings.currentPassword')}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !deletePwd}
|
||||
onClick={() => handleDelete(deletingId)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-red-600 border border-red-200 hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setDeletingId(null); setDeletePwd('') }}
|
||||
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
{/* Delete confirmation (password step-up) */}
|
||||
{deletingId !== null && (
|
||||
<div className="flex flex-col gap-3 rounded-[14px] border border-edge-faint bg-danger-soft p-3.5">
|
||||
<p className="m-0 font-semibold text-content" style={fs(13, 'body')}>{t('settings.passkey.deleteConfirm')}</p>
|
||||
<input
|
||||
type="password"
|
||||
value={deletePwd}
|
||||
onChange={e => setDeletePwd(e.target.value)}
|
||||
placeholder={t('settings.currentPassword')}
|
||||
aria-label={t('settings.currentPassword')}
|
||||
className={INPUT}
|
||||
/>
|
||||
<div className="flex justify-end gap-2" style={fs(13, 'body')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setDeletingId(null); setDeletePwd('') }}
|
||||
className={SETTINGS_BUTTON}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !deletePwd}
|
||||
onClick={() => handleDelete(deletingId)}
|
||||
className={DANGER_FILL}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add a passkey */}
|
||||
{canAdd && (addOpen ? (
|
||||
<div className="space-y-2 p-3 rounded-lg border border-edge bg-surface-hover">
|
||||
<p className="text-sm font-medium m-0 text-content">{t('settings.passkey.addTitle')}</p>
|
||||
<p className="text-xs m-0 text-content-muted">{t('settings.passkey.passwordPrompt')}</p>
|
||||
{/* Add a passkey */}
|
||||
{canAdd && addOpen && (
|
||||
<div className={PANEL}>
|
||||
<div>
|
||||
<p className="m-0 font-semibold text-content" style={fs(13, 'body')}>{t('settings.passkey.addTitle')}</p>
|
||||
<SettingsHint className="mt-0.5">{t('settings.passkey.passwordPrompt')}</SettingsHint>
|
||||
</div>
|
||||
<div className={GRID_2}>
|
||||
<input
|
||||
type="password"
|
||||
value={addPwd}
|
||||
onChange={e => setAddPwd(e.target.value)}
|
||||
placeholder={t('settings.currentPassword')}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
aria-label={t('settings.currentPassword')}
|
||||
className={INPUT}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={addName}
|
||||
onChange={e => setAddName(e.target.value)}
|
||||
placeholder={t('settings.passkey.namePlaceholder')}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
aria-label={t('settings.passkey.namePlaceholder')}
|
||||
className={INPUT}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !addPwd}
|
||||
onClick={handleAdd}
|
||||
className="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:opacity-50"
|
||||
>
|
||||
{busy ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : t('settings.passkey.add')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setAddOpen(false); setAddPwd(''); setAddName('') }}
|
||||
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors border border-edge bg-surface-card text-content"
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t('settings.passkey.add')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2" style={fs(13, 'body')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setAddOpen(false); setAddPwd(''); setAddName('') }}
|
||||
className={SETTINGS_BUTTON}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !addPwd}
|
||||
onClick={handleAdd}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
>
|
||||
{busy
|
||||
? <span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
: <><Fingerprint size={14} />{t('settings.passkey.add')}</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Camera, Save } from 'lucide-react'
|
||||
import { Camera, Loader2, Plug, Save } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../../components/shared/Toast'
|
||||
import apiClient from '../../api/client'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import Section from './Section'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, StatusPill } from './settingsKit'
|
||||
import { EditorField, INPUT } from '../shared/dialogParts'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
|
||||
interface ProviderField {
|
||||
key: string
|
||||
@@ -220,72 +224,93 @@ export default function PhotoProvidersSection(): React.ReactElement {
|
||||
const canSave = !!cfg.settings_put
|
||||
const canTest = !!(cfg.test_post || cfg.test_get || cfg.status_get)
|
||||
|
||||
const saveDisabledReason = !canSave
|
||||
? t('memories.saveRouteNotConfigured')
|
||||
: isProviderSaveDisabled(provider) ? t('memories.fillRequiredFields') : ''
|
||||
const fieldId = (key: string) => `photo-provider-${provider.id}-${key}`
|
||||
// Text fields stack on top, the switches gather in one box of rows below them.
|
||||
const textFields = fields.filter(field => field.input_type !== 'checkbox')
|
||||
const switchFields = fields.filter(field => field.input_type === 'checkbox')
|
||||
|
||||
return (
|
||||
<Section key={provider.id} title={provider.name || provider.id} icon={Camera}>
|
||||
<div className="space-y-3">
|
||||
{fields.map(field => (
|
||||
<div key={`${provider.id}-${field.key}`}>
|
||||
{field.input_type === 'checkbox' ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<Section
|
||||
key={provider.id}
|
||||
title={provider.name || provider.id}
|
||||
icon={Camera}
|
||||
badge={
|
||||
<StatusPill tone={connected ? 'success' : 'neutral'} icon={<span className="h-1.5 w-1.5 rounded-full bg-current" />}>
|
||||
{connected ? t('memories.connected') : t('memories.disconnected')}
|
||||
</StatusPill>
|
||||
}
|
||||
>
|
||||
{textFields.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{textFields.map(field => (
|
||||
<EditorField
|
||||
key={`${provider.id}-${field.key}`}
|
||||
label={t(`memories.${field.label}`)}
|
||||
htmlFor={fieldId(field.key)}
|
||||
hint={field.hint ? t(`memories.${field.hint}`) : undefined}
|
||||
>
|
||||
<input
|
||||
id={fieldId(field.key)}
|
||||
type={field.input_type || 'text'}
|
||||
value={values[field.key] || ''}
|
||||
onChange={e => handleProviderFieldChange(provider.id, field.key, e.target.value)}
|
||||
placeholder={field.secret && connected && !(values[field.key] || '') ? '••••••••' : (field.placeholder || '')}
|
||||
className={INPUT}
|
||||
/>
|
||||
</EditorField>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{switchFields.length > 0 && (
|
||||
<SettingRows>
|
||||
{switchFields.map(field => (
|
||||
<SettingRow
|
||||
key={`${provider.id}-${field.key}`}
|
||||
label={t(`memories.${field.label}`)}
|
||||
hint={field.hint ? t(`memories.${field.hint}`) : undefined}
|
||||
control={
|
||||
<ToggleSwitch
|
||||
on={values[field.key] === 'true'}
|
||||
onToggle={() => handleProviderFieldChange(provider.id, field.key, values[field.key] === 'true' ? 'false' : 'true')}
|
||||
label={t(`memories.${field.label}`)}
|
||||
/>
|
||||
<span className="text-sm font-medium text-slate-700">{t(`memories.${field.label}`)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t(`memories.${field.label}`)}</label>
|
||||
<input
|
||||
type={field.input_type || 'text'}
|
||||
value={values[field.key] || ''}
|
||||
onChange={e => handleProviderFieldChange(provider.id, field.key, e.target.value)}
|
||||
placeholder={field.secret && connected && !(values[field.key] || '') ? '••••••••' : (field.placeholder || '')}
|
||||
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
|
||||
/>
|
||||
{field.hint && (
|
||||
<p className="mt-1 text-xs text-slate-500">{t(`memories.${field.hint}`)}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* Wraps on mobile so the connection badge drops to its own row
|
||||
instead of clipping off the side of the card. */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button type="button"
|
||||
onClick={() => handleSaveProvider(provider)}
|
||||
disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
title={!canSave ? t('memories.saveRouteNotConfigured') : isProviderSaveDisabled(provider) ? t('memories.fillRequiredFields') : ''}
|
||||
>
|
||||
<Save className="w-4 h-4" /> {t('common.save')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => handleTestProvider(provider)}
|
||||
disabled={!canTest || testing}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50"
|
||||
title={!canTest ? t('memories.testRouteNotConfigured') : ''}
|
||||
>
|
||||
{testing
|
||||
? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
|
||||
: <Camera className="w-4 h-4" />}
|
||||
<span className="sm:hidden">{t('memories.testShort')}</span>
|
||||
<span className="hidden sm:inline">{t('memories.testConnection')}</span>
|
||||
</button>
|
||||
{/* On mobile the badge sits on its own row thanks to flex-wrap, so force a line break via basis-full. */}
|
||||
{connected ? (
|
||||
<span className="basis-full sm:basis-auto text-xs font-medium text-green-600 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
||||
{t('memories.connected')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="basis-full sm:basis-auto text-xs font-medium text-slate-400 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-slate-300 rounded-full" />
|
||||
{t('memories.disconnected')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SettingRows>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2" style={fs(13, 'body')}>
|
||||
{/* The span carries the tooltip: a disabled button gets no pointer events. */}
|
||||
<Tooltip label={saveDisabledReason} placement="top">
|
||||
<span className="inline-flex">
|
||||
<button type="button"
|
||||
onClick={() => handleSaveProvider(provider)}
|
||||
disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
>
|
||||
<Save size={14} strokeWidth={2.2} /> {t('common.save')}
|
||||
</button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip label={!canTest ? t('memories.testRouteNotConfigured') : ''} placement="top">
|
||||
<span className="inline-flex">
|
||||
<button type="button"
|
||||
onClick={() => handleTestProvider(provider)}
|
||||
disabled={!canTest || testing}
|
||||
className={SETTINGS_BUTTON}
|
||||
>
|
||||
{testing
|
||||
? <Loader2 size={14} strokeWidth={2.2} className="animate-spin" />
|
||||
: <Plug size={14} strokeWidth={2.2} />}
|
||||
{t('memories.testConnection')}
|
||||
</button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,9 @@ import { useEffect, useState } from 'react'
|
||||
import { History, RefreshCw } from 'lucide-react'
|
||||
import { pluginsApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { SettingsCard, SettingsHint, SETTINGS_ICON_BUTTON } from './settingsKit'
|
||||
|
||||
interface ActivityRow {
|
||||
ts: string
|
||||
@@ -23,6 +26,9 @@ function codeTone(code: string): string {
|
||||
return 'bg-warning-soft text-warning'
|
||||
}
|
||||
|
||||
const TH = 'whitespace-nowrap px-3 py-2.5 text-left font-geist font-bold uppercase tracking-[.08em] text-content-faint'
|
||||
const TD = 'px-3 py-3 align-middle'
|
||||
|
||||
/**
|
||||
* The signed-in user's own plugin activity log — every host-mediated action a
|
||||
* plugin took while bound to them, newest first. The user-facing half of the
|
||||
@@ -50,54 +56,52 @@ export default function PluginActivityPanel() {
|
||||
return Number.isNaN(d.getTime()) ? ts : d.toLocaleString(locale)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-edge bg-surface-card overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-edge-secondary flex items-center gap-3">
|
||||
<History className="w-4 h-4 flex-shrink-0 text-content-secondary" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-content">{t('settings.pluginActivity.title')}</h3>
|
||||
<p className="text-xs text-content-muted mt-0.5">{t('settings.pluginActivity.description')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-edge px-2.5 py-1.5 text-xs font-medium text-content-secondary hover:text-content disabled:opacity-60"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
|
||||
{t('settings.pluginActivity.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
const refreshLabel = t('settings.pluginActivity.refresh')
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
icon={History}
|
||||
title={t('settings.pluginActivity.title')}
|
||||
hint={t('settings.pluginActivity.description')}
|
||||
action={
|
||||
<Tooltip label={refreshLabel}>
|
||||
<button type="button" onClick={load} disabled={loading} aria-label={refreshLabel} className={SETTINGS_ICON_BUTTON}>
|
||||
<RefreshCw size={14} className={loading ? 'animate-spin' : undefined} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<p className="px-5 py-6 text-sm text-content-muted">
|
||||
<SettingsHint>
|
||||
{loading ? t('common.loading') : t('settings.pluginActivity.empty')}
|
||||
</p>
|
||||
</SettingsHint>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<div className={`overflow-x-auto rounded-[12px] border border-edge-faint bg-surface-card ${loading ? 'opacity-60' : ''}`}>
|
||||
<table className="w-full border-collapse" style={fs(12.5, 'body')}>
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase tracking-wide text-content-faint border-b border-edge-secondary">
|
||||
<th className="font-medium px-5 py-2">{t('settings.pluginActivity.columns.plugin')}</th>
|
||||
<th className="font-medium px-3 py-2">{t('settings.pluginActivity.columns.action')}</th>
|
||||
<th className="font-medium px-3 py-2">{t('settings.pluginActivity.columns.resource')}</th>
|
||||
<th className="font-medium px-3 py-2 whitespace-nowrap">{t('settings.pluginActivity.columns.when')}</th>
|
||||
<th className="font-medium px-5 py-2 text-right">{t('settings.pluginActivity.columns.status')}</th>
|
||||
<tr className="border-b border-edge-faint bg-surface-secondary" style={fs(10)}>
|
||||
<th className={`${TH} pl-3.5`}>{t('settings.pluginActivity.columns.plugin')}</th>
|
||||
<th className={TH}>{t('settings.pluginActivity.columns.action')}</th>
|
||||
<th className={TH}>{t('settings.pluginActivity.columns.resource')}</th>
|
||||
<th className={TH}>{t('settings.pluginActivity.columns.when')}</th>
|
||||
<th className={`${TH} pr-3.5 text-right`}>{t('settings.pluginActivity.columns.status')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className="divide-y divide-edge-faint">
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i} className="border-b border-edge-secondary last:border-b-0">
|
||||
<td className="px-5 py-2.5 text-content align-top">{r.plugin_name || r.plugin_id}</td>
|
||||
<td className="px-3 py-2.5 align-top">
|
||||
<span className="font-mono text-xs text-content-secondary">{r.method}</span>
|
||||
<tr key={i}>
|
||||
<td className={`${TD} max-w-[200px] pl-3.5`}>
|
||||
<span className="block truncate font-medium text-content">{r.plugin_name || r.plugin_id}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 align-top">
|
||||
<span className="font-mono text-xs text-content-muted">{r.resource || '—'}</span>
|
||||
<td className={TD}>
|
||||
<span className="font-mono text-content-secondary" style={fs(11.5)}>{r.method}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 align-top whitespace-nowrap text-content-muted">{fmtWhen(r.ts)}</td>
|
||||
<td className="px-5 py-2.5 align-top text-right">
|
||||
<span className={`inline-block rounded-full px-2 py-0.5 text-[11px] font-medium ${codeTone(r.code)}`}>
|
||||
<td className={`${TD} max-w-[220px]`}>
|
||||
<span className="block truncate font-mono text-content-muted" style={fs(11.5)} title={r.resource || undefined}>{r.resource || '—'}</span>
|
||||
</td>
|
||||
<td className={`${TD} whitespace-nowrap font-geist tabular-nums text-content-muted`}>{fmtWhen(r.ts)}</td>
|
||||
<td className={`${TD} pr-3.5 text-right`}>
|
||||
<span className={`inline-block whitespace-nowrap rounded-full px-2 py-[2px] font-geist font-semibold ${codeTone(r.code)}`} style={fs(11)}>
|
||||
{r.code}
|
||||
</span>
|
||||
</td>
|
||||
@@ -107,6 +111,6 @@ export default function PluginActivityPanel() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ function setPlugins(plugins: ActivePlugin[]): void {
|
||||
}
|
||||
|
||||
/** The settings card for a plugin (the heading's rounded container). */
|
||||
const cardFor = (name: string) => screen.getByRole('heading', { name }).closest('div.rounded-xl') as HTMLElement;
|
||||
const cardFor = (name: string) => screen.getByRole('heading', { name }).closest('section') as HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -160,10 +160,13 @@ describe('PluginSettingsTab', () => {
|
||||
setPlugins([plugin()]);
|
||||
render(<PluginSettingsTab />);
|
||||
|
||||
const select = await screen.findByRole('combobox') as HTMLSelectElement;
|
||||
expect(select.value).toBe('c');
|
||||
await user.selectOptions(select, 'f');
|
||||
expect(select.value).toBe('f');
|
||||
// A CustomSelect: the field's label names the trigger, the options open in a portal.
|
||||
const trigger = await screen.findByRole('button', { name: 'Unit' });
|
||||
expect(trigger).toHaveTextContent('Celsius');
|
||||
await user.click(trigger);
|
||||
const choices = await screen.findAllByRole('button', { name: 'Fahrenheit' });
|
||||
await user.click(choices[choices.length - 1]);
|
||||
expect(trigger).toHaveTextContent('Fahrenheit');
|
||||
});
|
||||
|
||||
it('FE-COMP-PLUGINSETTINGS-009: a select field without options falls back to a text input', async () => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Save, Loader2, Link2, Unlink, CheckCircle } from 'lucide-react'
|
||||
import PluginIcon from '../shared/PluginIcon'
|
||||
import { Fragment, useEffect, useState, type ReactNode } from 'react'
|
||||
import { Save, Loader2, Link2, Unlink, CheckCircle, Puzzle, Zap } from 'lucide-react'
|
||||
import { resolvePluginIcon } from '../shared/PluginIcon'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { EditorField, INPUT } from '../shared/dialogParts'
|
||||
import { SettingRow, SettingRows, SettingsCard, SettingsHint, SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY } from './settingsKit'
|
||||
import PluginFrame from '../Plugins/PluginFrame'
|
||||
import { pluginsApi, type PluginUserSettingField, type PluginAction } from '../../api/client'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
@@ -9,6 +13,40 @@ import { useTranslation } from '../../i18n'
|
||||
import PluginActivityPanel from './PluginActivityPanel'
|
||||
import { seedSettingsValues, findMissingRequired, settingsPatch } from '../Plugins/settingsForm'
|
||||
|
||||
type FieldGroup = { kind: 'switches' | 'fields'; fields: PluginUserSettingField[] }
|
||||
|
||||
/** Runs of on/off fields become one box of switch rows; everything else stays a field. */
|
||||
function groupFields(fields: PluginUserSettingField[]): FieldGroup[] {
|
||||
const groups: FieldGroup[] = []
|
||||
for (const f of fields) {
|
||||
const kind = f.input_type === 'checkbox' ? 'switches' : 'fields'
|
||||
const last = groups[groups.length - 1]
|
||||
if (last && last.kind === kind) last.fields.push(f)
|
||||
else groups.push({ kind, fields: [f] })
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* A declared checkbox in the look of ToggleSwitch. It stays a real checkbox
|
||||
* underneath, so a label points at it and assistive tech reads it as one.
|
||||
*/
|
||||
function CheckSwitch({ id, checked, onChange }: { id: string; checked: boolean; onChange: (checked: boolean) => void }) {
|
||||
return (
|
||||
<span className="relative inline-flex h-6 w-11 flex-none">
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={e => onChange(e.target.checked)}
|
||||
className="peer absolute inset-0 z-10 m-0 h-full w-full cursor-pointer opacity-0"
|
||||
/>
|
||||
<span aria-hidden="true" className="pointer-events-none absolute inset-0 rounded-full bg-[color:var(--border-primary)] transition-colors peer-checked:bg-accent peer-focus-visible:ring-2 peer-focus-visible:ring-[color:var(--text-primary)]" />
|
||||
<span aria-hidden="true" className="pointer-events-none absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-surface-card shadow-sm transition-[left] duration-200 peer-checked:left-[22px] peer-checked:bg-[color:var(--accent-text)]" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Host-brokered OAuth: a Connect/Disconnect control. The host runs the whole flow +
|
||||
* holds the tokens; this only triggers connect (redirect to the provider) / disconnect. */
|
||||
function PluginOAuthSection({ id, state, setState }: {
|
||||
@@ -39,16 +77,16 @@ function PluginOAuthSection({ id, state, setState }: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex items-center justify-between rounded-lg border border-border bg-surface-secondary px-3 py-2">
|
||||
<span className="flex items-center gap-2 text-sm text-content-secondary">
|
||||
{state.connected
|
||||
? <><CheckCircle className="w-4 h-4 text-success" /> {t('settings.plugins.oauth.connected')}</>
|
||||
: <>{t('settings.plugins.oauth.notConnected')}</>}
|
||||
</span>
|
||||
{state.connected
|
||||
? <button type="button" onClick={disconnect} disabled={busy} className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-semibold text-content disabled:opacity-60"><Unlink className="w-4 h-4" />{t('settings.plugins.oauth.disconnect')}</button>
|
||||
: <button type="button" onClick={connect} disabled={busy} className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-sm font-semibold text-white disabled:opacity-60">{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Link2 className="w-4 h-4" />}{t('settings.plugins.oauth.connect')}</button>}
|
||||
</div>
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
label={state.connected
|
||||
? <span className="inline-flex items-center gap-1.5"><CheckCircle size={14} className="flex-none text-success" />{t('settings.plugins.oauth.connected')}</span>
|
||||
: t('settings.plugins.oauth.notConnected')}
|
||||
control={state.connected
|
||||
? <button type="button" onClick={disconnect} disabled={busy} className={SETTINGS_BUTTON} style={fs(13, 'body')}><Unlink size={14} />{t('settings.plugins.oauth.disconnect')}</button>
|
||||
: <button type="button" onClick={connect} disabled={busy} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>{busy ? <Loader2 size={14} className="animate-spin" /> : <Link2 size={14} />}{t('settings.plugins.oauth.connect')}</button>}
|
||||
/>
|
||||
</SettingRows>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -125,92 +163,103 @@ function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon
|
||||
}
|
||||
}
|
||||
|
||||
const fieldId = (key: string) => `plugin-setting-${id}-${key}`
|
||||
const setValue = (key: string, value: string | boolean) => setValues(v => ({ ...v, [key]: value }))
|
||||
const labelOf = (f: PluginUserSettingField): ReactNode => (
|
||||
<>{f.label || f.key}{f.required && <span className="text-danger"> *</span>}</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<PluginIcon name={icon} className="w-4 h-4 text-content-secondary" />
|
||||
<h3 className="text-sm font-semibold text-content">{name}</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{(fields ?? []).map(f => (
|
||||
<label key={f.key} className="block">
|
||||
<span className="block text-sm font-medium text-content-secondary mb-1">
|
||||
{f.label || f.key}{f.required && <span className="text-danger"> *</span>}
|
||||
</span>
|
||||
{f.input_type === 'checkbox' ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={values[f.key] === true}
|
||||
onChange={e => setValues(v => ({ ...v, [f.key]: e.target.checked }))}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
) : f.input_type === 'select' && f.options ? (
|
||||
<select
|
||||
value={String(values[f.key] ?? '')}
|
||||
onChange={e => setValues(v => ({ ...v, [f.key]: e.target.value }))}
|
||||
className="w-full rounded-lg border border-border bg-surface-secondary px-3 py-2 text-sm text-content"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{f.options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type={f.secret ? 'password' : (f.input_type === 'number' ? 'number' : 'text')}
|
||||
value={String(values[f.key] ?? '')}
|
||||
placeholder={f.placeholder || ''}
|
||||
autoComplete={f.secret ? 'new-password' : 'off'}
|
||||
onChange={e => setValues(v => ({ ...v, [f.key]: e.target.value }))}
|
||||
className="w-full rounded-lg border border-border bg-surface-secondary px-3 py-2 text-sm text-content"
|
||||
/>
|
||||
)}
|
||||
{f.hint && <span className="block text-xs text-content-muted mt-1">{f.hint}</span>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<SettingsCard icon={resolvePluginIcon(icon)} title={name}>
|
||||
{hasFields && (
|
||||
<button type="button"
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
className="mt-4 inline-flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
{t('common.save')}
|
||||
</button>
|
||||
<div className={`flex flex-col gap-4 ${saving ? 'opacity-60' : ''}`}>
|
||||
{groupFields(fields ?? []).map(group => group.kind === 'switches' ? (
|
||||
// Consecutive on/off fields share one box of rows, like the rest of settings.
|
||||
<SettingRows key={group.fields[0].key}>
|
||||
{group.fields.map(f => (
|
||||
<SettingRow
|
||||
key={f.key}
|
||||
label={labelOf(f)}
|
||||
hint={f.hint || undefined}
|
||||
htmlFor={fieldId(f.key)}
|
||||
control={
|
||||
<CheckSwitch
|
||||
id={fieldId(f.key)}
|
||||
checked={values[f.key] === true}
|
||||
onChange={checked => setValue(f.key, checked)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SettingRows>
|
||||
) : (
|
||||
<Fragment key={group.fields[0].key}>
|
||||
{group.fields.map(f => (
|
||||
<EditorField key={f.key} label={labelOf(f)} htmlFor={fieldId(f.key)} hint={f.hint || undefined}>
|
||||
{f.input_type === 'select' && f.options ? (
|
||||
<CustomSelect
|
||||
id={fieldId(f.key)}
|
||||
value={String(values[f.key] ?? '')}
|
||||
onChange={v => setValue(f.key, String(v))}
|
||||
options={[{ value: '', label: '—' }, ...f.options.map(o => ({ value: o.value, label: o.label }))]}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id={fieldId(f.key)}
|
||||
type={f.secret ? 'password' : (f.input_type === 'number' ? 'number' : 'text')}
|
||||
value={String(values[f.key] ?? '')}
|
||||
placeholder={f.placeholder || ''}
|
||||
autoComplete={f.secret ? 'new-password' : 'off'}
|
||||
onChange={e => setValue(f.key, e.target.value)}
|
||||
className={INPUT}
|
||||
/>
|
||||
)}
|
||||
</EditorField>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={save} disabled={saving} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{actions.length > 0 && (
|
||||
<div className="mt-4 border-t border-border pt-4">
|
||||
<span className="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">
|
||||
<div>
|
||||
<p className="m-0 mb-2 flex items-center gap-1.5 font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(10)}>
|
||||
<Zap size={11} className="flex-none" />
|
||||
{t('settings.plugins.actions')}
|
||||
</span>
|
||||
<div className="flex flex-col gap-2">
|
||||
</p>
|
||||
<SettingRows>
|
||||
{actions.map(a => {
|
||||
const res = actionResult[a.key]
|
||||
return (
|
||||
<div key={a.key} className="flex flex-wrap items-center gap-2">
|
||||
<div key={a.key} className="flex flex-wrap items-center gap-x-3 gap-y-1.5 px-3.5 py-3">
|
||||
<button type="button"
|
||||
onClick={() => runAction(a)}
|
||||
disabled={running !== null}
|
||||
className={`inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm font-semibold disabled:opacity-60 ${
|
||||
a.danger ? 'border-danger text-danger' : 'border-border text-content'
|
||||
}`}
|
||||
className={a.danger ? SETTINGS_BUTTON_DANGER : SETTINGS_BUTTON}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{running === a.key && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{running === a.key && <Loader2 size={14} className="animate-spin" />}
|
||||
{a.label}
|
||||
</button>
|
||||
{a.hint && <span className="text-xs text-content-muted">{a.hint}</span>}
|
||||
{a.hint && <span className="min-w-0 flex-1 text-content-faint" style={fs(11.5)}>{a.hint}</span>}
|
||||
{res && (
|
||||
<span className={`text-xs font-medium ${res.ok ? 'text-success' : 'text-danger'}`}>
|
||||
<span className={`ml-auto font-medium ${res.ok ? 'text-success' : 'text-danger'}`} style={fs(12, 'body')}>
|
||||
{res.message || (res.ok ? t('common.success') : t('common.error'))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</SettingRows>
|
||||
</div>
|
||||
)}
|
||||
<PluginOAuthSection id={id} state={oauth} setState={setOauth} />
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -223,20 +272,16 @@ function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon
|
||||
*/
|
||||
function PluginSettingsUiCard({ id, name, icon }: { id: string; name: string; icon: string | null }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<PluginIcon name={icon} className="w-4 h-4 text-content-secondary" />
|
||||
<h3 className="text-sm font-semibold text-content">{name}</h3>
|
||||
</div>
|
||||
<SettingsCard icon={resolvePluginIcon(icon)} title={name}>
|
||||
{/* min-height covers the beat before the frame's first trek:resize lands.
|
||||
color-scheme light on the frame element matches the sandboxed document's
|
||||
default ("normal"): mismatched schemes make Chromium paint an opaque
|
||||
white canvas behind the transparent frame (same trap .hero-overlay-frame
|
||||
guards against), which glares in dark mode. */}
|
||||
<div className="min-h-[120px]">
|
||||
<div className="min-h-[120px] overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
|
||||
<PluginFrame pluginId={id} path="settings.html" title={name} surface="user-settings" className="[color-scheme:light]" />
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -245,19 +290,16 @@ export default function PluginSettingsTab() {
|
||||
const plugins = usePluginStore(s => s.plugins)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-content">{t('settings.plugins.title')}</h2>
|
||||
<p className="text-sm text-content-muted">{t('settings.plugins.subtitle')}</p>
|
||||
</div>
|
||||
{plugins.length === 0
|
||||
? <p className="text-sm text-content-muted">{t('settings.plugins.empty')}</p>
|
||||
: plugins.map(p => (
|
||||
<div key={p.id} className="space-y-4">
|
||||
<PluginSettingsForm id={p.id} name={p.name} icon={p.icon} />
|
||||
{p.settingsUi && <PluginSettingsUiCard id={p.id} name={p.name} icon={p.icon} />}
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<SettingsCard icon={Puzzle} title={t('settings.plugins.title')} hint={t('settings.plugins.subtitle')}>
|
||||
{plugins.length === 0 ? <SettingsHint>{t('settings.plugins.empty')}</SettingsHint> : null}
|
||||
</SettingsCard>
|
||||
{plugins.map(p => (
|
||||
<Fragment key={p.id}>
|
||||
<PluginSettingsForm id={p.id} name={p.name} icon={p.icon} />
|
||||
{p.settingsUi && <PluginSettingsUiCard id={p.id} name={p.name} icon={p.icon} />}
|
||||
</Fragment>
|
||||
))}
|
||||
<PluginActivityPanel />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import React from 'react'
|
||||
|
||||
/** The track and the knob glide with the same easing the planner's panels use. */
|
||||
const EASE = 'cubic-bezier(0.32, 0.72, 0, 1)'
|
||||
|
||||
/**
|
||||
* `describedBy` names the element that explains the switch, for a switch whose label alone
|
||||
* does not say what flipping it changes.
|
||||
@@ -7,20 +10,24 @@ import React from 'react'
|
||||
export default function ToggleSwitch({ on, onToggle, label, describedBy }: { on: boolean; onToggle: () => void; label?: string; describedBy?: string }) {
|
||||
return (
|
||||
<button type="button" onClick={onToggle} aria-pressed={on} aria-label={label} aria-describedby={describedBy}
|
||||
// A keyboard focus ring in the text colour, like the fields; the pointer gets none.
|
||||
className="group outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg-card)]"
|
||||
style={{
|
||||
position: 'relative', width: 44, height: 24, minWidth: 44, flexShrink: 0,
|
||||
borderRadius: 12, border: 'none', padding: 0, cursor: 'pointer',
|
||||
background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
|
||||
transition: 'background 0.2s',
|
||||
boxShadow: 'inset 0 1px 2px color-mix(in srgb, var(--text-primary) 12%, transparent)',
|
||||
transition: `background 0.2s ${EASE}`,
|
||||
}}>
|
||||
{/* The knob reads against its own track: --accent-text is what the palette
|
||||
already picked to sit on --accent, so a light accent gets a dark knob
|
||||
instead of the white-on-near-white it used to be in dark mode. */}
|
||||
<span style={{
|
||||
<span className="group-active:scale-95" style={{
|
||||
position: 'absolute', top: 2, left: on ? 22 : 2,
|
||||
width: 20, height: 20, borderRadius: '50%',
|
||||
background: on ? 'var(--accent-text, #ffffff)' : 'var(--bg-card, #ffffff)',
|
||||
transition: 'left 0.2s', boxShadow: 'var(--shadow-sm, 0 1px 3px rgba(0,0,0,0.2))',
|
||||
transition: `left 0.22s ${EASE}, transform 0.15s ease`,
|
||||
boxShadow: 'var(--shadow-sm, 0 1px 3px rgba(0,0,0,0.2))',
|
||||
}} />
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React from 'react'
|
||||
import { BellRing } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useWebPush } from '../../hooks/useWebPush'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { SettingsCard, SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY } from './settingsKit'
|
||||
|
||||
/**
|
||||
* The "Push notifications on this device" card of the Notifications tab. Only
|
||||
@@ -12,36 +15,40 @@ export default function WebPushCard(): React.ReactElement {
|
||||
const push = useWebPush()
|
||||
|
||||
return (
|
||||
<div className="mb-4 rounded-lg border border-edge bg-surface-secondary p-3">
|
||||
<p className="mb-1 text-caption font-semibold text-content-secondary">{t('settings.webPush.title')}</p>
|
||||
<p className="mb-2 text-caption text-content-faint">{t('settings.webPush.hint')}</p>
|
||||
{push.stateText && (
|
||||
<p role="status" className="mb-2 text-caption text-content-secondary">
|
||||
{push.stateText}
|
||||
</p>
|
||||
)}
|
||||
{push.canSwitch && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={push.toggle}
|
||||
disabled={push.busy}
|
||||
className={`rounded-md px-3 py-1.5 text-caption disabled:cursor-not-allowed disabled:opacity-60 ${
|
||||
push.on ? 'border border-edge bg-transparent text-content-secondary' : 'bg-accent text-accent-text'
|
||||
}`}
|
||||
>
|
||||
{push.switchLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={push.sendTest}
|
||||
disabled={!push.canTest}
|
||||
className="rounded-md border border-edge bg-transparent px-3 py-1.5 text-caption text-content-secondary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('settings.notificationPreferences.sendTest')}
|
||||
</button>
|
||||
<SettingsCard icon={BellRing} title={t('settings.webPush.title')} hint={t('settings.webPush.hint')}>
|
||||
{(push.stateText || push.canSwitch) && (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-3 rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3">
|
||||
{push.stateText
|
||||
? (
|
||||
<p role="status" className="m-0 min-w-0 flex-1 basis-56 text-content-secondary" style={fs(12.5, 'body')}>
|
||||
{push.stateText}
|
||||
</p>
|
||||
)
|
||||
: <span className="flex-1" />}
|
||||
{push.canSwitch && (
|
||||
<div className="flex flex-none flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={push.sendTest}
|
||||
disabled={!push.canTest}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{t('settings.notificationPreferences.sendTest')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={push.toggle}
|
||||
disabled={push.busy}
|
||||
className={push.on ? SETTINGS_BUTTON : SETTINGS_BUTTON_PRIMARY}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{push.switchLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user