feat(settings,admin): every tab and popup in the planner's design

This commit is contained in:
Maurice
2026-10-01 16:00:37 +02:00
parent 4bc7c5cf68
commit 788a58679e
75 changed files with 8134 additions and 8023 deletions
+132 -122
View File
@@ -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>
+6 -3
View File
@@ -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} />
+23 -31
View File
@@ -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
+77 -59
View File
@@ -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)
})
})
+331 -329
View File
@@ -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>
)
}
+122 -107
View File
@@ -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');
+229 -349
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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>
)
}