mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
feat(settings,admin): every tab and popup in the planner's design
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState, type ComponentType } from 'react'
|
||||
import { useEffect, useId, useState, type ComponentType } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useIsDark } from '../../hooks/useIsDark'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2 } from 'lucide-react'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
@@ -13,6 +13,10 @@ import AirTrailIcon from '../shared/AirTrailIcon'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../shared/DocumentProviderIcons'
|
||||
import AddonTile from './AddonTile'
|
||||
import AddonSubRow from './AddonSubRow'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { EditorField, INPUT, Segmented } from '../shared/dialogParts'
|
||||
import { SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY } from '../Settings/settingsKit'
|
||||
|
||||
// Keys are the `icon` column from the addons table (see server seeds.ts); anything
|
||||
// unknown falls back to Puzzle. Users/Sparkles cover collab and llm_parsing, which
|
||||
@@ -183,9 +187,11 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="grid place-items-center py-16">
|
||||
<Loader2 size={22} className="animate-spin text-content-faint" />
|
||||
</div>
|
||||
<SettingsCard icon={Puzzle} title={t('admin.addons.title')}>
|
||||
<div className="grid place-items-center py-14">
|
||||
<Loader2 size={22} className="animate-spin text-content-faint" />
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -278,9 +284,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
}
|
||||
|
||||
/* One column per type, side by side, instead of three stacked sections. Stacked, each
|
||||
section opened its own set of columns and the tall tiles (Collab 261px, Journey
|
||||
185px) set a section's height while its neighbours ran out early. Side by side the
|
||||
column heading carries the type, so the tiles need no badge of their own. */
|
||||
section opened its own set of columns and the tall tiles (Collab, Journey) set a
|
||||
section's height while its neighbours ran out early. Side by side the column's
|
||||
head carries the type, so the tiles need no badge of their own. */
|
||||
const groups = [
|
||||
{ key: 'trip', addons: tripAddons },
|
||||
{ key: 'global', addons: globalAddons },
|
||||
@@ -289,48 +295,49 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled).length
|
||||
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
|
||||
<div>
|
||||
<h2 className="text-subtitle font-semibold tracking-tight text-content">{t('admin.addons.title')}</h2>
|
||||
<p className="mt-1 flex flex-wrap items-center gap-1 text-caption text-content-muted">
|
||||
{t('admin.addons.subtitleBefore')}
|
||||
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 11, verticalAlign: 'middle', opacity: 0.7 }} />
|
||||
{t('admin.addons.subtitleAfter')}
|
||||
</p>
|
||||
</div>
|
||||
{totalCount > 0 && (
|
||||
<span
|
||||
className="shrink-0 text-caption tabular-nums text-content-faint"
|
||||
title={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}
|
||||
>
|
||||
{enabledCount}/{totalCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
const subtitle = (
|
||||
<span className="inline-flex flex-wrap items-center gap-1">
|
||||
{t('admin.addons.subtitleBefore')}
|
||||
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, verticalAlign: 'middle', opacity: 0.7 }} />
|
||||
{t('admin.addons.subtitleAfter')}
|
||||
</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
icon={Puzzle}
|
||||
title={t('admin.addons.title')}
|
||||
hint={subtitle}
|
||||
badge={totalCount > 0 ? (
|
||||
<Tooltip label={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}>
|
||||
<span className="inline-flex">
|
||||
<StatusPill tone={enabledCount > 0 ? 'success' : 'neutral'}>{enabledCount}/{totalCount}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
>
|
||||
{addons.length === 0 ? (
|
||||
<EmptyState scene="idle" title={t('admin.addons.noAddons')} />
|
||||
<EmptyState scene="idle" title={t('admin.addons.noAddons')} surface="var(--bg-secondary)" />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 items-start gap-x-4 gap-y-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="grid grid-cols-1 items-start gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{groups.map(g => {
|
||||
const meta = TYPE_META[g.key]
|
||||
return (
|
||||
<section key={g.key}>
|
||||
<section key={g.key} className="min-w-0 overflow-hidden rounded-[14px] border border-edge-faint bg-surface-card">
|
||||
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.addons} t={t} />
|
||||
<div className="space-y-3">{g.addons.map(tile)}</div>
|
||||
<div className="divide-y divide-edge-faint">{g.addons.map(tile)}</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
/** The head band of one type's column: icon tile, the type, its count and what it means. */
|
||||
function GroupHead({ icon: Icon, label, hint, addons, t }: {
|
||||
icon: ComponentType<{ size?: number; className?: string }>
|
||||
icon: ComponentType<{ size?: number; className?: string; strokeWidth?: number }>
|
||||
label: string
|
||||
hint: string
|
||||
addons: Addon[]
|
||||
@@ -338,24 +345,29 @@ function GroupHead({ icon: Icon, label, hint, addons, t }: {
|
||||
}) {
|
||||
const enabled = addons.filter(a => a.enabled).length
|
||||
return (
|
||||
<div
|
||||
className="mb-3 rounded-xl border border-edge-secondary bg-surface-secondary px-3 py-2"
|
||||
title={t('admin.addons.group.count', { enabled, total: addons.length })}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon size={13} className="shrink-0 text-content-muted" />
|
||||
<h3 className="min-w-0 flex-1 truncate text-caption font-semibold uppercase tracking-[0.06em] text-content">{label}</h3>
|
||||
<span className="shrink-0 rounded-full bg-surface px-1.5 py-0.5 text-caption font-medium tabular-nums text-content-muted">
|
||||
{enabled}/{addons.length}
|
||||
</span>
|
||||
<div className="flex items-start gap-2.5 border-b border-edge-faint bg-surface-tertiary px-3.5 py-2.5">
|
||||
<span className="mt-px grid h-7 w-7 flex-none place-items-center rounded-[9px] bg-surface-card text-content-secondary shadow-sm">
|
||||
<Icon size={13} strokeWidth={2} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="m-0 min-w-0 flex-1 truncate font-bold text-content" style={fs(13, 'body')}>{label}</h3>
|
||||
<Tooltip label={t('admin.addons.group.count', { enabled, total: addons.length })}>
|
||||
<span className="inline-flex">
|
||||
<StatusPill>{enabled}/{addons.length}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/* Says what the type means: the only place that still explains it. */}
|
||||
<p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11)}>{hint}</p>
|
||||
</div>
|
||||
{/* Says what the type means — the only place that still explains it. */}
|
||||
<p className="mt-0.5 text-caption text-content-faint">{hint}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MASKED = '••••••••'
|
||||
const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint'
|
||||
const SMALL_BUTTON = 'inline-flex flex-none items-center rounded-[9px] px-2.5 py-1 font-medium transition-colors disabled:cursor-default disabled:opacity-60'
|
||||
const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1'
|
||||
|
||||
/** Curated models the local extractor is tuned for, pullable via Ollama. The router drives
|
||||
@@ -381,6 +393,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
const [apiKey, setApiKey] = useState<string>((cfg.apiKey as string) ?? '')
|
||||
const [vision, setVision] = useState<LlmVision>(asLlmVision(cfg.vision))
|
||||
const [saving, setSaving] = useState(false)
|
||||
const fieldId = useId()
|
||||
|
||||
// Local-provider model management.
|
||||
const [installed, setInstalled] = useState<string[]>([])
|
||||
@@ -450,9 +463,6 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
}
|
||||
}
|
||||
|
||||
const fieldCls = 'w-full rounded-lg border border-edge-secondary bg-surface px-2.5 py-1.5 text-caption text-content placeholder:text-content-faint transition-colors focus:border-edge focus:outline-none'
|
||||
const labelCls = 'mb-1 block text-caption font-medium text-content-secondary'
|
||||
|
||||
const providerOptions = [
|
||||
{ value: 'local', label: 'Local · OpenAI-compatible', icon: <Server size={14} />, badge: 'Ollama' },
|
||||
{ value: 'openai', label: 'OpenAI', icon: <Cloud size={14} /> },
|
||||
@@ -460,65 +470,69 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
]
|
||||
const visionOptions = LLM_VISION_MODES.map(value => ({ value, label: t(`admin.addons.llm.vision.${value}`) }))
|
||||
|
||||
/* Lives in the tile's shelf like the collab toggles, so everything is caption-
|
||||
sized and single-column — the band this used to be had a whole page width. */
|
||||
/* Lives in the tile's shelf like the collab toggles, so it is one column of
|
||||
eyebrow fields: the band this used to be had a whole page width. */
|
||||
return (
|
||||
<li className="space-y-2.5 py-1.5">
|
||||
<p className="text-caption text-content-faint">
|
||||
<li className={`flex flex-col gap-3 p-3 ${saving ? 'opacity-60' : ''}`}>
|
||||
<SettingsHint>
|
||||
Instance-wide — applies to all users. Leave blank to let each user configure their own provider.
|
||||
</p>
|
||||
</SettingsHint>
|
||||
|
||||
<div>
|
||||
<span className={labelCls}>Provider</span>
|
||||
<EditorField label="Provider">
|
||||
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
|
||||
</div>
|
||||
</EditorField>
|
||||
{provider !== 'anthropic' && (
|
||||
<label className="block">
|
||||
<span className={labelCls}>Base URL</span>
|
||||
<input type="url" autoComplete="off" className={fieldCls} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
|
||||
</label>
|
||||
<EditorField label="Base URL" htmlFor={`${fieldId}-url`}>
|
||||
<input id={`${fieldId}-url`} type="url" autoComplete="off" className={INPUT} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
|
||||
</EditorField>
|
||||
)}
|
||||
<label className="block">
|
||||
<span className={labelCls}>API key</span>
|
||||
<input type="password" className={fieldCls} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
|
||||
</label>
|
||||
{provider === 'anthropic' && (
|
||||
<p className="text-caption text-content-faint">Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.</p>
|
||||
)}
|
||||
<label className="block">
|
||||
<span className={labelCls}>Model</span>
|
||||
<input autoComplete="off" className={fieldCls} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
|
||||
</label>
|
||||
<EditorField
|
||||
label="API key"
|
||||
htmlFor={`${fieldId}-key`}
|
||||
hint={provider === 'anthropic' ? 'Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.' : undefined}
|
||||
>
|
||||
<input id={`${fieldId}-key`} type="password" className={INPUT} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
|
||||
</EditorField>
|
||||
<EditorField label="Model" htmlFor={`${fieldId}-model`}>
|
||||
<input id={`${fieldId}-model`} autoComplete="off" className={`${INPUT} font-geist`} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
|
||||
</EditorField>
|
||||
|
||||
<div>
|
||||
<span className={labelCls}>{t('settings.aiParsing.multimodal')}</span>
|
||||
<CustomSelect value={vision} onChange={v => setVision(asLlmVision(v))} options={visionOptions} />
|
||||
<p className="mt-1 text-caption text-content-faint">
|
||||
{t(provider === 'local' ? 'admin.addons.llm.vision.hintLocal' : 'admin.addons.llm.vision.hintCloud')}
|
||||
</p>
|
||||
</div>
|
||||
<EditorField label={t('settings.aiParsing.multimodal')} hint={t(provider === 'local' ? 'admin.addons.llm.vision.hintLocal' : 'admin.addons.llm.vision.hintCloud')}>
|
||||
<Segmented<LlmVision>
|
||||
label={t('settings.aiParsing.multimodal')}
|
||||
value={vision}
|
||||
onChange={v => setVision(asLlmVision(v))}
|
||||
options={visionOptions}
|
||||
fill
|
||||
/>
|
||||
</EditorField>
|
||||
|
||||
{/* Local model management (Ollama) */}
|
||||
{provider === 'local' && (
|
||||
<div className="space-y-2 rounded-lg border border-edge-secondary bg-surface p-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-caption font-medium text-content-secondary">Installed on the server</span>
|
||||
<button type="button" onClick={loadModels} disabled={loadingModels} className="text-caption text-content-muted underline disabled:opacity-60">
|
||||
<div className="flex flex-col gap-2.5 rounded-[12px] border border-edge-faint bg-surface-card p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`${EYEBROW} min-w-0 flex-1 truncate`} style={fs(9.5)}>Installed on the server</span>
|
||||
<button type="button" onClick={loadModels} disabled={loadingModels}
|
||||
className="inline-flex flex-none items-center gap-1 rounded-full px-2 py-0.5 font-medium text-content-muted hover:bg-surface-secondary hover:text-content disabled:cursor-default disabled:opacity-60"
|
||||
style={fs(11.5, 'body')}>
|
||||
<RefreshCw size={11} strokeWidth={2.2} className={loadingModels ? 'animate-spin' : undefined} />
|
||||
{loadingModels ? 'Loading…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
{modelsErr && <p className="text-caption text-danger">{modelsErr}</p>}
|
||||
{modelsErr && <p className="m-0 break-words text-danger" style={fs(11.5)}>{modelsErr}</p>}
|
||||
{!modelsErr && installed.length === 0 && !loadingModels && (
|
||||
<p className="text-caption text-content-faint">No models installed yet — pull one below.</p>
|
||||
<SettingsHint>No models installed yet — pull one below.</SettingsHint>
|
||||
)}
|
||||
{installed.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{installed.map(name => (
|
||||
<button type="button"
|
||||
key={name}
|
||||
title={name}
|
||||
onClick={() => setModel(name)}
|
||||
className={`max-w-full truncate rounded-full border px-2 py-0.5 text-caption transition-colors ${model === name ? 'border-transparent bg-accent text-accent-text' : 'border-edge-secondary text-content-secondary hover:border-edge'}`}
|
||||
aria-pressed={model === name}
|
||||
className={`max-w-full truncate rounded-full px-2.5 py-[3px] font-geist font-medium transition-colors ${model === name ? 'bg-accent text-accent-text' : 'border border-edge bg-surface-card text-content-muted hover:text-content'}`}
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
@@ -526,48 +540,44 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-edge-secondary pt-2">
|
||||
<div className="mb-1.5 text-caption font-medium text-content-secondary">Pull a recommended model</div>
|
||||
<div className="space-y-1">
|
||||
{RECOMMENDED_MODELS.map(m => {
|
||||
const installedHere = isInstalled(m.id)
|
||||
const isPulling = pulling === m.id
|
||||
const active = model === m.id
|
||||
return (
|
||||
<div key={m.id} className="min-w-0" title={m.note}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-caption text-content">{m.label}</span>
|
||||
{m.recommended && (
|
||||
<span className="shrink-0 rounded-md bg-success-soft px-1.5 py-px text-caption font-semibold text-success">Recommended</span>
|
||||
)}
|
||||
{installedHere ? (
|
||||
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`shrink-0 rounded-md px-2 py-1 text-caption font-medium transition-colors ${active ? 'bg-surface-tertiary text-content-muted' : 'border border-edge-secondary text-content-secondary hover:border-edge'}`}>
|
||||
{active ? 'Selected' : 'Use'}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => pull(m.id)} disabled={!!pulling} className="shrink-0 rounded-md bg-accent px-2 py-1 text-caption font-medium text-accent-text disabled:opacity-60">
|
||||
{isPulling ? 'Pulling…' : 'Pull'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isPulling && (
|
||||
<div className="mt-1">
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-surface-tertiary">
|
||||
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
|
||||
</div>
|
||||
<div className="mt-0.5 text-caption text-content-faint">{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 border-t border-edge-faint pt-2.5">
|
||||
<span className={EYEBROW} style={fs(9.5)}>Pull a recommended model</span>
|
||||
{RECOMMENDED_MODELS.map(m => {
|
||||
const installedHere = isInstalled(m.id)
|
||||
const isPulling = pulling === m.id
|
||||
const active = model === m.id
|
||||
return (
|
||||
<div key={m.id} className="min-w-0" title={m.note}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>{m.label}</span>
|
||||
{m.recommended && <StatusPill tone="success">Recommended</StatusPill>}
|
||||
{installedHere ? (
|
||||
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`${SMALL_BUTTON} ${active ? 'bg-surface-tertiary text-content-muted' : 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary'}`} style={fs(12, 'body')}>
|
||||
{active ? 'Selected' : 'Use'}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => pull(m.id)} disabled={!!pulling} className={`${SMALL_BUTTON} bg-accent text-accent-text hover:opacity-90`} style={fs(12, 'body')}>
|
||||
{isPulling ? 'Pulling…' : 'Pull'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{isPulling && (
|
||||
<div className="mt-2">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
|
||||
<div className="h-full rounded-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
|
||||
</div>
|
||||
<div className="mt-1 truncate font-geist tabular-nums text-content-faint" style={fs(11)}>{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={save} disabled={saving} className="rounded-lg bg-accent px-3 py-1.5 text-caption font-medium text-accent-text transition-opacity disabled:opacity-60">
|
||||
<button type="button" onClick={save} disabled={saving} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
|
||||
/**
|
||||
* One child row inside an {@link AddonTile}'s sub-shelf: bag tracking under
|
||||
@@ -29,9 +30,11 @@ export default function AddonSubRow({
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<li className="flex min-h-[36px] items-center gap-3">
|
||||
{icon ? <span className="shrink-0 text-content-faint">{icon}</span> : <span className="w-3.5 shrink-0" aria-hidden />}
|
||||
<span className="min-w-0 flex-1 truncate text-caption font-medium text-content-secondary" title={description}>
|
||||
<li className="flex min-h-[44px] items-center gap-2.5 px-3 py-1.5">
|
||||
{icon
|
||||
? <span className={`grid h-6 w-6 flex-none place-items-center ${enabled ? 'text-content-muted' : 'text-content-faint'}`}>{icon}</span>
|
||||
: <span className="w-6 flex-none" aria-hidden />}
|
||||
<span className={`min-w-0 flex-1 truncate font-medium ${enabled ? 'text-content' : 'text-content-muted'}`} style={fs(12.5, 'body')} title={description}>
|
||||
{title}
|
||||
</span>
|
||||
<ToggleSwitch on={enabled} onToggle={onToggle} label={title} />
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
|
||||
/**
|
||||
* One addon in the admin grid: a card whose fill carries the on/off state, with
|
||||
* an optional shelf at the foot holding its sub-features.
|
||||
* One addon in its type's card: a row like the bag sidebar's (#2541), with the
|
||||
* icon tile on the left, name and description beside it and the switch on the
|
||||
* right. Its sub-features sit under it in a grey inset box of hairline rows.
|
||||
*
|
||||
* The fill ladder is `--bg-secondary` (on) against `--bg-card` (the panel it
|
||||
* sits on) — that pair reads in both themes, because secondary is *darker* than
|
||||
* the card in light and *lighter* in dark. The obvious alternative, on =
|
||||
* `bg-surface-card`, inverts in dark and turns the disabled tiles into the loud
|
||||
* ones.
|
||||
* The on/off state rides on the icon tile and the switch: a switched-off addon
|
||||
* greys its tile and its description, but keeps its name at full strength. The
|
||||
* name is the tile's identity, not its state; greying it out reads as "broken"
|
||||
* rather than "not enabled".
|
||||
*
|
||||
* Hover and focus ride on border and fill rather than a shadow: `--shadow-card`
|
||||
* is only declared in `:root`, so the `.dark` palette never overrides it and a
|
||||
* shadow-based affordance would be invisible there.
|
||||
*
|
||||
* The card is not a click target — only the switches are. No lift, no pointer
|
||||
* The row is not a click target, only the switches are. No lift, no pointer
|
||||
* cursor, no `role="button"`.
|
||||
*/
|
||||
export default function AddonTile({
|
||||
@@ -35,46 +32,41 @@ export default function AddonTile({
|
||||
children?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<article
|
||||
className={`flex flex-col overflow-hidden rounded-xl border transition-[background-color,border-color] duration-150 focus-within:ring-2 focus-within:ring-accent ${
|
||||
enabled
|
||||
? 'border-edge bg-surface-secondary'
|
||||
: 'border-edge-secondary bg-surface hover:border-edge hover:bg-surface-secondary'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3 p-4">
|
||||
<article className="px-3.5 py-3 transition-colors duration-150 focus-within:bg-surface-secondary">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
// overflow-hidden so a brand mark that fills the slot keeps the slot's
|
||||
// rounded corners instead of squaring them off.
|
||||
className={`grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-xl ${
|
||||
enabled ? 'border border-accent bg-accent-subtle text-accent-on' : 'bg-surface-tertiary text-content-faint'
|
||||
className={`grid h-9 w-9 flex-none place-items-center overflow-hidden rounded-[10px] transition-colors ${
|
||||
enabled ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'bg-surface-tertiary text-content-faint'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* The name stays full-strength when off: it is the tile's identity, not its
|
||||
state. Greying it out reads as "broken" rather than "not enabled". */}
|
||||
<h4 className="truncate text-body font-semibold text-content" title={name}>
|
||||
<div className="min-w-0 flex-1 pt-px">
|
||||
<h4 className="m-0 truncate font-semibold text-content" style={fs(13, 'body')} title={name}>
|
||||
{name}
|
||||
</h4>
|
||||
<p
|
||||
className={`mt-0.5 line-clamp-2 text-caption ${enabled ? 'text-content-muted' : 'text-content-faint'}`}
|
||||
className={`m-0 mt-0.5 line-clamp-2 leading-snug ${enabled ? 'text-content-muted' : 'text-content-faint'}`}
|
||||
style={fs(11.5)}
|
||||
title={description}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ToggleSwitch on={enabled} onToggle={onToggle} label={name} />
|
||||
<div className="flex-none pt-1.5">
|
||||
<ToggleSwitch on={enabled} onToggle={onToggle} label={name} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{children && (
|
||||
<div className="border-t border-edge-secondary bg-surface-tertiary px-4 py-2.5">
|
||||
<div className="mt-3 overflow-hidden rounded-[12px] border border-edge-faint bg-surface-secondary">
|
||||
{/* Sitting inside the parent on its own fill is enough to say these belong
|
||||
to it — no rail, no indent guides. */}
|
||||
<ul className="space-y-0.5">{children}</ul>
|
||||
to it: no rail, no indent guides. */}
|
||||
<ul className="m-0 list-none divide-y divide-edge-faint p-0">{children}</ul>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('AdminMcpTokensPanel', () => {
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('CI Token');
|
||||
|
||||
const deleteButtons = screen.getAllByTitle('Delete');
|
||||
const deleteButtons = screen.getAllByLabelText('Delete');
|
||||
await user.click(deleteButtons[0]);
|
||||
|
||||
expect(screen.getByText('Delete Token')).toBeInTheDocument();
|
||||
@@ -108,7 +108,7 @@ describe('AdminMcpTokensPanel', () => {
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('CI Token');
|
||||
|
||||
const deleteButtons = screen.getAllByTitle('Delete');
|
||||
const deleteButtons = screen.getAllByLabelText('Delete');
|
||||
await user.click(deleteButtons[0]);
|
||||
expect(screen.getByText('Delete Token')).toBeInTheDocument();
|
||||
|
||||
@@ -129,7 +129,7 @@ describe('AdminMcpTokensPanel', () => {
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('CI Token');
|
||||
|
||||
const deleteButtons = screen.getAllByTitle('Delete');
|
||||
const deleteButtons = screen.getAllByLabelText('Delete');
|
||||
await user.click(deleteButtons[0]);
|
||||
expect(screen.getByText('Delete Token')).toBeInTheDocument();
|
||||
|
||||
@@ -155,7 +155,7 @@ describe('AdminMcpTokensPanel', () => {
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
await screen.findByText('CI Token');
|
||||
|
||||
const deleteButtons = screen.getAllByTitle('Delete');
|
||||
const deleteButtons = screen.getAllByLabelText('Delete');
|
||||
await user.click(deleteButtons[0]);
|
||||
await user.click(screen.getByText('Delete'));
|
||||
|
||||
@@ -180,7 +180,7 @@ describe('AdminMcpTokensPanel', () => {
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
await screen.findByText('CI Token');
|
||||
|
||||
const deleteButtons = screen.getAllByTitle('Delete');
|
||||
const deleteButtons = screen.getAllByLabelText('Delete');
|
||||
await user.click(deleteButtons[0]);
|
||||
await user.click(screen.getByText('Delete'));
|
||||
|
||||
@@ -282,14 +282,14 @@ describe('AdminMcpTokensPanel', () => {
|
||||
await screen.findByText('Revoke Me');
|
||||
|
||||
// Click the revoke (trash) button next to the session
|
||||
const deleteBtn = screen.getAllByTitle('Delete')[0];
|
||||
const deleteBtn = screen.getAllByLabelText('Delete')[0];
|
||||
await user.click(deleteBtn);
|
||||
|
||||
// Confirmation modal opens
|
||||
expect(screen.getByText('Revoke Session')).toBeInTheDocument();
|
||||
// Confirm — find the modal's Delete button (has no title, unlike the trash icon)
|
||||
// Confirm — find the modal's Delete button (has no aria-label, unlike the trash icon)
|
||||
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
|
||||
const confirmBtn = deleteBtns.find(b => !b.title);
|
||||
const confirmBtn = deleteBtns.find(b => !b.hasAttribute('aria-label'));
|
||||
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Revoke Me')).not.toBeInTheDocument();
|
||||
@@ -313,10 +313,10 @@ describe('AdminMcpTokensPanel', () => {
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
await screen.findByText('Error Session');
|
||||
|
||||
const deleteBtn = screen.getAllByTitle('Delete')[0];
|
||||
const deleteBtn = screen.getAllByLabelText('Delete')[0];
|
||||
await user.click(deleteBtn);
|
||||
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
|
||||
const confirmBtn = deleteBtns.find(b => !b.title);
|
||||
const confirmBtn = deleteBtns.find(b => !b.hasAttribute('aria-label'));
|
||||
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
|
||||
expect(await screen.findByText('Failed to revoke session')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, type ReactNode } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Key, Trash2, User, Loader2, Shield } from 'lucide-react'
|
||||
import { Key, Trash2, User, Loader2, Shield, KeyRound, Bot } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { SETTINGS_ICON_BUTTON, SettingRows, SettingsCard, SettingsHint, StatusPill } from '../Settings/settingsKit'
|
||||
|
||||
interface AdminOAuthSession {
|
||||
id: number
|
||||
@@ -28,6 +32,58 @@ interface AdminMcpToken {
|
||||
|
||||
const SCOPES_PREVIEW = 6
|
||||
|
||||
const ROW = 'flex flex-wrap items-center gap-x-4 gap-y-2 px-3.5 py-3'
|
||||
const TILE = 'grid h-9 w-9 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary'
|
||||
const CHIP = 'inline-flex items-center rounded-full border border-edge-faint bg-surface-secondary px-2 py-[1px] font-geist text-content-muted'
|
||||
|
||||
/** The spinner or the empty line a list shows in place of its rows. */
|
||||
function ListState({ loading, icon, text }: { loading: boolean; icon: ReactNode; text: string }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 size={18} className="animate-spin text-content-faint" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-content-faint">
|
||||
{icon}
|
||||
<SettingsHint>{text}</SettingsHint>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One fact on the right of a row: an eyebrow over its value. */
|
||||
function Meta({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-0 text-right">
|
||||
<div className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5)}>{label}</div>
|
||||
<div className="whitespace-nowrap font-geist tabular-nums text-content-muted" style={fs(12, 'body')}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The owner of a session or token, with the person glyph in front. */
|
||||
function Owner({ name }: { name: string }) {
|
||||
return (
|
||||
<span className="inline-flex min-w-0 max-w-[180px] items-center gap-1.5 text-content-secondary" style={fs(12.5, 'body')}>
|
||||
<User size={13} className="flex-none text-content-faint" />
|
||||
<span className="truncate">{name}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** The trash button of a row, named by its tooltip. */
|
||||
function DeleteAction({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<Tooltip label={label} placement="left">
|
||||
<button type="button" onClick={onClick} aria-label={label} className={`${SETTINGS_ICON_BUTTON} hover:text-danger`}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminMcpTokensPanel() {
|
||||
const [sessions, setSessions] = useState<AdminOAuthSession[]>([])
|
||||
const [sessionsLoading, setSessionsLoading] = useState(true)
|
||||
@@ -80,188 +136,107 @@ export default function AdminMcpTokensPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-content">{t('admin.mcpTokens.title')}</h2>
|
||||
<p className="text-sm mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.subtitle')}</p>
|
||||
</div>
|
||||
const date = (iso: string) => new Date(iso).toLocaleDateString(locale)
|
||||
|
||||
{/* OAuth Sessions */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2 text-content-secondary">{t('admin.oauthSessions.sectionTitle')}</h3>
|
||||
<div className="rounded-xl border overflow-hidden border-edge bg-surface-card">
|
||||
{sessionsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<Shield className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.oauthSessions.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-x-6 px-4 py-2.5 text-xs font-medium border-b border-edge bg-surface-secondary"
|
||||
style={{ color: 'var(--text-tertiary)' }}>
|
||||
<span>{t('admin.oauthSessions.clientName')}</span>
|
||||
<span>{t('admin.oauthSessions.owner')}</span>
|
||||
<span className="text-right">{t('admin.oauthSessions.created')}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{sessions.map((session, i) => {
|
||||
const expanded = expandedScopes.has(session.id)
|
||||
const visible = expanded ? session.scopes : session.scopes.slice(0, SCOPES_PREVIEW)
|
||||
const hidden = session.scopes.length - SCOPES_PREVIEW
|
||||
return (
|
||||
<div key={session.id}
|
||||
className={`grid grid-cols-[1fr_auto_auto_auto] items-start gap-x-6 px-4 py-3 ${i < sessions.length - 1 ? 'border-b border-edge' : ''}`}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate text-content">{session.client_name}</p>
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{visible.map(scope => (
|
||||
<span key={scope} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono bg-surface-secondary border border-edge"
|
||||
style={{ color: 'var(--text-tertiary)' }}>
|
||||
{scope}
|
||||
</span>
|
||||
))}
|
||||
{!expanded && hidden > 0 && (
|
||||
<button type="button" onClick={() => toggleScopes(session.id)}
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80 bg-surface-secondary text-content-secondary border border-edge">
|
||||
+{hidden} more
|
||||
</button>
|
||||
)}
|
||||
{expanded && hidden > 0 && (
|
||||
<button type="button" onClick={() => toggleScopes(session.id)}
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80 bg-surface-secondary text-content-secondary border border-edge">
|
||||
show less
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<div>
|
||||
{/* OAuth Sessions. The tab's own subtitle sits in its band: the sidebar
|
||||
already names the tab, so a bare heading over the cards only repeated it. */}
|
||||
<SettingsCard
|
||||
icon={Bot}
|
||||
title={t('admin.oauthSessions.sectionTitle')}
|
||||
hint={t('admin.mcpTokens.subtitle')}
|
||||
badge={!sessionsLoading && sessions.length > 0 ? <StatusPill>{sessions.length}</StatusPill> : undefined}
|
||||
>
|
||||
{sessionsLoading || sessions.length === 0 ? (
|
||||
<ListState loading={sessionsLoading} icon={<Shield size={24} strokeWidth={1.6} />} text={t('admin.oauthSessions.empty')} />
|
||||
) : (
|
||||
<SettingRows>
|
||||
{sessions.map(session => {
|
||||
const expanded = expandedScopes.has(session.id)
|
||||
const visible = expanded ? session.scopes : session.scopes.slice(0, SCOPES_PREVIEW)
|
||||
const hidden = session.scopes.length - SCOPES_PREVIEW
|
||||
return (
|
||||
<div key={session.id} className={`${ROW} items-start`}>
|
||||
<span className={TILE}><Bot size={16} strokeWidth={1.9} /></span>
|
||||
<div className="min-w-0 flex-1 basis-60">
|
||||
<p className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>{session.client_name}</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1" style={fs(11)}>
|
||||
{visible.map(scope => (
|
||||
<span key={scope} className={CHIP}>{scope}</span>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<button type="button" onClick={() => toggleScopes(session.id)}
|
||||
className="inline-flex items-center rounded-full bg-surface-tertiary px-2 py-[1px] font-semibold text-content-secondary hover:text-content">
|
||||
{expanded ? 'show less' : `+${hidden} more`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm pt-0.5 text-content-secondary">
|
||||
<User className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">{session.username}</span>
|
||||
</div>
|
||||
<span className="text-xs whitespace-nowrap text-right pt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{new Date(session.created_at).toLocaleDateString(locale)}
|
||||
</span>
|
||||
<button type="button" onClick={() => setRevokeConfirmId(session.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-none items-center gap-4 pt-0.5">
|
||||
<Owner name={session.username} />
|
||||
<Meta label={t('admin.oauthSessions.created')}>{date(session.created_at)}</Meta>
|
||||
<DeleteAction label={t('common.delete')} onClick={() => setRevokeConfirmId(session.id)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</SettingRows>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
{/* MCP Tokens */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2 text-content-secondary">{t('admin.mcpTokens.sectionTitle')}</h3>
|
||||
<div className="rounded-xl border overflow-hidden border-edge bg-surface-card">
|
||||
{tokensLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</div>
|
||||
) : tokens.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<Key className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b border-edge bg-surface-secondary"
|
||||
style={{ color: 'var(--text-tertiary)' }}>
|
||||
<span>{t('admin.mcpTokens.tokenName')}</span>
|
||||
<span>{t('admin.mcpTokens.owner')}</span>
|
||||
<span className="text-right">{t('admin.mcpTokens.created')}</span>
|
||||
<span className="text-right">{t('admin.mcpTokens.lastUsed')}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{tokens.map((token, i) => (
|
||||
<div key={token.id}
|
||||
className={`grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3 ${i < tokens.length - 1 ? 'border-b border-edge' : ''}`}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate text-content">{token.name}</p>
|
||||
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{token.token_prefix}...</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm text-content-secondary">
|
||||
<User className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">{token.username}</span>
|
||||
</div>
|
||||
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{new Date(token.created_at).toLocaleDateString(locale)}
|
||||
</span>
|
||||
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')}
|
||||
</span>
|
||||
<button type="button" onClick={() => setDeleteConfirmId(token.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<SettingsCard
|
||||
icon={KeyRound}
|
||||
title={t('admin.mcpTokens.sectionTitle')}
|
||||
badge={!tokensLoading && tokens.length > 0 ? <StatusPill>{tokens.length}</StatusPill> : undefined}
|
||||
>
|
||||
{tokensLoading || tokens.length === 0 ? (
|
||||
<ListState loading={tokensLoading} icon={<Key size={24} strokeWidth={1.6} />} text={t('admin.mcpTokens.empty')} />
|
||||
) : (
|
||||
<SettingRows>
|
||||
{tokens.map(token => (
|
||||
<div key={token.id} className={ROW}>
|
||||
<span className={TILE}><KeyRound size={16} strokeWidth={1.9} /></span>
|
||||
<div className="min-w-0 flex-1 basis-48">
|
||||
<p className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>{token.name}</p>
|
||||
<p className="m-0 mt-0.5 truncate font-geist text-content-faint" style={fs(11)}>{token.token_prefix}...</p>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-none items-center gap-4">
|
||||
<Owner name={token.username} />
|
||||
<Meta label={t('admin.mcpTokens.created')}>{date(token.created_at)}</Meta>
|
||||
<Meta label={t('admin.mcpTokens.lastUsed')}>
|
||||
{token.last_used_at ? date(token.last_used_at) : t('admin.mcpTokens.never')}
|
||||
</Meta>
|
||||
<DeleteAction label={t('common.delete')} onClick={() => setDeleteConfirmId(token.id)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</SettingRows>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
{/* Revoke OAuth session modal */}
|
||||
{revokeConfirmId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
role="button" tabIndex={0} aria-label={t('common.close')}
|
||||
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}
|
||||
onKeyDown={e => {
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setRevokeConfirmId(null) }
|
||||
}}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
|
||||
<h3 className="text-base font-semibold text-content">{t('admin.oauthSessions.revokeTitle')}</h3>
|
||||
<p className="text-sm text-content-secondary">{t('admin.oauthSessions.revokeMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button type="button" onClick={() => setRevokeConfirmId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" onClick={() => handleRevoke(revokeConfirmId)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Revoke OAuth session */}
|
||||
<ConfirmDialog
|
||||
isOpen={revokeConfirmId !== null}
|
||||
onClose={() => setRevokeConfirmId(null)}
|
||||
onConfirm={() => { if (revokeConfirmId !== null) void handleRevoke(revokeConfirmId) }}
|
||||
title={t('admin.oauthSessions.revokeTitle')}
|
||||
message={t('admin.oauthSessions.revokeMessage')}
|
||||
confirmLabel={t('common.delete')}
|
||||
danger
|
||||
/>
|
||||
|
||||
{/* Delete MCP token modal */}
|
||||
{deleteConfirmId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
role="button" tabIndex={0} aria-label={t('common.close')}
|
||||
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}
|
||||
onKeyDown={e => {
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setDeleteConfirmId(null) }
|
||||
}}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
|
||||
<h3 className="text-base font-semibold text-content">{t('admin.mcpTokens.deleteTitle')}</h3>
|
||||
<p className="text-sm text-content-secondary">{t('admin.mcpTokens.deleteMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button type="button" onClick={() => setDeleteConfirmId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" onClick={() => handleDelete(deleteConfirmId)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Delete MCP token */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirmId !== null}
|
||||
onClose={() => setDeleteConfirmId(null)}
|
||||
onConfirm={() => { if (deleteConfirmId !== null) void handleDelete(deleteConfirmId) }}
|
||||
title={t('admin.mcpTokens.deleteTitle')}
|
||||
message={t('admin.mcpTokens.deleteMessage')}
|
||||
confirmLabel={t('common.delete')}
|
||||
danger
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,12 @@ function mockList(p: Record<string, unknown>) {
|
||||
|
||||
beforeEach(() => resetAllStores())
|
||||
|
||||
/** A press on a dialog's backdrop: DialogShell closes only when the press both starts and ends there. */
|
||||
function clickBackdrop(el: HTMLElement) {
|
||||
fireEvent.mouseDown(el)
|
||||
fireEvent.click(el)
|
||||
}
|
||||
|
||||
describe('AdminPluginsPanel — allowed-hosts chip', () => {
|
||||
it('FE-COMP-PLUGINS-EGRESS-001: invites the admin to add a host when none is set', async () => {
|
||||
mockList(plugin({ egressHostCount: 0 }))
|
||||
@@ -658,7 +664,7 @@ describe('AdminPluginsPanel — load states and toolbar', () => {
|
||||
withToast()
|
||||
await screen.findByText('Gotify')
|
||||
|
||||
fireEvent.click(screen.getAllByTitle('Rescan')[0])
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Rescan' })[0])
|
||||
|
||||
expect(await screen.findByText('Rescanned the plugins folder')).toBeInTheDocument()
|
||||
expect(refreshFlags).toContain('1')
|
||||
@@ -676,7 +682,7 @@ describe('AdminPluginsPanel — sideloading', () => {
|
||||
|
||||
const input = container.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const clickSpy = vi.spyOn(input, 'click').mockImplementation(() => {})
|
||||
fireEvent.click(screen.getAllByTitle('Upload plugin')[0])
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Upload plugin' })[0])
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
clickSpy.mockRestore()
|
||||
|
||||
@@ -763,7 +769,7 @@ describe('AdminPluginsPanel — row actions', () => {
|
||||
|
||||
/** Every modal in the panel closes by clicking its backdrop. */
|
||||
function closeModal() {
|
||||
fireEvent.click(document.querySelector('.fixed.inset-0.z-50') as HTMLElement)
|
||||
clickBackdrop(document.querySelector('.trek-modal-backdrop') as HTMLElement)
|
||||
}
|
||||
|
||||
it('FE-COMP-PLUGINS-PANEL-014: the error log lists what the runtime recorded', async () => {
|
||||
@@ -888,7 +894,7 @@ describe('AdminPluginsPanel — row actions', () => {
|
||||
const body = (await screen.findByText('gotify.lan')).closest('.overflow-y-auto')
|
||||
expect(body).not.toBeNull()
|
||||
expect(body).toContainElement(screen.getByPlaceholderText('gotify.example.com'))
|
||||
expect(body!.parentElement!.className).toContain('max-h-[88vh]')
|
||||
expect(body!.parentElement!.className).toContain('max-h-full')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1875,7 +1881,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
await user.click(screen.getByRole('button', { name: /view error log/i }))
|
||||
|
||||
const empty = await screen.findByText(/no errors/i)
|
||||
fireEvent.click(empty.closest('.fixed') as HTMLElement)
|
||||
clickBackdrop(empty.closest('.fixed') as HTMLElement)
|
||||
await waitFor(() => expect(screen.queryByText(/no errors/i)).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
@@ -2052,7 +2058,7 @@ describe('AdminPluginsPanel registry cards and dependency chips', () => {
|
||||
fireEvent.keyDown(card, { key: 'Enter' })
|
||||
expect(await screen.findByRole('heading', { name: 'Mapper' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('heading', { name: 'Mapper' }).closest('.fixed') as HTMLElement)
|
||||
clickBackdrop(screen.getByRole('heading', { name: 'Mapper' }).closest('.fixed') as HTMLElement)
|
||||
await waitFor(() => expect(screen.queryByRole('heading', { name: 'Mapper' })).not.toBeInTheDocument())
|
||||
|
||||
fireEvent.keyDown(card, { key: ' ' })
|
||||
@@ -2174,7 +2180,7 @@ describe('AdminPluginsPanel signature fingerprints and dismissals', () => {
|
||||
fireEvent.click(await screen.findByRole('button', { name: /review/i }))
|
||||
const detail = await screen.findByText('only half signed')
|
||||
|
||||
fireEvent.click(detail.closest('.fixed') as HTMLElement)
|
||||
clickBackdrop(detail.closest('.fixed') as HTMLElement)
|
||||
await waitFor(() => expect(screen.queryByText('only half signed')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
@@ -2196,7 +2202,7 @@ describe('AdminPluginsPanel signature fingerprints and dismissals', () => {
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /1 allowed host/i }))
|
||||
const reopened = (await screen.findByText('gotify.mydomain.com')).closest('.fixed') as HTMLElement
|
||||
fireEvent.click(reopened)
|
||||
clickBackdrop(reopened)
|
||||
await waitFor(() => expect(screen.queryByText('gotify.mydomain.com')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
@@ -2355,15 +2361,17 @@ describe('AdminPluginsPanel — instance settings', () => {
|
||||
fireEvent.click(screen.getByText('Instance settings'))
|
||||
|
||||
// Stored values land in the right controls; an unset value renders empty.
|
||||
const select = await screen.findByRole('combobox', {}, { timeout: 5000 })
|
||||
expect(select).toHaveValue('slow')
|
||||
expect(screen.getByRole('checkbox')).toBeChecked()
|
||||
const select = await screen.findByRole('button', { name: 'Mode' }, { timeout: 5000 })
|
||||
expect(select).toHaveTextContent('Slow')
|
||||
const toggle = screen.getByRole('button', { name: 'enabled' })
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByRole('spinbutton')).toHaveValue(null)
|
||||
expect(screen.getByText('Pick one')).toBeInTheDocument() // hint
|
||||
expect(screen.getByText('enabled')).toBeInTheDocument() // label falls back to the key
|
||||
|
||||
fireEvent.change(select, { target: { value: 'fast' } })
|
||||
fireEvent.click(screen.getByRole('checkbox'))
|
||||
fireEvent.click(select)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Fast' }))
|
||||
fireEvent.click(toggle)
|
||||
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '3' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,9 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { RefreshCw, ClipboardList } from 'lucide-react'
|
||||
import { RefreshCw, ClipboardList, Loader2 } from 'lucide-react'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { SETTINGS_BUTTON, SettingsCard, SettingsHint, StatusPill } from '../Settings/settingsKit'
|
||||
|
||||
interface AuditEntry {
|
||||
id: number
|
||||
@@ -19,6 +21,10 @@ interface AuditLogPanelProps {
|
||||
serverTimezone?: string
|
||||
}
|
||||
|
||||
const TH = 'whitespace-nowrap px-3.5 py-2.5 text-left font-geist font-bold uppercase tracking-[.08em] text-content-faint'
|
||||
const TD = 'px-3.5 py-2.5 align-top'
|
||||
const CODE = 'font-geist tabular-nums'
|
||||
|
||||
export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([])
|
||||
@@ -96,74 +102,86 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R
|
||||
return '—'
|
||||
}
|
||||
|
||||
let body: React.ReactNode
|
||||
if (loading && entries.length === 0) {
|
||||
body = (
|
||||
<div className="flex items-center justify-center gap-2 py-10 text-content-faint" style={fs(12.5, 'body')}>
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
<span>{t('common.loading')}</span>
|
||||
</div>
|
||||
)
|
||||
} else if (entries.length === 0) {
|
||||
body = <SettingsHint className="py-6 text-center">{t('admin.audit.empty')}</SettingsHint>
|
||||
} else {
|
||||
body = (
|
||||
<div className={`overflow-x-auto rounded-[12px] border border-edge-faint bg-surface-card transition-opacity ${loading ? 'opacity-60' : ''}`}>
|
||||
<table className="w-full min-w-[720px] border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-edge-faint bg-surface-secondary" style={fs(9.5)}>
|
||||
<th className={TH}>{t('admin.audit.col.time')}</th>
|
||||
<th className={TH}>{t('admin.audit.col.user')}</th>
|
||||
<th className={TH}>{t('admin.audit.col.action')}</th>
|
||||
<th className={TH}>{t('admin.audit.col.resource')}</th>
|
||||
<th className={TH}>{t('admin.audit.col.ip')}</th>
|
||||
<th className={TH}>{t('admin.audit.col.details')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-edge-faint" style={fs(12, 'body')}>
|
||||
{entries.map((e) => (
|
||||
<tr key={e.id} className="hover:bg-surface-secondary">
|
||||
<td className={`${TD} ${CODE} whitespace-nowrap text-content-muted`}>{fmtTime(e.created_at)}</td>
|
||||
<td className={`${TD} font-medium text-content`}>
|
||||
{/* A table cell ignores max-width under auto layout, so the box inside truncates. */}
|
||||
<span className="block max-w-[180px] truncate" title={userLabel(e)}>{userLabel(e)}</span>
|
||||
</td>
|
||||
<td className={`${TD} whitespace-nowrap`}>
|
||||
<span className={`${CODE} inline-block rounded-[6px] bg-surface-tertiary px-1.5 py-[1px] text-content`}>{e.action}</span>
|
||||
</td>
|
||||
<td className={`${TD} ${CODE} max-w-[160px] break-all text-content-muted`}>{e.resource || '—'}</td>
|
||||
<td className={`${TD} ${CODE} whitespace-nowrap text-content-muted`}>{e.ip || '—'}</td>
|
||||
<td className={`${TD} ${CODE} max-w-[300px] break-all text-content-faint`}>{fmtDetails(e.details)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg m-0 flex items-center gap-2 text-content">
|
||||
<ClipboardList size={20} />
|
||||
{t('admin.tabs.audit')}
|
||||
</h2>
|
||||
<p className="text-sm m-0 mt-1 text-content-muted">{t('admin.audit.subtitle')}</p>
|
||||
</div>
|
||||
<SettingsCard
|
||||
icon={ClipboardList}
|
||||
title={t('admin.tabs.audit')}
|
||||
hint={t('admin.audit.subtitle')}
|
||||
badge={<StatusPill>{t('admin.audit.showing', { count: entries.length, total })}</StatusPill>}
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => loadFirstPage()}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-opacity disabled:opacity-50 border-edge text-content bg-surface-card"
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
<RefreshCw size={14} strokeWidth={2.2} className={loading ? 'animate-spin' : ''} />
|
||||
{t('admin.audit.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs m-0 text-content-faint">
|
||||
{t('admin.audit.showing', { count: entries.length, total })}
|
||||
</p>
|
||||
|
||||
{loading && entries.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-content-muted">{t('common.loading')}</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-content-muted">{t('admin.audit.empty')}</div>
|
||||
) : (
|
||||
<div className="rounded-xl border overflow-x-auto border-edge bg-surface-card">
|
||||
<table className="w-full text-sm border-collapse min-w-[720px]">
|
||||
<thead>
|
||||
<tr className="border-b text-left border-edge-secondary">
|
||||
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.time')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.user')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.action')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.resource')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.ip')}</th>
|
||||
<th className="p-3 font-semibold text-content-secondary">{t('admin.audit.col.details')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => (
|
||||
<tr key={e.id} className="border-b align-top border-edge-secondary">
|
||||
<td className="p-3 whitespace-nowrap font-mono text-xs text-content">{fmtTime(e.created_at)}</td>
|
||||
<td className="p-3 text-content">{userLabel(e)}</td>
|
||||
<td className="p-3 font-mono text-xs text-content">{e.action}</td>
|
||||
<td className="p-3 font-mono text-xs break-all max-w-[140px] text-content-muted">{e.resource || '—'}</td>
|
||||
<td className="p-3 font-mono text-xs whitespace-nowrap text-content-muted">{e.ip || '—'}</td>
|
||||
<td className="p-3 font-mono text-xs break-all max-w-[280px] text-content-faint">{fmtDetails(e.details)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
>
|
||||
{body}
|
||||
|
||||
{entries.length < total && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => loadMore()}
|
||||
className="text-sm font-medium underline-offset-2 hover:underline disabled:opacity-50 text-content-secondary"
|
||||
>
|
||||
{t('admin.audit.loadMore')}
|
||||
</button>
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => loadMore()}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{t('admin.audit.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -165,8 +165,8 @@ describe('BackupPanel', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Restore Backup?')).toBeInTheDocument()
|
||||
})
|
||||
// Click the backdrop overlay (the fixed-position div)
|
||||
const backdrop = document.querySelector('[style*="position: fixed"]') as HTMLElement
|
||||
// Click the backdrop overlay (the shared confirm dialog's dimmed layer)
|
||||
const backdrop = document.querySelector('.trek-backdrop-enter') as HTMLElement
|
||||
expect(backdrop).toBeTruthy()
|
||||
fireEvent.click(backdrop!)
|
||||
await waitFor(() => {
|
||||
@@ -215,6 +215,10 @@ describe('BackupPanel', () => {
|
||||
) as HTMLElement
|
||||
expect(trashBtn).toBeTruthy()
|
||||
await user.click(trashBtn!)
|
||||
// The question is the shared confirm dialog now, not window.confirm
|
||||
expect(await screen.findByText('Delete backup "backup-2025-01-15.zip"?')).toBeInTheDocument()
|
||||
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' })
|
||||
await user.click(deleteButtons[deleteButtons.length - 1]!)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Backup deleted')).toBeInTheDocument()
|
||||
})
|
||||
@@ -421,7 +425,6 @@ describe('BackupPanel', () => {
|
||||
return HttpResponse.json({ error: 'file is locked' }, { status: 500 })
|
||||
}),
|
||||
)
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await screen.findByText('backup-2025-01-15.zip')
|
||||
|
||||
@@ -429,11 +432,13 @@ describe('BackupPanel', () => {
|
||||
b => b.querySelector('svg.lucide-trash2'),
|
||||
) as HTMLElement
|
||||
await user.click(trashBtn)
|
||||
await user.click(await screen.findByRole('button', { name: 'Cancel' }))
|
||||
expect(deleteCalls).toBe(0)
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
|
||||
|
||||
confirmSpy.mockReturnValue(true)
|
||||
await user.click(trashBtn)
|
||||
const deleteButtons = await screen.findAllByRole('button', { name: 'Delete' })
|
||||
await user.click(deleteButtons[deleteButtons.length - 1]!)
|
||||
|
||||
expect(await screen.findByText('Failed to delete')).toBeInTheDocument()
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
|
||||
@@ -584,18 +589,24 @@ describe('BackupPanel', () => {
|
||||
expect(await screen.findByText('Download failed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// BKP-029: Confirm button hover styling
|
||||
it('FE-ADMIN-BKP-029: the destructive confirm button darkens on hover', async () => {
|
||||
// BKP-029: Escape takes the restore question back
|
||||
it('FE-ADMIN-BKP-029: Escape dismisses the restore question without restoring', async () => {
|
||||
const user = userEvent.setup()
|
||||
let restoreCalled = false
|
||||
server.use(
|
||||
http.post('/api/backup/restore/:filename', () => {
|
||||
restoreCalled = true
|
||||
return HttpResponse.json({ success: true })
|
||||
}),
|
||||
)
|
||||
render(<BackupPanel />)
|
||||
await screen.findByText('backup-2025-01-15.zip')
|
||||
|
||||
await user.click(screen.getAllByText('Restore')[0])
|
||||
const confirmBtn = await screen.findByText('Yes, restore')
|
||||
await screen.findByText('Yes, restore')
|
||||
|
||||
fireEvent.mouseEnter(confirmBtn)
|
||||
expect(confirmBtn.style.background).toBe('rgb(185, 28, 28)')
|
||||
fireEvent.mouseLeave(confirmBtn)
|
||||
expect(confirmBtn.style.background).toBe('rgb(220, 38, 38)')
|
||||
await user.keyboard('{Escape}')
|
||||
await waitFor(() => expect(screen.queryByText('Restore Backup?')).not.toBeInTheDocument())
|
||||
expect(restoreCalled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useState, useEffect, useRef, type ChangeEvent } from 'react'
|
||||
import { backupApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react'
|
||||
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { Segmented } from '../shared/dialogParts'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
import {
|
||||
ChoiceChips,
|
||||
SETTINGS_BUTTON,
|
||||
SETTINGS_BUTTON_PRIMARY,
|
||||
SETTINGS_ICON_BUTTON,
|
||||
SettingRow,
|
||||
SettingRows,
|
||||
SettingsCard,
|
||||
StatusPill,
|
||||
} from '../Settings/settingsKit'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
const INTERVAL_OPTIONS = [
|
||||
@@ -37,20 +53,36 @@ const HOURS = Array.from({ length: 24 }, (_, i) => i)
|
||||
|
||||
const DAYS_OF_MONTH = Array.from({ length: 28 }, (_, i) => i + 1)
|
||||
|
||||
/** The compact buttons of a backup row: white on a hairline, a size under the card's own. */
|
||||
const ROW_BUTTON = 'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 py-1.5 font-medium text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:cursor-default disabled:opacity-50'
|
||||
|
||||
interface BackupItem {
|
||||
filename: string
|
||||
created_at?: string | null
|
||||
size?: number | null
|
||||
}
|
||||
|
||||
interface RestoreTarget {
|
||||
type: 'file' | 'upload'
|
||||
filename: string
|
||||
file?: File
|
||||
}
|
||||
|
||||
export default function BackupPanel() {
|
||||
const [backups, setBackups] = useState([])
|
||||
const [backups, setBackups] = useState<BackupItem[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [restoringFile, setRestoringFile] = useState(null)
|
||||
const [restoringFile, setRestoringFile] = useState<string | null>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 })
|
||||
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
|
||||
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
|
||||
const [serverTimezone, setServerTimezone] = useState('')
|
||||
const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? }
|
||||
const fileInputRef = useRef(null)
|
||||
const [restoreConfirm, setRestoreConfirm] = useState<RestoreTarget | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const toast = useToast()
|
||||
const { t, language, locale } = useTranslation()
|
||||
const { t, locale } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
|
||||
const loadBackups = async () => {
|
||||
@@ -88,12 +120,12 @@ export default function BackupPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestore = (filename) => {
|
||||
const handleRestore = (filename: string) => {
|
||||
setRestoreConfirm({ type: 'file', filename })
|
||||
}
|
||||
|
||||
const handleUploadRestore = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
const handleUploadRestore = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
e.target.value = ''
|
||||
setRestoreConfirm({ type: 'upload', filename: file.name, file })
|
||||
@@ -127,8 +159,15 @@ export default function BackupPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (filename) => {
|
||||
if (!confirm(t('backup.confirm.delete', { name: filename }))) return
|
||||
// The question is asked in the planner's confirm dialog; the delete itself runs once it is answered.
|
||||
const handleDelete = (filename: string) => {
|
||||
setDeleteTarget(filename)
|
||||
}
|
||||
|
||||
const executeDelete = async () => {
|
||||
const filename = deleteTarget
|
||||
setDeleteTarget(null)
|
||||
if (!filename) return
|
||||
try {
|
||||
await backupApi.delete(filename)
|
||||
toast.success(t('backup.toast.deleted'))
|
||||
@@ -138,7 +177,7 @@ export default function BackupPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAutoSettingsChange = (key, value) => {
|
||||
const handleAutoSettingsChange = (key: string, value: unknown) => {
|
||||
setAutoSettings(prev => ({ ...prev, [key]: value }))
|
||||
setAutoSettingsDirty(true)
|
||||
}
|
||||
@@ -157,13 +196,13 @@ export default function BackupPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
const formatSize = (bytes: number | null | undefined) => {
|
||||
if (!bytes) return '-'
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const formatDate = (dateStr: string | null | undefined) => {
|
||||
if (!dateStr) return '-'
|
||||
try {
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
@@ -175,352 +214,315 @@ export default function BackupPanel() {
|
||||
} catch { return dateStr }
|
||||
}
|
||||
|
||||
const isAuto = (filename) => filename.startsWith('auto-backup-')
|
||||
const isAuto = (filename: string) => filename.startsWith('auto-backup-')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
const headerActions = (
|
||||
<>
|
||||
<Tooltip label={t('backup.refresh')}>
|
||||
<button type="button"
|
||||
onClick={loadBackups}
|
||||
disabled={isLoading}
|
||||
aria-label={t('backup.refresh')}
|
||||
className={SETTINGS_ICON_BUTTON}
|
||||
>
|
||||
<RefreshCw size={14} strokeWidth={2} className={isLoading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Manual Backups */}
|
||||
<div className="bg-white rounded-2xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<HardDrive className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">{t('backup.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted">{t('backup.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button"
|
||||
onClick={loadBackups}
|
||||
disabled={isLoading}
|
||||
className="p-2 text-gray-500 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title={t('backup.refresh')}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
{/* Upload & Restore */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="hidden"
|
||||
onChange={handleUploadRestore}
|
||||
/>
|
||||
<button type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
title={isUploading ? t('backup.uploading') : t('backup.upload')}
|
||||
>
|
||||
{isUploading
|
||||
? <Loader2 size={14} strokeWidth={2.2} className="animate-spin" />
|
||||
: <Upload size={14} strokeWidth={2.2} />}
|
||||
<span>{isUploading ? t('backup.uploading') : t('backup.upload')}</span>
|
||||
</button>
|
||||
|
||||
{/* Upload & Restore */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="hidden"
|
||||
onChange={handleUploadRestore}
|
||||
/>
|
||||
<button type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="flex items-center gap-2 border border-gray-200 text-gray-700 px-3 py-2 rounded-lg hover:bg-gray-50 text-sm font-medium disabled:opacity-60"
|
||||
title={isUploading ? t('backup.uploading') : t('backup.upload')}
|
||||
>
|
||||
{isUploading ? (
|
||||
<div className="w-4 h-4 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Upload className="w-4 h-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">{isUploading ? t('backup.uploading') : t('backup.upload')}</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={handleCreate}
|
||||
disabled={isCreating}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
style={fs(12.5, 'body')}
|
||||
title={isCreating ? t('backup.creating') : t('backup.create')}
|
||||
>
|
||||
{isCreating
|
||||
? <Loader2 size={14} strokeWidth={2.2} className="animate-spin" />
|
||||
: <Plus size={14} strokeWidth={2.2} />}
|
||||
<span>{isCreating ? t('backup.creating') : t('backup.create')}</span>
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
|
||||
<button type="button"
|
||||
onClick={handleCreate}
|
||||
disabled={isCreating}
|
||||
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-60"
|
||||
title={isCreating ? t('backup.creating') : t('backup.create')}
|
||||
>
|
||||
{isCreating ? (
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Plus className="w-4 h-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">{isCreating ? t('backup.creating') : t('backup.create')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && backups.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12 text-gray-400">
|
||||
<div className="w-6 h-6 border-2 border-gray-300 border-t-slate-700 rounded-full animate-spin mr-2" />
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : backups.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<HardDrive className="w-10 h-10 mb-3 mx-auto opacity-40" />
|
||||
<p className="text-sm">{t('backup.empty')}</p>
|
||||
<button type="button" onClick={handleCreate} className="mt-4 text-slate-700 text-sm hover:underline">
|
||||
{t('backup.createFirst')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{backups.map(backup => (
|
||||
<div key={backup.filename} className="flex items-center gap-4 py-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0">
|
||||
{isAuto(backup.filename)
|
||||
? <RefreshCw className="w-4 h-4 text-blue-500" />
|
||||
: <HardDrive className="w-4 h-4 text-gray-500" />
|
||||
}
|
||||
let backupList
|
||||
if (isLoading && backups.length === 0) {
|
||||
backupList = (
|
||||
<div className="flex items-center justify-center gap-2 py-10 text-content-faint" style={fs(12.5, 'body')}>
|
||||
<Loader2 size={16} strokeWidth={2} className="animate-spin" />
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
)
|
||||
} else if (backups.length === 0) {
|
||||
backupList = (
|
||||
<EmptyState
|
||||
title={t('backup.empty')}
|
||||
size={84}
|
||||
compact
|
||||
surface="var(--bg-secondary)"
|
||||
className="!py-6"
|
||||
action={
|
||||
<button type="button" onClick={handleCreate} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
|
||||
<Plus size={14} strokeWidth={2.2} />
|
||||
{t('backup.createFirst')}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
backupList = (
|
||||
<SettingRows>
|
||||
{backups.map(backup => {
|
||||
const auto = isAuto(backup.filename)
|
||||
const restoring = restoringFile === backup.filename
|
||||
return (
|
||||
<div key={backup.filename} className="flex flex-wrap items-center gap-x-3 gap-y-2 px-3.5 py-3">
|
||||
<span className="grid h-9 w-9 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-muted">
|
||||
{auto
|
||||
? <RefreshCw size={15} strokeWidth={2} />
|
||||
: <HardDrive size={15} strokeWidth={2} />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 basis-48">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="m-0 truncate font-geist font-semibold text-content" style={fs(13, 'body')}>{backup.filename}</p>
|
||||
{auto && <StatusPill>Auto</StatusPill>}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-sm text-gray-900 truncate">{backup.filename}</p>
|
||||
{isAuto(backup.filename) && (
|
||||
<span className="text-xs bg-blue-50 text-blue-600 border border-blue-100 rounded-full px-2 py-0.5 whitespace-nowrap">Auto</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-0.5">
|
||||
<span className="text-xs text-gray-400">{formatDate(backup.created_at)}</span>
|
||||
<span className="text-xs text-gray-400">{formatSize(backup.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<button type="button"
|
||||
onClick={() => backupApi.download(backup.filename).catch(() => toast.error(t('backup.toast.downloadError')))}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-slate-700 border border-slate-200 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
{t('backup.download')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => handleRestore(backup.filename)}
|
||||
disabled={restoringFile === backup.filename}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-amber-700 border border-amber-200 rounded-lg hover:bg-amber-50 disabled:opacity-60"
|
||||
>
|
||||
{restoringFile === backup.filename
|
||||
? <div className="w-3.5 h-3.5 border-2 border-amber-400 border-t-transparent rounded-full animate-spin" />
|
||||
: <RotateCcw className="w-3.5 h-3.5" />
|
||||
}
|
||||
{t('backup.restore')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => handleDelete(backup.filename)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 font-geist tabular-nums text-content-faint" style={fs(11.5)}>
|
||||
<span>{formatDate(backup.created_at)}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{formatSize(backup.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-none items-center gap-1.5">
|
||||
<button type="button"
|
||||
onClick={() => backupApi.download(backup.filename).catch(() => toast.error(t('backup.toast.downloadError')))}
|
||||
className={ROW_BUTTON}
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
<Download size={13} strokeWidth={2.2} />
|
||||
{t('backup.download')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => handleRestore(backup.filename)}
|
||||
disabled={restoring}
|
||||
className={ROW_BUTTON}
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{restoring
|
||||
? <Loader2 size={13} strokeWidth={2.2} className="animate-spin text-warning" />
|
||||
: <RotateCcw size={13} strokeWidth={2.2} className="text-warning" />}
|
||||
{t('backup.restore')}
|
||||
</button>
|
||||
<Tooltip label={t('common.delete')}>
|
||||
<button type="button"
|
||||
onClick={() => handleDelete(backup.filename)}
|
||||
aria-label={t('common.delete')}
|
||||
className={`${SETTINGS_ICON_BUTTON} hover:!text-danger`}
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</SettingRows>
|
||||
)
|
||||
}
|
||||
|
||||
const hourOptions = HOURS.map(h => {
|
||||
let label: string
|
||||
if (is12h) {
|
||||
const period = h >= 12 ? 'PM' : 'AM'
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||
label = `${h12}:00 ${period}`
|
||||
} else {
|
||||
label = `${String(h).padStart(2, '0')}:00`
|
||||
}
|
||||
return { value: String(h), label }
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
|
||||
{/* Manual Backups */}
|
||||
<SettingsCard
|
||||
icon={HardDrive}
|
||||
title={t('backup.title')}
|
||||
hint={t('backup.subtitle')}
|
||||
action={headerActions}
|
||||
>
|
||||
{backupList}
|
||||
</SettingsCard>
|
||||
|
||||
{/* Auto-Backup Settings */}
|
||||
<div className="bg-white rounded-2xl border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Clock className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">{t('backup.auto.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted">{t('backup.auto.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Enable toggle */}
|
||||
<label className="flex items-center justify-between gap-4 cursor-pointer">
|
||||
<div className="min-w-0 text-sm font-medium text-gray-900">
|
||||
{t('backup.auto.enable')}
|
||||
<p className="text-xs font-normal text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p>
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
|
||||
className="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: autoSettings.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: autoSettings.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
<SettingsCard icon={Clock} title={t('backup.auto.title')} hint={t('backup.auto.subtitle')}>
|
||||
<SettingRows>
|
||||
{/* The whole row is the switch's label, so a click on the text flips it too. */}
|
||||
<label className="flex cursor-pointer items-center gap-4 px-3.5 py-3">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-medium text-content" style={fs(13, 'body')}>{t('backup.auto.enable')}</span>
|
||||
<span className="mt-0.5 block leading-snug text-content-faint" style={fs(11.5)}>{t('backup.auto.enableHint')}</span>
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
on={!!autoSettings.enabled}
|
||||
onToggle={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
|
||||
label={t('backup.auto.enable')}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{autoSettings.enabled && (
|
||||
<>
|
||||
{/* Interval */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.interval')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{INTERVAL_OPTIONS.map(opt => (
|
||||
<button type="button"
|
||||
key={opt.value}
|
||||
onClick={() => handleAutoSettingsChange('interval', opt.value)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
autoSettings.interval === opt.value
|
||||
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
|
||||
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t(opt.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t('backup.auto.interval')}
|
||||
control={
|
||||
<Segmented<string>
|
||||
label={t('backup.auto.interval')}
|
||||
value={autoSettings.interval}
|
||||
onChange={v => handleAutoSettingsChange('interval', v)}
|
||||
options={INTERVAL_OPTIONS.map(opt => ({ value: opt.value, label: t(opt.labelKey) }))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Hour picker (for daily, weekly, monthly) */}
|
||||
{autoSettings.interval !== 'hourly' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.hour')}</label>
|
||||
<CustomSelect
|
||||
value={String(autoSettings.hour)}
|
||||
onChange={v => handleAutoSettingsChange('hour', Number.parseInt(String(v), 10))}
|
||||
size="sm"
|
||||
options={HOURS.map(h => {
|
||||
let label: string
|
||||
if (is12h) {
|
||||
const period = h >= 12 ? 'PM' : 'AM'
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||
label = `${h12}:00 ${period}`
|
||||
} else {
|
||||
label = `${String(h).padStart(2, '0')}:00`
|
||||
}
|
||||
return { value: String(h), label }
|
||||
})}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{t('backup.auto.hourHint', { format: is12h ? '12h' : '24h' })}{serverTimezone ? ` (Timezone: ${serverTimezone})` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t('backup.auto.hour')}
|
||||
hint={<>{t('backup.auto.hourHint', { format: is12h ? '12h' : '24h' })}{serverTimezone ? ` (Timezone: ${serverTimezone})` : ''}</>}
|
||||
control={
|
||||
<div className="w-40">
|
||||
<CustomSelect
|
||||
value={String(autoSettings.hour)}
|
||||
onChange={v => handleAutoSettingsChange('hour', Number.parseInt(String(v), 10))}
|
||||
size="sm"
|
||||
options={hourOptions}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Day of week (for weekly) */}
|
||||
{autoSettings.interval === 'weekly' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfWeek')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DAYS_OF_WEEK.map(opt => (
|
||||
<button type="button"
|
||||
key={opt.value}
|
||||
onClick={() => handleAutoSettingsChange('day_of_week', opt.value)}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
autoSettings.day_of_week === opt.value
|
||||
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
|
||||
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t(opt.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<SettingRow
|
||||
stacked
|
||||
label={t('backup.auto.dayOfWeek')}
|
||||
control={
|
||||
<ChoiceChips<string>
|
||||
label={t('backup.auto.dayOfWeek')}
|
||||
value={String(autoSettings.day_of_week)}
|
||||
onChange={v => handleAutoSettingsChange('day_of_week', Number(v))}
|
||||
options={DAYS_OF_WEEK.map(opt => ({ value: String(opt.value), label: t(opt.labelKey) }))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Day of month (for monthly) */}
|
||||
{autoSettings.interval === 'monthly' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfMonth')}</label>
|
||||
<CustomSelect
|
||||
value={String(autoSettings.day_of_month)}
|
||||
onChange={v => handleAutoSettingsChange('day_of_month', Number.parseInt(String(v), 10))}
|
||||
size="sm"
|
||||
options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">{t('backup.auto.dayOfMonthHint')}</p>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t('backup.auto.dayOfMonth')}
|
||||
hint={t('backup.auto.dayOfMonthHint')}
|
||||
control={
|
||||
<div className="w-28">
|
||||
<CustomSelect
|
||||
value={String(autoSettings.day_of_month)}
|
||||
onChange={v => handleAutoSettingsChange('day_of_month', Number.parseInt(String(v), 10))}
|
||||
size="sm"
|
||||
options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Keep duration */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{KEEP_OPTIONS.map(opt => (
|
||||
<button type="button"
|
||||
key={opt.value}
|
||||
onClick={() => handleAutoSettingsChange('keep_days', opt.value)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
autoSettings.keep_days === opt.value
|
||||
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
|
||||
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t(opt.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<SettingRow
|
||||
stacked
|
||||
label={t('backup.auto.keepLabel')}
|
||||
control={
|
||||
<ChoiceChips<string>
|
||||
label={t('backup.auto.keepLabel')}
|
||||
value={String(autoSettings.keep_days)}
|
||||
onChange={v => handleAutoSettingsChange('keep_days', Number(v))}
|
||||
options={KEEP_OPTIONS.map(opt => ({ value: String(opt.value), label: t(opt.labelKey) }))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SettingRows>
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex justify-end pt-2 border-t border-gray-100">
|
||||
<button type="button"
|
||||
onClick={handleSaveAutoSettings}
|
||||
disabled={autoSettingsSaving || !autoSettingsDirty}
|
||||
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-5 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{autoSettingsSaving
|
||||
? <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
: <Check className="w-4 h-4" />
|
||||
}
|
||||
{autoSettingsSaving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Restore Warning Modal */}
|
||||
{restoreConfirm && (
|
||||
<div
|
||||
// Dismiss-on-backdrop is a mouse shortcut for the Cancel button below;
|
||||
// the backdrop itself carries no semantics of its own.
|
||||
role="presentation"
|
||||
className="bg-[rgba(0,0,0,0.5)]"
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 9999, backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onClick={() => setRestoreConfirm(null)}
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }}
|
||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
|
||||
{/* Save button */}
|
||||
<div className="flex justify-end">
|
||||
<button type="button"
|
||||
onClick={handleSaveAutoSettings}
|
||||
disabled={autoSettingsSaving || !autoSettingsDirty}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{/* Red header */}
|
||||
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div className="bg-[rgba(255,255,255,0.2)]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<AlertTriangle size={20} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white" style={{ margin: 0, fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700 }}>
|
||||
{t('backup.restoreConfirmTitle')}
|
||||
</h3>
|
||||
<p className="text-[rgba(255,255,255,0.8)]" style={{ margin: '2px 0 0', fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
|
||||
{restoreConfirm.filename}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ padding: '20px 24px' }}>
|
||||
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.6, margin: 0 }}>
|
||||
{t('backup.restoreWarning')}
|
||||
</p>
|
||||
|
||||
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: 1.5 }}
|
||||
className="bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800"
|
||||
>
|
||||
{t('backup.restoreTip')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ padding: '0 24px 20px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||
<button type="button"
|
||||
onClick={() => setRestoreConfirm(null)}
|
||||
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={executeRestore}
|
||||
className="bg-[#dc2626] text-white"
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
|
||||
>
|
||||
{t('backup.restoreConfirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{autoSettingsSaving
|
||||
? <Loader2 size={14} strokeWidth={2.2} className="animate-spin" />
|
||||
: <Check size={14} strokeWidth={2.2} />}
|
||||
{autoSettingsSaving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
{/* Restore warning */}
|
||||
<ConfirmDialog
|
||||
isOpen={restoreConfirm !== null}
|
||||
onClose={() => setRestoreConfirm(null)}
|
||||
onConfirm={executeRestore}
|
||||
title={t('backup.restoreConfirmTitle')}
|
||||
message={t('backup.restoreWarning')}
|
||||
confirmLabel={t('backup.restoreConfirm')}
|
||||
danger
|
||||
>
|
||||
{restoreConfirm && (
|
||||
<>
|
||||
<div className="flex min-w-0 items-center gap-2.5 rounded-[12px] border border-edge-faint bg-surface-secondary px-3 py-2.5">
|
||||
<HardDrive size={15} strokeWidth={2} className="flex-none text-content-muted" />
|
||||
<span className="min-w-0 truncate font-geist font-semibold text-content" style={fs(12.5, 'body')}>{restoreConfirm.filename}</span>
|
||||
</div>
|
||||
<p className="m-0 rounded-[12px] bg-warning-soft px-3 py-2.5 leading-normal text-warning" style={fs(12, 'body')}>
|
||||
{t('backup.restoreTip')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</ConfirmDialog>
|
||||
|
||||
{/* Delete question */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteTarget !== null}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={executeDelete}
|
||||
title={t('common.delete')}
|
||||
message={deleteTarget ? t('backup.confirm.delete', { name: deleteTarget }) : ''}
|
||||
confirmLabel={t('common.delete')}
|
||||
danger
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useState, useEffect, useRef, useId } from 'react'
|
||||
import { categoriesApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Plus, Edit2, Trash2, Pipette } from 'lucide-react'
|
||||
import { Plus, Pencil, Trash2, Pipette, Tags, Loader2 } from 'lucide-react'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { SettingRows, SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON } from '../Settings/settingsKit'
|
||||
import { CATEGORY_ICON_MAP, ICON_LABELS, getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
@@ -12,18 +15,22 @@ const PRESET_COLORS = [
|
||||
'#6b7280', '#1f2937',
|
||||
]
|
||||
|
||||
// The colour a new category is stored with: data the server keeps, not a theme colour.
|
||||
const DEFAULT_COLOR = '#6366f1' // theme-lint-disable: a stored category colour, not styling
|
||||
|
||||
const ICON_NAMES = Object.keys(CATEGORY_ICON_MAP)
|
||||
|
||||
export default function CategoryManager() {
|
||||
const [categories, setCategories] = useState([])
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingId, setEditingId] = useState(null)
|
||||
const [form, setForm] = useState({ name: '', color: '#6366f1', icon: 'MapPin' })
|
||||
const [form, setForm] = useState({ name: '', color: DEFAULT_COLOR, icon: 'MapPin' })
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const colorInputRef = useRef(null)
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
const labelId = useId()
|
||||
|
||||
useEffect(() => { void loadCategories() }, [])
|
||||
|
||||
@@ -41,13 +48,13 @@ export default function CategoryManager() {
|
||||
|
||||
const handleStartEdit = (cat) => {
|
||||
setEditingId(cat.id)
|
||||
setForm({ name: cat.name, color: cat.color || '#6366f1', icon: cat.icon || 'MapPin' })
|
||||
setForm({ name: cat.name, color: cat.color || DEFAULT_COLOR, icon: cat.icon || 'MapPin' })
|
||||
setShowForm(false)
|
||||
}
|
||||
|
||||
const handleStartCreate = () => {
|
||||
setEditingId(null)
|
||||
setForm({ name: '', color: '#6366f1', icon: 'MapPin' })
|
||||
setForm({ name: '', color: DEFAULT_COLOR, icon: 'MapPin' })
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
@@ -71,7 +78,7 @@ export default function CategoryManager() {
|
||||
setShowForm(false)
|
||||
toast.success(t('categories.toast.created'))
|
||||
}
|
||||
setForm({ name: '', color: '#6366f1', icon: 'MapPin' })
|
||||
setForm({ name: '', color: DEFAULT_COLOR, icon: 'MapPin' })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('categories.toast.saveError')))
|
||||
} finally {
|
||||
@@ -92,22 +99,44 @@ export default function CategoryManager() {
|
||||
|
||||
const isPresetColor = PRESET_COLORS.includes(form.color)
|
||||
const PreviewIcon = getCategoryIcon(form.icon)
|
||||
const formOpen = showForm || editingId !== null
|
||||
const saveDisabled = isSaving || !form.name.trim()
|
||||
|
||||
const categoryForm = (
|
||||
<div className="bg-gray-50 rounded-xl p-4 space-y-3 border border-gray-200">
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={e => setForm(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder={t('categories.namePlaceholder')}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 bg-white"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-2">{t('categories.icon')}</label>
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
<div className="flex flex-wrap gap-1.5 px-1.5 py-1.5">
|
||||
const categoryDialog = (
|
||||
<DialogShell
|
||||
onClose={handleCancel}
|
||||
labelledBy={labelId}
|
||||
width="detail"
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={<DialogTile><PreviewIcon size={20} strokeWidth={1.9} color={form.color} /></DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={handleCancel}
|
||||
eyebrow={editingId ? t('common.edit') : t('categories.new')}
|
||||
titleInput={{
|
||||
value: form.name,
|
||||
onChange: name => setForm(prev => ({ ...prev, name })),
|
||||
label: t('categories.namePlaceholder'),
|
||||
placeholder: t('categories.namePlaceholder'),
|
||||
autoFocus: true,
|
||||
onKeyDown: e => { if (e.key === 'Enter' && !saveDisabled) void handleSave() },
|
||||
}}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={handleCancel}>{t('common.cancel')}</DialogButton>
|
||||
<DialogButton variant="primary" onClick={handleSave} disabled={saveDisabled}>
|
||||
{isSaving ? t('common.saving') : editingId ? t('categories.update') : t('categories.create')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
>
|
||||
<DialogSection label={t('categories.icon')}>
|
||||
<div className="max-h-52 overflow-y-auto rounded-[14px] border border-edge-faint bg-surface-secondary p-2">
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(38px,1fr))] gap-1">
|
||||
{ICON_NAMES.map(name => {
|
||||
const Icon = CATEGORY_ICON_MAP[name]
|
||||
const isSelected = form.icon === name
|
||||
@@ -116,28 +145,29 @@ export default function CategoryManager() {
|
||||
key={name}
|
||||
type="button"
|
||||
title={ICON_LABELS[name] || name}
|
||||
aria-label={ICON_LABELS[name] || name}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => setForm(prev => ({ ...prev, icon: name }))}
|
||||
className={`w-9 h-9 flex items-center justify-center rounded-lg transition-all ${
|
||||
isSelected
|
||||
? 'ring-2 ring-offset-1 ring-slate-700'
|
||||
: 'hover:bg-gray-200'
|
||||
className={`grid h-[38px] place-items-center rounded-[10px] transition-colors ${
|
||||
isSelected ? 'bg-surface-card shadow-sm ring-2 ring-content' : 'text-content-secondary hover:bg-surface-card'
|
||||
}`}
|
||||
style={{ background: isSelected ? `${form.color}18` : undefined }}
|
||||
style={isSelected ? { background: `${form.color}18` } : undefined}
|
||||
>
|
||||
<Icon size={17} strokeWidth={1.8} color={isSelected ? form.color : '#374151'} />
|
||||
<Icon size={17} strokeWidth={1.8} color={isSelected ? form.color : 'currentColor'} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogSection>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1.5">{t('categories.color')}</label>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<DialogSection label={t('categories.color')}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{PRESET_COLORS.map(color => (
|
||||
<button key={color} type="button" onClick={() => setForm(prev => ({ ...prev, color }))}
|
||||
className={`w-7 h-7 rounded-full transition-transform hover:scale-110 ${form.color === color ? 'ring-2 ring-offset-2 ring-gray-400 scale-110' : ''}`}
|
||||
aria-label={color}
|
||||
aria-pressed={form.color === color}
|
||||
className={`h-7 w-7 rounded-full transition-transform hover:scale-110 ${form.color === color ? 'scale-110 ring-2 ring-content ring-offset-2 ring-offset-surface-card' : ''}`}
|
||||
style={{ backgroundColor: color }} />
|
||||
))}
|
||||
|
||||
@@ -148,109 +178,94 @@ export default function CategoryManager() {
|
||||
value={form.color}
|
||||
onChange={e => setForm(prev => ({ ...prev, color: e.target.value }))}
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
title={t('categories.customColor')}
|
||||
aria-label={t('categories.customColor')}
|
||||
onClick={() => colorInputRef.current?.click()}
|
||||
className={`w-7 h-7 rounded-full flex items-center justify-center border-2 transition-transform hover:scale-110 ${
|
||||
className={`grid h-7 w-7 place-items-center rounded-full border-2 transition-transform hover:scale-110 ${
|
||||
!isPresetColor
|
||||
? 'ring-2 ring-offset-2 ring-gray-400 scale-110 border-transparent'
|
||||
: 'border-dashed border-gray-300 hover:border-gray-400'
|
||||
? 'scale-110 border-transparent ring-2 ring-content ring-offset-2 ring-offset-surface-card'
|
||||
: 'border-dashed border-edge text-content-faint hover:border-content-faint hover:text-content-muted'
|
||||
}`}
|
||||
style={!isPresetColor ? { backgroundColor: form.color } : undefined}
|
||||
>
|
||||
{isPresetColor && <Pipette className="w-3 h-3 text-gray-400" />}
|
||||
{isPresetColor && <Pipette size={12} strokeWidth={2} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogSection>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">{t('categories.preview')}:</span>
|
||||
<span className="inline-flex items-center gap-1.5 text-sm px-2.5 py-1 rounded-full font-medium"
|
||||
style={{ backgroundColor: `${form.color}20`, color: form.color }}>
|
||||
<PreviewIcon size={14} strokeWidth={1.8} />
|
||||
{form.name || t('categories.defaultName')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={handleCancel}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 border border-gray-200 rounded-lg hover:bg-gray-50">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" onClick={handleSave} disabled={isSaving || !form.name.trim()}
|
||||
className="px-4 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium">
|
||||
{isSaving ? t('common.saving') : editingId ? t('categories.update') : t('categories.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogSection label={t('categories.preview')}>
|
||||
<div className="flex items-center rounded-[14px] border border-edge-faint bg-surface-secondary px-3.5 py-3">
|
||||
<span className="inline-flex min-w-0 max-w-full items-center gap-1.5 truncate rounded-full px-2.5 py-1 font-semibold"
|
||||
style={{ ...fs(12.5, 'body'), backgroundColor: `${form.color}20`, color: form.color }}>
|
||||
<PreviewIcon size={14} strokeWidth={1.8} className="flex-none" />
|
||||
{form.name || t('categories.defaultName')}
|
||||
</span>
|
||||
</div>
|
||||
</DialogSection>
|
||||
</DialogShell>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">{t('categories.title')}</h2>
|
||||
<p className="mt-1 text-caption text-content-faint">{t('categories.subtitle')}</p>
|
||||
</div>
|
||||
<button type="button" onClick={handleStartCreate}
|
||||
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
|
||||
<Plus className="w-4 h-4" />
|
||||
<SettingsCard
|
||||
icon={Tags}
|
||||
title={t('categories.title')}
|
||||
hint={t('categories.subtitle')}
|
||||
badge={!isLoading && categories.length > 0 ? <StatusPill>{categories.length}</StatusPill> : undefined}
|
||||
action={
|
||||
<button type="button" onClick={handleStartCreate} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
||||
<Plus size={14} strokeWidth={2.2} />
|
||||
<span className="hidden sm:inline">{t('categories.new')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && <div className="mb-4">{categoryForm}</div>}
|
||||
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8 text-gray-400">
|
||||
<div className="w-6 h-6 border-2 border-gray-300 border-t-slate-600 rounded-full animate-spin" />
|
||||
<div className="grid place-items-center py-8">
|
||||
<Loader2 size={20} className="animate-spin text-content-faint" />
|
||||
</div>
|
||||
) : categories.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
<p className="text-sm">{t('categories.empty')}</p>
|
||||
<div className="rounded-[12px] border border-dashed border-edge px-4 py-6 text-center">
|
||||
<SettingsHint>{t('categories.empty')}</SettingsHint>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<SettingRows>
|
||||
{categories.map(cat => {
|
||||
const Icon = getCategoryIcon(cat.icon)
|
||||
return (
|
||||
<div key={cat.id}>
|
||||
{editingId === cat.id ? (
|
||||
<div className="mb-2">{categoryForm}</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 p-3 border border-gray-100 rounded-xl hover:border-gray-200 group">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: `${cat.color}20` }}>
|
||||
<Icon size={18} strokeWidth={1.8} color={cat.color} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900 text-sm">{cat.name}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full"
|
||||
style={{ backgroundColor: `${cat.color}20`, color: cat.color }}>
|
||||
{cat.color}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button type="button" onClick={() => handleStartEdit(cat)}
|
||||
className="p-1.5 text-gray-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg">
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" onClick={() => handleDelete(cat.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div key={cat.id} className={`group flex items-center gap-3 p-3 ${editingId === cat.id ? 'bg-surface-secondary' : ''}`}>
|
||||
<span className="grid h-9 w-9 flex-none place-items-center rounded-[10px]"
|
||||
style={{ backgroundColor: `${cat.color}20` }}>
|
||||
<Icon size={17} strokeWidth={1.8} color={cat.color} />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className="min-w-0 truncate font-medium text-content" style={fs(13, 'body')}>{cat.name}</span>
|
||||
<span className="flex-none rounded-full px-2 py-[2px] font-geist font-semibold uppercase tabular-nums"
|
||||
style={{ ...fs(10.5), backgroundColor: `${cat.color}20`, color: cat.color }}>
|
||||
{cat.color}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-none items-center gap-1.5">
|
||||
<Tooltip label={t('common.edit')}>
|
||||
<button type="button" onClick={() => handleStartEdit(cat)} aria-label={t('common.edit')} className={SETTINGS_ICON_BUTTON}>
|
||||
<Pencil size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('common.delete')}>
|
||||
<button type="button" onClick={() => handleDelete(cat.id)} aria-label={t('common.delete')} className={`${SETTINGS_ICON_BUTTON} hover:!text-danger`}>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</SettingRows>
|
||||
)}
|
||||
</div>
|
||||
{formOpen && categoryDialog}
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,9 +43,9 @@ function withToast() {
|
||||
return render(<><ToastContainer /><DefaultUserSettingsTab /></>);
|
||||
}
|
||||
|
||||
/** The selected option button is the one drawn with the strong border token. */
|
||||
/** The selected option is the pressed button of its segmented group. */
|
||||
function isActive(button: HTMLElement): boolean {
|
||||
return (button.style.border || '').includes('var(--text-primary)');
|
||||
return button.getAttribute('aria-pressed') === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Map as MapIcon, Settings2 } from 'lucide-react'
|
||||
import { Loader2, Map as MapIcon, Settings2 } from 'lucide-react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import Section from '../Settings/Section'
|
||||
import { SettingRows, SettingsHint } from '../Settings/settingsKit'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { EditorField, INPUT, Segmented } from '../shared/dialogParts'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { MapView } from '../Map/MapView'
|
||||
import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants'
|
||||
@@ -66,51 +69,49 @@ function styleForProvider(provider: GlMapProvider, style?: string | null): strin
|
||||
return normalizeStyleForProvider(provider, style)
|
||||
}
|
||||
|
||||
function OptionRow({
|
||||
/**
|
||||
* One default as a row of the card's white box: its name (with the reset link
|
||||
* once it is set) on the left, the choice on the right. `stacked` puts a wide
|
||||
* choice and a hint under the name. The label sits straight in the row, so the
|
||||
* row is the nearest box around both the name and its choice.
|
||||
*/
|
||||
function ChoiceRow({
|
||||
label,
|
||||
hint,
|
||||
stacked = false,
|
||||
children,
|
||||
}: {
|
||||
label: React.ReactNode
|
||||
hint?: string
|
||||
stacked?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-content-secondary">
|
||||
<div className={stacked ? 'flex flex-col gap-2.5 px-3.5 py-3' : 'flex flex-wrap items-center gap-x-4 gap-y-2 px-3.5 py-3'}>
|
||||
<label className={`block font-medium text-content ${stacked ? '' : 'min-w-0 flex-1 basis-40'}`} style={fs(13, 'body')}>
|
||||
{label}
|
||||
</label>
|
||||
{hint && <p className="text-xs mb-2 text-content-faint">{hint}</p>}
|
||||
<div className="flex gap-3 flex-wrap">{children}</div>
|
||||
{hint && <p className="-mt-2 m-0 leading-snug text-content-faint" style={fs(11.5)}>{hint}</p>}
|
||||
<div className={stacked ? 'min-w-0' : 'flex min-w-0 flex-none items-center'}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OptionButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button type="button"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
border: active ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: active ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
/** A wide field box inside the white rows, for the map card's token and style. */
|
||||
const FIELD_ROW = 'px-3.5 py-3'
|
||||
|
||||
/** On/Off for a boolean default; neither is pressed while it is unset. */
|
||||
function onOffValue(value: boolean | undefined): string {
|
||||
if (value === true) return 'on'
|
||||
if (value === false) return 'off'
|
||||
return ''
|
||||
}
|
||||
|
||||
/** The stored colour mode as one of the three choices; a legacy boolean still picks light or dark. */
|
||||
function colorModeValue(value: string | boolean | undefined): string {
|
||||
if (value === true) return 'dark'
|
||||
if (value === false) return 'light'
|
||||
return value ?? ''
|
||||
}
|
||||
|
||||
export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
@@ -165,12 +166,14 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
|
||||
const isSet = (key: keyof Defaults) => defaults[key] !== undefined
|
||||
|
||||
// Inside the row's own <label>, after the name: the link only shows once the
|
||||
// default is set, and the name stays the label's text.
|
||||
const ResetButton = ({ field }: { field: keyof Defaults }) =>
|
||||
isSet(field) ? (
|
||||
<button type="button"
|
||||
onClick={() => reset(field)}
|
||||
className="text-xs ml-2 text-content-faint underline"
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer' }}
|
||||
className="ml-2 align-baseline font-geist font-medium normal-case tracking-normal text-content-muted underline decoration-edge underline-offset-2 hover:text-content"
|
||||
style={fs(11)}
|
||||
>
|
||||
{t('admin.defaultSettings.resetToBuiltIn')}
|
||||
</button>
|
||||
@@ -202,7 +205,12 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
}], [])
|
||||
|
||||
if (!loaded) {
|
||||
return <p className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontStyle: 'italic', padding: 16 }}>Loading…</p>
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 rounded-2xl border border-edge-faint bg-surface-secondary px-4 py-5 text-content-faint" style={fs(12.5, 'body')}>
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
Loading…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const darkMode = defaults.dark_mode
|
||||
@@ -223,9 +231,9 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm text-content-faint mb-4">
|
||||
<SettingsHint className="mb-4">
|
||||
{t('admin.defaultSettings.description')}
|
||||
</p>
|
||||
</SettingsHint>
|
||||
|
||||
{/* Two columns from xl up, the same idea as the Settings tab: as one flat list
|
||||
the map fields sat a screen below the units while the right half of the page
|
||||
@@ -237,148 +245,125 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
carries its own bottom margin and a row gap would double it once the grid
|
||||
collapses. Two cards can sit straight in the grid; a third would need the
|
||||
explicit per-column stacks the Settings tab uses, or it leaves a hole. */}
|
||||
<div className="grid grid-cols-1 gap-x-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)] xl:items-start">
|
||||
<div className="grid grid-cols-1 gap-x-5 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)] xl:items-start">
|
||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||
<SettingRows>
|
||||
{/* Color Mode */}
|
||||
<OptionRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
|
||||
{([
|
||||
{ value: 'light', label: t('settings.light') },
|
||||
{ value: 'dark', label: t('settings.dark') },
|
||||
{ value: 'auto', label: t('settings.auto') },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={darkMode === opt.value || (opt.value === 'light' && darkMode === false) || (opt.value === 'dark' && darkMode === true)}
|
||||
onClick={() => save({ dark_mode: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
<ChoiceRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
|
||||
<Segmented<string>
|
||||
label={t('settings.colorMode')}
|
||||
value={colorModeValue(darkMode)}
|
||||
onChange={value => save({ dark_mode: value })}
|
||||
options={[
|
||||
{ value: 'light', label: t('settings.light') },
|
||||
{ value: 'dark', label: t('settings.dark') },
|
||||
{ value: 'auto', label: t('settings.auto') },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Temperature */}
|
||||
<OptionRow label={<>{t('settings.temperature')} <ResetButton field="temperature_unit" /></>}>
|
||||
{([
|
||||
{ value: 'celsius', label: '°C Celsius' },
|
||||
{ value: 'fahrenheit', label: '°F Fahrenheit' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={defaults.temperature_unit === opt.value}
|
||||
onClick={() => save({ temperature_unit: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
<ChoiceRow label={<>{t('settings.temperature')} <ResetButton field="temperature_unit" /></>}>
|
||||
<Segmented<string>
|
||||
label={t('settings.temperature')}
|
||||
value={defaults.temperature_unit ?? ''}
|
||||
onChange={value => save({ temperature_unit: value })}
|
||||
options={[
|
||||
{ value: 'celsius', label: '°C Celsius' },
|
||||
{ value: 'fahrenheit', label: '°F Fahrenheit' },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Distance */}
|
||||
<OptionRow label={<>{t('settings.distance')} <ResetButton field="distance_unit" /></>}>
|
||||
{([
|
||||
{ value: 'metric', label: 'km Metric' },
|
||||
{ value: 'imperial', label: 'mi Imperial' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={defaults.distance_unit === opt.value}
|
||||
onClick={() => save({ distance_unit: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
<ChoiceRow label={<>{t('settings.distance')} <ResetButton field="distance_unit" /></>}>
|
||||
<Segmented<DistanceUnit | ''>
|
||||
label={t('settings.distance')}
|
||||
value={defaults.distance_unit ?? ''}
|
||||
onChange={value => { if (value) void save({ distance_unit: value }) }}
|
||||
options={[
|
||||
{ value: 'metric', label: 'km Metric' },
|
||||
{ value: 'imperial', label: 'mi Imperial' },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Time Format */}
|
||||
<OptionRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
|
||||
{([
|
||||
{ value: '24h', label: '24h (14:30)' },
|
||||
{ value: '12h', label: '12h (2:30 PM)' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={defaults.time_format === opt.value}
|
||||
onClick={() => save({ time_format: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
<ChoiceRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
|
||||
<Segmented<string>
|
||||
label={t('settings.timeFormat')}
|
||||
value={defaults.time_format ?? ''}
|
||||
onChange={value => save({ time_format: value })}
|
||||
options={[
|
||||
{ value: '24h', label: '24h (14:30)' },
|
||||
{ value: '12h', label: '12h (2:30 PM)' },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Week start: the first column of every date picker (#2029) */}
|
||||
<OptionRow label={<>{t('settings.weekStart')} <ResetButton field="week_start" /></>}>
|
||||
{weekStartOptions(locale).map(opt => (
|
||||
<OptionButton key={opt.value} active={defaults.week_start === opt.value} onClick={() => save({ week_start: opt.value })}>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
<ChoiceRow label={<>{t('settings.weekStart')} <ResetButton field="week_start" /></>}>
|
||||
<Segmented<WeekStart | ''>
|
||||
label={t('settings.weekStart')}
|
||||
value={defaults.week_start ?? ''}
|
||||
onChange={value => { if (value) void save({ week_start: value }) }}
|
||||
options={weekStartOptions(locale)}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Default Currency */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('settings.currency')} <ResetButton field="default_currency" />
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={defaults.default_currency || ''}
|
||||
onChange={(value: string) => { if (value) void save({ default_currency: value }) }}
|
||||
placeholder={t('settings.currency')}
|
||||
searchable
|
||||
options={currenciesWith(defaults.default_currency).map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
|
||||
size="sm"
|
||||
style={{ maxWidth: 240 }}
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.currencyHint')}</p>
|
||||
</div>
|
||||
<ChoiceRow label={<>{t('settings.currency')} <ResetButton field="default_currency" /></>}>
|
||||
<div className="w-[220px] max-w-full">
|
||||
<CustomSelect
|
||||
value={defaults.default_currency || ''}
|
||||
onChange={(value: string) => { if (value) void save({ default_currency: value }) }}
|
||||
placeholder={t('settings.currency')}
|
||||
searchable
|
||||
options={currenciesWith(defaults.default_currency).map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
|
||||
/>
|
||||
</div>
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Blur Booking Codes */}
|
||||
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
||||
{([
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={String(opt.value)}
|
||||
active={defaults.blur_booking_codes === opt.value}
|
||||
onClick={() => save({ blur_booking_codes: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
<ChoiceRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
||||
<Segmented<string>
|
||||
label={t('settings.blurBookingCodes')}
|
||||
value={onOffValue(defaults.blur_booking_codes)}
|
||||
onChange={value => save({ blur_booking_codes: value === 'on' })}
|
||||
options={[
|
||||
{ value: 'on', label: t('settings.on') || 'On' },
|
||||
{ value: 'off', label: t('settings.off') || 'Off' },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
</SettingRows>
|
||||
<SettingsHint className="-mt-2">{t('settings.currencyHint')}</SettingsHint>
|
||||
</Section>
|
||||
|
||||
<Section title={t('settings.map')} icon={MapIcon}>
|
||||
{/* Map Tile URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('settings.mapTemplate')}
|
||||
<ResetButton field="map_tile_url" />
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={mapTileUrl}
|
||||
onChange={(value: string) => { if (value) { setMapTileUrl(value); void save({ map_tile_url: value }) } }}
|
||||
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||
size="sm"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mapTileUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
|
||||
onBlur={() => save({ map_tile_url: mapTileUrl })}
|
||||
placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.mapDefaultHint')}</p>
|
||||
{/* The key comes with the instance on a managed install, injected when the
|
||||
settings are read. A field here would only let somebody save a worse one. */}
|
||||
{!managed && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('admin.defaultSettings.cartoKey')}
|
||||
<ResetButton field="carto_api_key" />
|
||||
</label>
|
||||
<EditorField label={<>{t('settings.mapTemplate')}<ResetButton field="map_tile_url" /></>} hint={t('settings.mapDefaultHint')}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<CustomSelect
|
||||
value={mapTileUrl}
|
||||
onChange={(value: string) => { if (value) { setMapTileUrl(value); void save({ map_tile_url: value }) } }}
|
||||
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mapTileUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
|
||||
onBlur={() => save({ map_tile_url: mapTileUrl })}
|
||||
placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
className={`${INPUT} font-geist`}
|
||||
/>
|
||||
</div>
|
||||
</EditorField>
|
||||
{/* The key comes with the instance on a managed install, injected when the
|
||||
settings are read. A field here would only let somebody save a worse one. */}
|
||||
{!managed && (
|
||||
<EditorField label={<>{t('admin.defaultSettings.cartoKey')}<ResetButton field="carto_api_key" /></>} hint={t('admin.defaultSettings.cartoKeyHint')}>
|
||||
<input
|
||||
type="text"
|
||||
value={cartoKey}
|
||||
@@ -386,72 +371,60 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
onBlur={() => save({ carto_api_key: cartoKey })}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
className={`${INPUT} font-geist`}
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.cartoKeyHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{!managed && (
|
||||
<div className="mt-3.5">
|
||||
<RoutingInstanceFields defaults={defaults} onSave={save} onReset={reset} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
places: mapPreviewPlaces,
|
||||
dayPlaces: [],
|
||||
route: null,
|
||||
routeSegments: null,
|
||||
selectedPlaceId: null,
|
||||
onMarkerClick: null,
|
||||
onMapClick: null,
|
||||
onMapContextMenu: null,
|
||||
center: [48.8566, 2.3522],
|
||||
zoom: 10,
|
||||
// Same as the user-facing map tab: the field holds what is being
|
||||
// edited, so the key goes back on before the preview resolves it.
|
||||
tileUrl: withTileApiKey(mapTileUrl, cartoKey),
|
||||
fitKey: null,
|
||||
dayOrderMap: [],
|
||||
leftWidth: 0,
|
||||
rightWidth: 0,
|
||||
hasInspector: false,
|
||||
})}
|
||||
</div>
|
||||
</EditorField>
|
||||
)}
|
||||
{!managed && <RoutingInstanceFields defaults={defaults} onSave={save} onReset={reset} />}
|
||||
<div className="relative h-[200px] w-full overflow-hidden rounded-[12px] border border-edge-faint">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
places: mapPreviewPlaces,
|
||||
dayPlaces: [],
|
||||
route: null,
|
||||
routeSegments: null,
|
||||
selectedPlaceId: null,
|
||||
onMarkerClick: null,
|
||||
onMapClick: null,
|
||||
onMapContextMenu: null,
|
||||
center: [48.8566, 2.3522],
|
||||
zoom: 10,
|
||||
// Same as the user-facing map tab: the field holds what is being
|
||||
// edited, so the key goes back on before the preview resolves it.
|
||||
tileUrl: withTileApiKey(mapTileUrl, cartoKey),
|
||||
fitKey: null,
|
||||
dayOrderMap: [],
|
||||
leftWidth: 0,
|
||||
rightWidth: 0,
|
||||
hasInspector: false,
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Map provider / instance-wide Mapbox ───────────────────────── */}
|
||||
<div style={{ borderTop: '1px solid var(--border-primary)', paddingTop: 20, marginTop: 4 }}>
|
||||
<OptionRow
|
||||
<SettingRows>
|
||||
<ChoiceRow
|
||||
stacked
|
||||
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" /></>}
|
||||
hint={t('admin.defaultSettings.mapProviderHint')}
|
||||
>
|
||||
{([
|
||||
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
|
||||
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
|
||||
{ value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={mapProvider === opt.value}
|
||||
onClick={() => saveMapProvider(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
<Segmented<MapProvider>
|
||||
fill
|
||||
label={t('admin.defaultSettings.mapProvider')}
|
||||
value={mapProvider}
|
||||
onChange={saveMapProvider}
|
||||
options={[
|
||||
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
|
||||
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
|
||||
{ value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
|
||||
{mapProvider !== 'leaflet' && (
|
||||
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
{/* The token comes with the instance on a managed install, injected when the
|
||||
settings are read. A field here would only let somebody save a worse one. */}
|
||||
{mapProvider === 'mapbox-gl' && !managed && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('admin.defaultSettings.mapboxToken')}
|
||||
<ResetButton field="mapbox_access_token" />
|
||||
</label>
|
||||
{/* The token comes with the instance on a managed install, injected when the
|
||||
settings are read. A field here would only let somebody save a worse one. */}
|
||||
{mapProvider === 'mapbox-gl' && !managed && (
|
||||
<div className={FIELD_ROW}>
|
||||
<EditorField label={<>{t('admin.defaultSettings.mapboxToken')}<ResetButton field="mapbox_access_token" /></>} hint={t('admin.defaultSettings.mapboxTokenHint')}>
|
||||
<input
|
||||
type="text"
|
||||
value={mapboxToken}
|
||||
@@ -460,67 +433,67 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
placeholder="pk.eyJ…"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
className={`${INPUT} font-geist`}
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.mapboxTokenHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('admin.defaultSettings.mapboxStyle')}
|
||||
<ResetButton field={styleKey} />
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={mapboxStyle}
|
||||
onChange={(value: string) => { if (value) { setMapboxStyle(value); void save({ [styleKey]: value }) } }}
|
||||
placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')}
|
||||
options={glStylePresets.map(p => ({ value: p.url, label: p.name }))}
|
||||
size="sm"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mapboxStyle}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
|
||||
onBlur={() => {
|
||||
const nextStyle = normalizeStyleForProvider(mapProvider, mapboxStyle)
|
||||
setMapboxStyle(nextStyle)
|
||||
void save({ [styleKey]: nextStyle })
|
||||
}}
|
||||
placeholder={defaultStyleForProvider(mapProvider)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mapProvider === 'mapbox-gl' && (
|
||||
<>
|
||||
<OptionRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
|
||||
{([
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton key={String(opt.value)} active={(defaults.mapbox_3d_enabled ?? true) === opt.value} onClick={() => save({ mapbox_3d_enabled: opt.value })}>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
<OptionRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}>
|
||||
{([
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton key={String(opt.value)} active={(defaults.mapbox_quality_mode ?? false) === opt.value} onClick={() => save({ mapbox_quality_mode: opt.value })}>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
</>
|
||||
)}
|
||||
</EditorField>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mapProvider !== 'leaflet' && (
|
||||
<div className={FIELD_ROW}>
|
||||
<EditorField label={<>{t('admin.defaultSettings.mapboxStyle')}<ResetButton field={styleKey} /></>}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<CustomSelect
|
||||
value={mapboxStyle}
|
||||
onChange={(value: string) => { if (value) { setMapboxStyle(value); void save({ [styleKey]: value }) } }}
|
||||
placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')}
|
||||
options={glStylePresets.map(p => ({ value: p.url, label: p.name }))}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mapboxStyle}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
|
||||
onBlur={() => {
|
||||
const nextStyle = normalizeStyleForProvider(mapProvider, mapboxStyle)
|
||||
setMapboxStyle(nextStyle)
|
||||
void save({ [styleKey]: nextStyle })
|
||||
}}
|
||||
placeholder={defaultStyleForProvider(mapProvider)}
|
||||
className={`${INPUT} font-geist`}
|
||||
/>
|
||||
</div>
|
||||
</EditorField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mapProvider === 'mapbox-gl' && (
|
||||
<>
|
||||
<ChoiceRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
|
||||
<Segmented<string>
|
||||
label={t('admin.defaultSettings.mapbox3d')}
|
||||
value={onOffValue(defaults.mapbox_3d_enabled ?? true)}
|
||||
onChange={value => save({ mapbox_3d_enabled: value === 'on' })}
|
||||
options={[
|
||||
{ value: 'on', label: t('settings.on') || 'On' },
|
||||
{ value: 'off', label: t('settings.off') || 'Off' },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
|
||||
<ChoiceRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}>
|
||||
<Segmented<string>
|
||||
label={t('admin.defaultSettings.mapboxQuality')}
|
||||
value={onOffValue(defaults.mapbox_quality_mode ?? false)}
|
||||
onChange={value => save({ mapbox_quality_mode: value === 'on' })}
|
||||
options={[
|
||||
{ value: 'on', label: t('settings.on') || 'On' },
|
||||
{ value: 'off', label: t('settings.off') || 'Off' },
|
||||
]}
|
||||
/>
|
||||
</ChoiceRow>
|
||||
</>
|
||||
)}
|
||||
</SettingRows>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -297,14 +297,13 @@ describe('DevNotificationsPanel', () => {
|
||||
expect(screen.getByText('Admin-Scoped Events')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-016: hovering a trigger paints and restores its background', async () => {
|
||||
it('FE-ADMIN-DEVNOTIF-016: a trigger takes its hover background from CSS, not inline styles', async () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
|
||||
const btn = screen.getByText('Simple → Me').closest('button')!;
|
||||
fireEvent.mouseEnter(btn);
|
||||
expect(btn.style.background).toBe('var(--bg-hover)');
|
||||
fireEvent.mouseLeave(btn);
|
||||
expect(btn.style.background).toBe('var(--bg-card)');
|
||||
expect(btn.style.background).toBe('');
|
||||
expect(btn.className).toContain('hover:bg-surface-secondary');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useId } from 'react'
|
||||
import { adminApi, tripsApi } from '../../api/client'
|
||||
import { getApiErrorMessage } from '../../utils/apiError'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { EditorField, INPUT } from '../shared/dialogParts'
|
||||
import { SettingsCard, StatusPill } from '../Settings/settingsKit'
|
||||
import {
|
||||
Bell, Zap, ArrowRight, CheckCircle, Navigation, User,
|
||||
Calendar, Clock, Image, MessageSquare, Tag, UserPlus,
|
||||
Download, MapPin,
|
||||
Bell, Zap, CheckCircle, Navigation, User, Calendar, Clock, Image, MessageSquare, Tag, UserPlus,
|
||||
Download, MapPin, ChevronDown, FlaskConical, Loader2, Map as MapIcon, ShieldCheck,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface Trip {
|
||||
@@ -20,8 +22,26 @@ interface AppUser {
|
||||
email: string
|
||||
}
|
||||
|
||||
/** A native select in the editors' box look, with the chevron the CustomSelect draws. */
|
||||
const SELECT = `${INPUT} appearance-none pr-9`
|
||||
|
||||
function SelectBox({ id, children, ...rest }: React.SelectHTMLAttributes<HTMLSelectElement>): React.ReactElement {
|
||||
return (
|
||||
<div className="relative">
|
||||
<select id={id} {...rest} className={SELECT}>{children}</select>
|
||||
<ChevronDown size={14} strokeWidth={2.2} className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-content-faint" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The buttons of one group, two to a row from a small window up. */
|
||||
function ButtonGrid({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
return <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">{children}</div>
|
||||
}
|
||||
|
||||
export default function DevNotificationsPanel(): React.ReactElement {
|
||||
const toast = useToast()
|
||||
const ids = useId()
|
||||
const user = useAuthStore(s => s.user)
|
||||
const [sending, setSending] = useState<string | null>(null)
|
||||
const [trips, setTrips] = useState<Trip[]>([])
|
||||
@@ -62,74 +82,38 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
const Btn = ({
|
||||
id, label, sub, icon: Icon, color, onClick,
|
||||
id, label, sub, icon: Icon, onClick,
|
||||
}: {
|
||||
id: string; label: string; sub: string; icon: React.ElementType; color: string; onClick: () => void
|
||||
id: string; label: string; sub: string; icon: React.ElementType; onClick: () => void
|
||||
}) => (
|
||||
<button type="button"
|
||||
onClick={onClick}
|
||||
disabled={sending !== null}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left w-full border-edge bg-surface-card"
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)' }}
|
||||
className="flex w-full min-w-0 items-center gap-3 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5 text-left transition-colors hover:bg-surface-secondary disabled:cursor-default"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: `${color}20`, color }}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-content">{label}</p>
|
||||
<p className="text-xs truncate text-content-faint">{sub}</p>
|
||||
</div>
|
||||
{sending === id && (
|
||||
<div className="w-4 h-4 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin flex-shrink-0" />
|
||||
)}
|
||||
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary">
|
||||
<Icon size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium text-content" style={fs(13, 'body')}>{label}</span>
|
||||
<span className="block truncate font-geist text-content-faint" style={fs(11)}>{sub}</span>
|
||||
</span>
|
||||
{sending === id && <Loader2 size={15} className="flex-none animate-spin text-content-faint" />}
|
||||
</button>
|
||||
)
|
||||
|
||||
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
|
||||
<h3 className="text-sm font-semibold mb-3 text-content-secondary">{children}</h3>
|
||||
)
|
||||
|
||||
const TripSelector = () => (
|
||||
<select
|
||||
value={selectedTripId ?? ''}
|
||||
onChange={e => setSelectedTripId(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 rounded-lg border text-sm mb-3 border-edge bg-surface-card text-content"
|
||||
>
|
||||
{trips.map(trip => <option key={trip.id} value={trip.id}>{trip.title}</option>)}
|
||||
</select>
|
||||
)
|
||||
|
||||
const UserSelector = () => (
|
||||
<select
|
||||
value={selectedUserId ?? ''}
|
||||
onChange={e => setSelectedUserId(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 rounded-lg border text-sm mb-3 border-edge bg-surface-card text-content"
|
||||
>
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.username} ({u.email})</option>)}
|
||||
</select>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="px-2 py-0.5 rounded text-xs font-mono font-bold bg-[#fbbf24] text-[#000]">
|
||||
DEV ONLY
|
||||
</div>
|
||||
<span className="text-sm font-medium text-content">
|
||||
Notification Testing
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{/* ── Type Testing ─────────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<SectionTitle>Type Testing</SectionTitle>
|
||||
<p className="text-xs mb-3 text-content-muted">
|
||||
Test how each in-app notification type renders, sent to yourself.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Btn id="simple-me" label="Simple → Me" sub="test_simple · user" icon={Bell} color="#6366f1"
|
||||
{/* The whole tab is a dev build's tool; the first card, always there, says so in its band. */}
|
||||
<SettingsCard
|
||||
icon={FlaskConical}
|
||||
title="Type Testing"
|
||||
hint="Test how each in-app notification type renders, sent to yourself."
|
||||
badge={<StatusPill tone="warning">DEV ONLY</StatusPill>}
|
||||
>
|
||||
<ButtonGrid>
|
||||
<Btn id="simple-me" label="Simple → Me" sub="test_simple · user" icon={Bell}
|
||||
onClick={() => fire('simple-me', {
|
||||
event: 'test_simple',
|
||||
scope: 'user',
|
||||
@@ -137,7 +121,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
params: {},
|
||||
})}
|
||||
/>
|
||||
<Btn id="boolean-me" label="Boolean → Me" sub="test_boolean · user" icon={CheckCircle} color="#10b981"
|
||||
<Btn id="boolean-me" label="Boolean → Me" sub="test_boolean · user" icon={CheckCircle}
|
||||
onClick={() => fire('boolean-me', {
|
||||
event: 'test_boolean',
|
||||
scope: 'user',
|
||||
@@ -150,7 +134,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<Btn id="navigate-me" label="Navigate → Me" sub="test_navigate · user" icon={Navigation} color="#f59e0b"
|
||||
<Btn id="navigate-me" label="Navigate → Me" sub="test_navigate · user" icon={Navigation}
|
||||
onClick={() => fire('navigate-me', {
|
||||
event: 'test_navigate',
|
||||
scope: 'user',
|
||||
@@ -158,7 +142,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
params: {},
|
||||
})}
|
||||
/>
|
||||
<Btn id="simple-admins" label="Simple → All Admins" sub="test_simple · admin" icon={Zap} color="#ef4444"
|
||||
<Btn id="simple-admins" label="Simple → All Admins" sub="test_simple · admin" icon={Zap}
|
||||
onClick={() => fire('simple-admins', {
|
||||
event: 'test_simple',
|
||||
scope: 'admin',
|
||||
@@ -166,19 +150,23 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
params: {},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ButtonGrid>
|
||||
</SettingsCard>
|
||||
|
||||
{/* ── Trip-Scoped Events ───────────────────────────────────────────── */}
|
||||
{trips.length > 0 && (
|
||||
<div>
|
||||
<SectionTitle>Trip-Scoped Events</SectionTitle>
|
||||
<p className="text-xs mb-3 text-content-muted">
|
||||
Fires each trip event to all members of the selected trip (excluding yourself).
|
||||
</p>
|
||||
<TripSelector />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Btn id="booking_change" label="booking_change" sub="navigate · trip" icon={Calendar} color="#6366f1"
|
||||
<SettingsCard icon={MapIcon} title="Trip-Scoped Events" hint="Fires each trip event to all members of the selected trip (excluding yourself).">
|
||||
<EditorField label="Trip" htmlFor={`${ids}-trip`}>
|
||||
<SelectBox
|
||||
id={`${ids}-trip`}
|
||||
value={selectedTripId ?? ''}
|
||||
onChange={e => setSelectedTripId(Number(e.target.value))}
|
||||
>
|
||||
{trips.map(trip => <option key={trip.id} value={trip.id}>{trip.title}</option>)}
|
||||
</SelectBox>
|
||||
</EditorField>
|
||||
<ButtonGrid>
|
||||
<Btn id="booking_change" label="booking_change" sub="navigate · trip" icon={Calendar}
|
||||
onClick={() => selectedTripId && fire('booking_change', {
|
||||
event: 'booking_change',
|
||||
scope: 'trip',
|
||||
@@ -186,7 +174,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
params: { actor: username, trip: tripTitle, booking: 'Test Hotel', type: 'hotel', tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
<Btn id="trip_reminder" label="trip_reminder" sub="navigate · trip" icon={Clock} color="#10b981"
|
||||
<Btn id="trip_reminder" label="trip_reminder" sub="navigate · trip" icon={Clock}
|
||||
onClick={() => selectedTripId && fire('trip_reminder', {
|
||||
event: 'trip_reminder',
|
||||
scope: 'trip',
|
||||
@@ -194,7 +182,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
params: { trip: tripTitle, tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
<Btn id="photos_shared" label="photos_shared" sub="navigate · trip" icon={Image} color="#f59e0b"
|
||||
<Btn id="photos_shared" label="photos_shared" sub="navigate · trip" icon={Image}
|
||||
onClick={() => selectedTripId && fire('photos_shared', {
|
||||
event: 'photos_shared',
|
||||
scope: 'trip',
|
||||
@@ -202,7 +190,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
params: { actor: username, trip: tripTitle, count: '5', tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
<Btn id="collab_message" label="collab_message" sub="navigate · trip" icon={MessageSquare} color="#8b5cf6"
|
||||
<Btn id="collab_message" label="collab_message" sub="navigate · trip" icon={MessageSquare}
|
||||
onClick={() => selectedTripId && fire('collab_message', {
|
||||
event: 'collab_message',
|
||||
scope: 'trip',
|
||||
@@ -210,7 +198,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
params: { actor: username, trip: tripTitle, preview: 'This is a test message preview.', tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
<Btn id="packing_tagged" label="packing_tagged" sub="navigate · trip" icon={Tag} color="#ec4899"
|
||||
<Btn id="packing_tagged" label="packing_tagged" sub="navigate · trip" icon={Tag}
|
||||
onClick={() => selectedTripId && fire('packing_tagged', {
|
||||
event: 'packing_tagged',
|
||||
scope: 'trip',
|
||||
@@ -218,25 +206,28 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
params: { actor: username, trip: tripTitle, category: 'Clothing', tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ButtonGrid>
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
{/* ── User-Scoped Events ───────────────────────────────────────────── */}
|
||||
{users.length > 0 && (
|
||||
<div>
|
||||
<SectionTitle>User-Scoped Events</SectionTitle>
|
||||
<p className="text-xs mb-3 text-content-muted">
|
||||
Fires each user event to the selected recipient.
|
||||
</p>
|
||||
<UserSelector />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<SettingsCard icon={User} title="User-Scoped Events" hint="Fires each user event to the selected recipient.">
|
||||
<EditorField label="Recipient" htmlFor={`${ids}-user`}>
|
||||
<SelectBox
|
||||
id={`${ids}-user`}
|
||||
value={selectedUserId ?? ''}
|
||||
onChange={e => setSelectedUserId(Number(e.target.value))}
|
||||
>
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.username} ({u.email})</option>)}
|
||||
</SelectBox>
|
||||
</EditorField>
|
||||
<ButtonGrid>
|
||||
<Btn
|
||||
id={`trip_invite-${selectedUserId}`}
|
||||
label="trip_invite"
|
||||
sub="navigate · user"
|
||||
icon={UserPlus}
|
||||
color="#06b6d4"
|
||||
onClick={() => selectedUserId && fire(`trip_invite-${selectedUserId}`, {
|
||||
event: 'trip_invite',
|
||||
scope: 'user',
|
||||
@@ -249,7 +240,6 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
label="vacay_invite"
|
||||
sub="navigate · user"
|
||||
icon={MapPin}
|
||||
color="#f97316"
|
||||
onClick={() => selectedUserId && fire(`vacay_invite-${selectedUserId}`, {
|
||||
event: 'vacay_invite',
|
||||
scope: 'user',
|
||||
@@ -257,18 +247,14 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
params: { actor: username, planId: '1' },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ButtonGrid>
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
{/* ── Admin-Scoped Events ──────────────────────────────────────────── */}
|
||||
<div>
|
||||
<SectionTitle>Admin-Scoped Events</SectionTitle>
|
||||
<p className="text-xs mb-3 text-content-muted">
|
||||
Fires to all admin users.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Btn id="version_available" label="version_available" sub="navigate · admin" icon={Download} color="#64748b"
|
||||
<SettingsCard icon={ShieldCheck} title="Admin-Scoped Events" hint="Fires to all admin users.">
|
||||
<ButtonGrid>
|
||||
<Btn id="version_available" label="version_available" sub="navigate · admin" icon={Download}
|
||||
onClick={() => fire('version_available', {
|
||||
event: 'version_available',
|
||||
scope: 'admin',
|
||||
@@ -276,8 +262,8 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
params: { version: '9.9.9-test' },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ButtonGrid>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ describe('GitHubPanel', () => {
|
||||
expect(listItem.closest('li')).toBeInTheDocument();
|
||||
|
||||
// Bold text rendered as <strong>
|
||||
const container = document.querySelector('.mt-2.p-3.rounded-lg')!;
|
||||
const container = listItem.closest('ul')!.parentElement!;
|
||||
expect(container.querySelector('strong')).toBeInTheDocument();
|
||||
expect(container.querySelector('strong')!.textContent).toBe('bold text');
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
BookOpen,
|
||||
Bug,
|
||||
Calendar,
|
||||
@@ -7,13 +8,16 @@ import {
|
||||
Coffee,
|
||||
ExternalLink,
|
||||
Heart,
|
||||
History,
|
||||
Lightbulb,
|
||||
Loader2,
|
||||
Tag,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, type ComponentType, type CSSProperties, type ReactNode } from 'react';
|
||||
import apiClient from '../../api/client';
|
||||
import { getLocaleForLanguage, useTranslation } from '../../i18n';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { SETTINGS_BUTTON, SettingsCard, StatusPill } from '../Settings/settingsKit';
|
||||
|
||||
const REPO = 'liketrek/TREK';
|
||||
const PER_PAGE = 10;
|
||||
@@ -30,6 +34,50 @@ interface GithubRelease {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** The Discord mark, sized and coloured like the lucide glyphs beside it. */
|
||||
function DiscordIcon({ size = 18 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface SupportLink {
|
||||
href: string;
|
||||
/** The brand's own colour: the icon and the hover edge take it, nothing else. */
|
||||
brand: string;
|
||||
icon: ComponentType<{ size?: number }>;
|
||||
title: ReactNode;
|
||||
hint: ReactNode;
|
||||
}
|
||||
|
||||
/** One link to the project's other places: a card like the bookings', its brand only on the icon. */
|
||||
function SupportCard({ link }: { link: SupportLink }) {
|
||||
const Icon = link.icon;
|
||||
return (
|
||||
<a
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex min-w-0 items-center gap-3 rounded-[14px] border border-edge-faint bg-surface-card p-3 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:border-[color:var(--brand)] hover:shadow-sm"
|
||||
style={{ '--brand': link.brand } as CSSProperties}
|
||||
>
|
||||
<span
|
||||
className="grid h-10 w-10 flex-none place-items-center rounded-[12px] shadow-sm"
|
||||
style={{ background: 'color-mix(in srgb, var(--brand) 13%, var(--bg-card))', color: 'var(--brand)' }}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold text-content" style={fs(13.5, 'body')}>{link.title}</span>
|
||||
<span className="block truncate text-content-faint" style={fs(11.5)}>{link.hint}</span>
|
||||
</span>
|
||||
<ExternalLink size={14} className="flex-none text-content-faint transition-colors group-hover:text-content-muted" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) {
|
||||
const { t, language } = useTranslation();
|
||||
const [releases, setReleases] = useState<GithubRelease[]>([]);
|
||||
@@ -64,32 +112,29 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
setLoadingMore(false);
|
||||
};
|
||||
|
||||
const toggleExpand = (id) => {
|
||||
const toggleExpand = (id: number) => {
|
||||
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const formatDate = (dateStr: string) => {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
};
|
||||
|
||||
// Simple markdown-to-html for release notes (handles headers, bold, lists, links)
|
||||
const renderBody = (body) => {
|
||||
const renderBody = (body: string) => {
|
||||
if (!body) return null;
|
||||
const lines = body.split('\n');
|
||||
const elements = [];
|
||||
let listItems = [];
|
||||
const elements: ReactNode[] = [];
|
||||
let listItems: string[] = [];
|
||||
|
||||
const flushList = () => {
|
||||
if (listItems.length > 0) {
|
||||
elements.push(
|
||||
<ul key={`ul-${elements.length}`} className="my-2 space-y-1">
|
||||
<ul key={`ul-${elements.length}`} className="my-1.5 space-y-1 pl-0">
|
||||
{listItems.map((item, i) => (
|
||||
<li key={i} className="flex gap-2 text-xs text-content-muted">
|
||||
<span
|
||||
className="mt-1.5 h-1 w-1 flex-shrink-0 rounded-full"
|
||||
style={{ background: 'var(--text-faint)' }}
|
||||
/>
|
||||
<li key={i} className="flex gap-2 leading-relaxed text-content-secondary" style={fs(12.5, 'body')}>
|
||||
<span className="mt-[0.6em] h-1 w-1 flex-shrink-0 rounded-full bg-content-faint" />
|
||||
<span dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
|
||||
</li>
|
||||
))}
|
||||
@@ -99,18 +144,18 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
}
|
||||
};
|
||||
|
||||
const escapeHtml = (str) =>
|
||||
const escapeHtml = (str: string) =>
|
||||
str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const inlineFormat = (text) => {
|
||||
const inlineFormat = (text: string) => {
|
||||
return escapeHtml(text)
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(
|
||||
/`(.+?)`/g,
|
||||
'<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>'
|
||||
'<code style="font-size:0.9em;padding:1px 5px;border-radius:6px;background:var(--bg-tertiary)">$1</code>'
|
||||
)
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
|
||||
const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#';
|
||||
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">${label}</a>`;
|
||||
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:var(--text-primary);text-decoration:underline;text-underline-offset:2px">${label}</a>`;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -124,14 +169,14 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
if (trimmed.startsWith('### ')) {
|
||||
flushList();
|
||||
elements.push(
|
||||
<h4 key={elements.length} className="mb-1 mt-3 text-xs font-semibold text-content">
|
||||
<h4 key={elements.length} className="mb-1 mt-3 font-geist font-bold uppercase tracking-[.08em] text-content-faint first:mt-0" style={fs(10)}>
|
||||
{trimmed.slice(4)}
|
||||
</h4>
|
||||
);
|
||||
} else if (trimmed.startsWith('## ')) {
|
||||
flushList();
|
||||
elements.push(
|
||||
<h3 key={elements.length} className="mb-1 mt-3 text-sm font-semibold text-content">
|
||||
<h3 key={elements.length} className="mb-1 mt-3 font-semibold text-content first:mt-0" style={fs(13, 'body')}>
|
||||
{trimmed.slice(3)}
|
||||
</h3>
|
||||
);
|
||||
@@ -142,7 +187,8 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
elements.push(
|
||||
<p
|
||||
key={elements.length}
|
||||
className="my-1 text-xs text-content-muted"
|
||||
className="my-1 leading-relaxed text-content-secondary"
|
||||
style={fs(12.5, 'body')}
|
||||
dangerouslySetInnerHTML={{ __html: inlineFormat(trimmed) }}
|
||||
/>
|
||||
);
|
||||
@@ -152,351 +198,185 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
return elements;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Support cards */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<a
|
||||
href="https://ko-fi.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#ff5e5b';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-[#ff5e5b15]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Coffee size={20} className="text-[#ff5e5b]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-content">Ko-fi</div>
|
||||
<div className="text-xs text-content-faint">{t('admin.github.support')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://buymeacoffee.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#ffdd00';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-[#ffdd0015]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Heart size={20} className="text-[#ffdd00]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-content">Buy Me a Coffee</div>
|
||||
<div className="text-xs text-content-faint">{t('admin.github.support')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.gg/NhZBDSd4qW"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#5865F2';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-[#5865F215]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-content">Discord</div>
|
||||
<div className="text-xs text-content-faint">Join the community</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
</div>
|
||||
const supportLinks: SupportLink[] = [
|
||||
{
|
||||
href: 'https://ko-fi.com/mauriceboe',
|
||||
brand: '#ff5e5b', // theme-lint-disable: Ko-fi brand colour
|
||||
icon: Coffee,
|
||||
title: 'Ko-fi',
|
||||
hint: t('admin.github.support'),
|
||||
},
|
||||
{
|
||||
href: 'https://buymeacoffee.com/mauriceboe',
|
||||
brand: '#e0b400', // theme-lint-disable: Buy Me a Coffee brand yellow, deepened so it reads on white
|
||||
icon: Heart,
|
||||
title: 'Buy Me a Coffee',
|
||||
hint: t('admin.github.support'),
|
||||
},
|
||||
{
|
||||
href: 'https://discord.gg/NhZBDSd4qW',
|
||||
brand: '#5865F2', // theme-lint-disable: Discord brand colour
|
||||
icon: DiscordIcon,
|
||||
title: 'Discord',
|
||||
hint: 'Join the community',
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/liketrek/TREK/issues/new?template=bug_report.yml',
|
||||
brand: 'var(--danger)',
|
||||
icon: Bug,
|
||||
title: t('settings.about.reportBug'),
|
||||
hint: t('settings.about.reportBugHint'),
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/liketrek/TREK/discussions/new?category=feature-requests',
|
||||
brand: 'var(--warning)',
|
||||
icon: Lightbulb,
|
||||
title: t('settings.about.featureRequest'),
|
||||
hint: t('settings.about.featureRequestHint'),
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/liketrek/TREK/wiki',
|
||||
brand: 'var(--text-secondary)',
|
||||
icon: BookOpen,
|
||||
title: 'Wiki',
|
||||
hint: t('settings.about.wikiHint'),
|
||||
},
|
||||
];
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<a
|
||||
href="https://github.com/liketrek/TREK/issues/new?template=bug_report.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#ef4444';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-[#ef444415]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Bug size={20} className="text-[#ef4444]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-content">{t('settings.about.reportBug')}</div>
|
||||
<div className="text-xs text-content-faint">{t('settings.about.reportBugHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/liketrek/TREK/discussions/new?category=feature-requests"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#f59e0b';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-[#f59e0b15]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Lightbulb size={20} className="text-[#f59e0b]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-content">{t('settings.about.featureRequest')}</div>
|
||||
<div className="text-xs text-content-faint">{t('settings.about.featureRequestHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/liketrek/TREK/wiki"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#6366f1';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-[#6366f115]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<BookOpen size={20} className="text-[#6366f1]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-content">Wiki</div>
|
||||
<div className="text-xs text-content-faint">{t('settings.about.wikiHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
</div>
|
||||
const subtitle = t('admin.github.subtitle').replace('{repo}', REPO);
|
||||
|
||||
{/* Loading / Error / Releases */}
|
||||
{loading ? (
|
||||
<div className="overflow-hidden rounded-xl border border-edge bg-surface-card">
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-content-muted" />
|
||||
</div>
|
||||
let releasesCard: ReactNode;
|
||||
if (loading) {
|
||||
releasesCard = (
|
||||
<SettingsCard icon={History} title={t('admin.github.title')} hint={subtitle}>
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-content-faint" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="overflow-hidden rounded-xl border border-edge bg-surface-card">
|
||||
<div className="p-6 text-center">
|
||||
<p className="text-sm text-content-muted">{t('admin.github.error')}</p>
|
||||
<p className="mt-1 text-xs text-content-faint">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-edge bg-surface-card">
|
||||
<div className="flex items-center justify-between border-b border-edge-secondary px-5 py-4">
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">{t('admin.github.title')}</h2>
|
||||
<p className="mt-0.5 text-xs text-content-faint">{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
|
||||
</div>
|
||||
<a
|
||||
href={`https://github.com/${REPO}/releases`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-lg bg-surface-secondary px-3 py-1.5 text-xs font-medium text-content-muted transition-colors"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
} else if (error) {
|
||||
releasesCard = <SettingsCard icon={AlertTriangle} tone="danger" title={t('admin.github.error')} hint={error} />;
|
||||
} else {
|
||||
const shown = isPrerelease ? releases : releases.filter((r) => !r.prerelease);
|
||||
releasesCard = (
|
||||
<SettingsCard
|
||||
icon={History}
|
||||
title={t('admin.github.title')}
|
||||
hint={subtitle}
|
||||
action={
|
||||
<a
|
||||
href={`https://github.com/${REPO}/releases`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`${SETTINGS_BUTTON} no-underline`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<ExternalLink size={13} strokeWidth={2.2} />
|
||||
GitHub
|
||||
</a>
|
||||
}
|
||||
>
|
||||
{/* Timeline */}
|
||||
<div className="relative">
|
||||
{/* Timeline line, from the first dot's middle down to the last one's */}
|
||||
{shown.length > 1 && <div className="absolute bottom-6 left-[15px] top-4 w-px bg-edge" aria-hidden="true" />}
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="px-5 py-4">
|
||||
<div className="relative">
|
||||
{/* Timeline line */}
|
||||
<div
|
||||
className="absolute bottom-3 left-[11px] top-3 w-px"
|
||||
style={{ background: 'var(--border-primary)' }}
|
||||
/>
|
||||
<div className="flex flex-col gap-5">
|
||||
{shown.map((release, idx) => {
|
||||
const isLatest = idx === 0;
|
||||
const isExpanded = expanded[release.id];
|
||||
|
||||
<div className="space-y-0">
|
||||
{(isPrerelease ? releases : releases.filter((r) => !r.prerelease)).map((release, idx) => {
|
||||
const isLatest = idx === 0;
|
||||
const isExpanded = expanded[release.id];
|
||||
return (
|
||||
<div key={release.id} className="relative flex gap-3.5">
|
||||
{/* Timeline dot */}
|
||||
<span
|
||||
className={`relative grid h-8 w-8 flex-none place-items-center rounded-[10px] ${
|
||||
isLatest ? 'bg-accent text-accent-text' : 'bg-surface-card text-content-faint shadow-sm ring-1 ring-edge-faint'
|
||||
}`}
|
||||
>
|
||||
<Tag size={13} strokeWidth={2.2} />
|
||||
</span>
|
||||
|
||||
return (
|
||||
<div key={release.id} className="relative pb-5 pl-8">
|
||||
{/* Timeline dot */}
|
||||
<div
|
||||
className="absolute left-0 top-1 flex h-[23px] w-[23px] items-center justify-center rounded-full border-2"
|
||||
style={{
|
||||
background: isLatest ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
borderColor: isLatest ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
<Tag size={10} style={{ color: isLatest ? 'var(--bg-card)' : 'var(--text-faint)' }} />
|
||||
</div>
|
||||
{/* Release content */}
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-geist font-bold tabular-nums text-content" style={fs(14, 'body')}>{release.tag_name}</span>
|
||||
{isLatest && <StatusPill tone="success">{t('admin.github.latest')}</StatusPill>}
|
||||
{release.prerelease && <StatusPill tone="warning">{t('admin.github.prerelease')}</StatusPill>}
|
||||
</div>
|
||||
|
||||
{/* Release content */}
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-content">{release.tag_name}</span>
|
||||
{isLatest && (
|
||||
<span className="rounded-full bg-[rgba(34,197,94,0.12)] px-2 py-0.5 text-[10px] font-semibold text-[#16a34a]">
|
||||
{t('admin.github.latest')}
|
||||
</span>
|
||||
)}
|
||||
{release.prerelease && (
|
||||
<span className="rounded-full bg-[rgba(245,158,11,0.12)] px-2 py-0.5 text-[10px] font-semibold text-[#d97706]">
|
||||
{t('admin.github.prerelease')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{release.name && release.name !== release.tag_name && (
|
||||
<p className="m-0 mt-0.5 truncate font-medium text-content-secondary" style={fs(12.5, 'body')}>{release.name}</p>
|
||||
)}
|
||||
|
||||
{release.name && release.name !== release.tag_name && (
|
||||
<p className="mt-0.5 text-xs font-medium text-content-muted">{release.name}</p>
|
||||
)}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-content-faint" style={fs(11.5)}>
|
||||
<span className="inline-flex items-center gap-1 font-geist tabular-nums text-content-faint">
|
||||
<Calendar size={11} />
|
||||
{formatDate(release.published_at || release.created_at)}
|
||||
</span>
|
||||
{release.author && (
|
||||
<span className="text-content-faint">
|
||||
{t('admin.github.by')} {release.author.login}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-center gap-3">
|
||||
<span className="flex items-center gap-1 text-[11px] text-content-faint">
|
||||
<Calendar size={10} />
|
||||
{formatDate(release.published_at || release.created_at)}
|
||||
</span>
|
||||
{release.author && (
|
||||
<span className="text-[11px] text-content-faint">
|
||||
{t('admin.github.by')} {release.author.login}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Expandable body */}
|
||||
{release.body && (
|
||||
<div className="mt-2">
|
||||
<button type="button"
|
||||
onClick={() => toggleExpand(release.id)}
|
||||
aria-expanded={!!isExpanded}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-surface-tertiary px-2.5 py-1 font-semibold text-content-muted transition-colors hover:text-content"
|
||||
style={fs(11.5, 'body')}
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={12} strokeWidth={2.4} /> : <ChevronDown size={12} strokeWidth={2.4} />}
|
||||
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
|
||||
</button>
|
||||
|
||||
{/* Expandable body */}
|
||||
{release.body && (
|
||||
<div className="mt-2">
|
||||
<button type="button"
|
||||
onClick={() => toggleExpand(release.id)}
|
||||
className="flex items-center gap-1 text-[11px] font-medium text-content-muted transition-colors"
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2 rounded-lg bg-surface-secondary p-3">{renderBody(release.body)}</div>
|
||||
)}
|
||||
{isExpanded && (
|
||||
<div className="mt-2.5 min-w-0 overflow-hidden break-words rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3">
|
||||
{renderBody(release.body)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Load more */}
|
||||
{hasMore && (
|
||||
<div className="pt-2 text-center">
|
||||
<button type="button"
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-surface-secondary px-4 py-2 text-xs font-medium text-content-muted transition-colors"
|
||||
>
|
||||
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
|
||||
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more */}
|
||||
{hasMore && (
|
||||
<div className="flex justify-center">
|
||||
<button type="button"
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{loadingMore ? <Loader2 size={13} className="animate-spin" /> : <ChevronDown size={13} strokeWidth={2.2} />}
|
||||
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* Support links: one card, the six places as tiles inside it */}
|
||||
<SettingsCard icon={Heart} title={t('settings.about')}>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{supportLinks.map((link) => <SupportCard key={link.href} link={link} />)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Loading / Error / Releases */}
|
||||
{releasesCard}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,57 +1,62 @@
|
||||
import { AlertTriangle, Check } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { NumericInput } from '../shared/NumericInput'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { INPUT } from '../shared/dialogParts'
|
||||
import { SETTINGS_BUTTON_PRIMARY, StatusPill } from '../Settings/settingsKit'
|
||||
import { useGoogleQuota } from './useGoogleQuota'
|
||||
|
||||
/**
|
||||
* The daily ceiling on Google calls (#1582), as one row of the Google options:
|
||||
* today's count as a badge beside the name, the number on the right, a save
|
||||
* button only while there is something to save.
|
||||
* today's count as a pill beside the name, the number on the right, a save
|
||||
* button only while there is something to save. Laid out like settingsKit's
|
||||
* SettingRow, but with the pill outside the <label> so the field keeps the
|
||||
* plain name as its accessible name.
|
||||
*/
|
||||
export default function GoogleDailyLimitRow() {
|
||||
const { t } = useTranslation()
|
||||
const { status, draft, setDraft, dirty, saving, save } = useGoogleQuota()
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-2 py-3">
|
||||
<div className="min-w-0 flex-1 basis-60">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label htmlFor="admin-google-daily-limit" className="text-sm font-medium text-content-secondary">{t('admin.googleQuota.title')}</label>
|
||||
{/* Today's count beside the name it belongs to: green while there is room, amber once the day is used up. */}
|
||||
{status && (
|
||||
status.exhausted ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-warning-soft px-2 py-[2px] text-caption font-semibold tabular-nums text-warning">
|
||||
<AlertTriangle size={11} strokeWidth={2.4} />
|
||||
{t('admin.googleQuota.reached', { used: status.used_today })}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-edge-faint bg-surface-secondary px-2 py-[2px] text-caption font-semibold tabular-nums text-content-secondary">
|
||||
<span aria-hidden className="h-1.5 w-1.5 rounded-full bg-success" />
|
||||
{status.daily_limit == null
|
||||
? t('admin.googleQuota.usedToday', { used: status.used_today })
|
||||
: t('admin.googleQuota.usedOfLimit', { used: status.used_today, limit: status.daily_limit })}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
<div className={`px-3.5 py-3 ${saving ? 'opacity-60' : ''}`}>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<div className="min-w-0 flex-1 basis-56">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label htmlFor="admin-google-daily-limit" className="font-medium text-content" style={fs(13, 'body')}>{t('admin.googleQuota.title')}</label>
|
||||
{/* Today's count beside the name it belongs to: a green dot while there is room, amber once the day is used up. */}
|
||||
{status && (
|
||||
status.exhausted ? (
|
||||
<StatusPill tone="warning" icon={<AlertTriangle size={11} strokeWidth={2.4} />}>
|
||||
{t('admin.googleQuota.reached', { used: status.used_today })}
|
||||
</StatusPill>
|
||||
) : (
|
||||
<StatusPill icon={<span aria-hidden className="mr-0.5 h-1.5 w-1.5 rounded-full bg-success" />}>
|
||||
{status.daily_limit == null
|
||||
? t('admin.googleQuota.usedToday', { used: status.used_today })
|
||||
: t('admin.googleQuota.usedOfLimit', { used: status.used_today, limit: status.daily_limit })}
|
||||
</StatusPill>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11.5)}>{t('admin.googleQuota.subtitle')}</p>
|
||||
</div>
|
||||
<p className="text-xs text-content-faint mt-0.5">{t('admin.googleQuota.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<NumericInput
|
||||
id="admin-google-daily-limit"
|
||||
mode="integer"
|
||||
value={draft}
|
||||
onValueChange={setDraft}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void save() } }}
|
||||
placeholder={t('admin.googleQuota.placeholder')}
|
||||
disabled={status === null || saving}
|
||||
className="w-32 px-3 py-1.5 border border-edge rounded-lg text-sm text-right tabular-nums bg-surface-input text-content focus:ring-2 focus:ring-accent focus:border-transparent disabled:opacity-60"
|
||||
/>
|
||||
<div className="flex flex-none items-center gap-2">
|
||||
<div className="w-32">
|
||||
<NumericInput
|
||||
id="admin-google-daily-limit"
|
||||
mode="integer"
|
||||
value={draft}
|
||||
onValueChange={setDraft}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void save() } }}
|
||||
placeholder={t('admin.googleQuota.placeholder')}
|
||||
disabled={status === null || saving}
|
||||
className={`${INPUT} text-right font-geist tabular-nums`}
|
||||
/>
|
||||
</div>
|
||||
{dirty && (
|
||||
<button type="button" onClick={() => void save()} disabled={saving}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-sm font-medium text-accent-text hover:opacity-90 disabled:opacity-60">
|
||||
<Check size={14} />
|
||||
className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
||||
<Check size={14} strokeWidth={2.2} />
|
||||
{t('common.save')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -24,9 +24,9 @@ function rowButtons(name: string, selector: string): HTMLElement[] {
|
||||
return within(row).getAllByRole('button');
|
||||
}
|
||||
|
||||
const templateButtons = (name: string) => rowButtons(name, '.px-5.py-3');
|
||||
const categoryButtons = (name: string) => rowButtons(name, '.bg-slate-50');
|
||||
const itemButtons = (name: string) => rowButtons(name, '.group');
|
||||
const templateButtons = (name: string) => rowButtons(name, '[data-row="template"]');
|
||||
const categoryButtons = (name: string) => rowButtons(name, '[data-row="category"]');
|
||||
const itemButtons = (name: string) => rowButtons(name, '[data-row="item"]');
|
||||
|
||||
/** Expands the single fixture template and waits for its content. */
|
||||
async function expandBeachTrip(user: ReturnType<typeof userEvent.setup>, firstChild: string) {
|
||||
@@ -171,23 +171,8 @@ describe('PackingTemplateManager', () => {
|
||||
await screen.findByText('Beach Trip');
|
||||
expect(screen.getByText('City Break')).toBeInTheDocument();
|
||||
|
||||
// Find all Trash2 (delete) buttons — there are 2 (one per template)
|
||||
const deleteButtons = screen.getAllByRole('button').filter(b =>
|
||||
b.className.includes('hover:bg-red-50') || b.querySelector('svg')
|
||||
);
|
||||
// Click the delete button for "Beach Trip" (first template row's trash button)
|
||||
// The buttons layout in each row: [chevron, edit, delete]
|
||||
// We find rows first
|
||||
const beachTripRow = screen.getByText('Beach Trip').closest('div');
|
||||
const trashBtn = beachTripRow!.parentElement!.querySelector('button.hover\\:bg-red-50') as HTMLElement | null;
|
||||
if (trashBtn) {
|
||||
await user.click(trashBtn);
|
||||
} else {
|
||||
// Fallback: find all red-hover buttons and click first
|
||||
const allBtns = screen.getAllByRole('button');
|
||||
const redBtns = allBtns.filter(b => b.className.includes('hover:bg-red-50'));
|
||||
await user.click(redBtns[0]);
|
||||
}
|
||||
// Template rows carry [chevron, name, edit, delete]
|
||||
await user.click(templateButtons('Beach Trip')[3]);
|
||||
await waitFor(() => expect(deleteCalled).toBe(true));
|
||||
await waitFor(() => expect(screen.queryByText('Beach Trip')).not.toBeInTheDocument());
|
||||
expect(screen.getByText('City Break')).toBeInTheDocument();
|
||||
@@ -209,18 +194,7 @@ describe('PackingTemplateManager', () => {
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
|
||||
// Find the Edit2 button on the template row
|
||||
const beachTripText = screen.getByText('Beach Trip');
|
||||
const row = beachTripText.closest('div')!.parentElement!;
|
||||
const editBtn = row.querySelector('button.hover\\:bg-slate-100') as HTMLElement | null;
|
||||
if (editBtn) {
|
||||
await user.click(editBtn);
|
||||
} else {
|
||||
// Fallback: find all slate-100-hover buttons
|
||||
const allBtns = screen.getAllByRole('button');
|
||||
const editBtns = allBtns.filter(b => b.className.includes('hover:bg-slate-100'));
|
||||
await user.click(editBtns[0]);
|
||||
}
|
||||
await user.click(templateButtons('Beach Trip')[2]);
|
||||
|
||||
const input = screen.getByDisplayValue('Beach Trip');
|
||||
await user.clear(input);
|
||||
@@ -301,13 +275,8 @@ describe('PackingTemplateManager', () => {
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('Clothing');
|
||||
|
||||
// Find the Edit2 button in the Clothing category header
|
||||
const clothingHeader = screen.getByText('Clothing').closest('div')!;
|
||||
const editBtns = Array.from(clothingHeader.querySelectorAll('button')).filter(
|
||||
b => b.className.includes('hover:text-slate-700')
|
||||
);
|
||||
// Second button (after Plus) is Edit2
|
||||
await user.click(editBtns[1]);
|
||||
// Category headers carry [add item, edit, delete]
|
||||
await user.click(categoryButtons('Clothing')[1]);
|
||||
|
||||
const catInput = screen.getByDisplayValue('Clothing');
|
||||
await user.clear(catInput);
|
||||
@@ -334,10 +303,7 @@ describe('PackingTemplateManager', () => {
|
||||
await screen.findByText('Clothing');
|
||||
expect(screen.getByText('T-shirt')).toBeInTheDocument();
|
||||
|
||||
// Find the Trash2 button in the Clothing category header
|
||||
const clothingHeader = screen.getByText('Clothing').closest('div')!;
|
||||
const trashBtn = clothingHeader.querySelector('button.hover\\:text-red-500') as HTMLElement;
|
||||
await user.click(trashBtn);
|
||||
await user.click(categoryButtons('Clothing')[2]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Clothing')).not.toBeInTheDocument();
|
||||
@@ -363,18 +329,8 @@ describe('PackingTemplateManager', () => {
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('T-shirt');
|
||||
|
||||
// Find the Edit2 button in the T-shirt item row (opacity-0 group-hover buttons)
|
||||
const itemRow = screen.getByText('T-shirt').closest('div')!;
|
||||
const editBtn = Array.from(itemRow.querySelectorAll('button')).find(
|
||||
b => b.className.includes('opacity-0')
|
||||
) as HTMLElement | undefined;
|
||||
if (editBtn) {
|
||||
await user.click(editBtn);
|
||||
} else {
|
||||
// Directly click the first button in the item row
|
||||
const btns = itemRow.querySelectorAll('button');
|
||||
await user.click(btns[0] as HTMLElement);
|
||||
}
|
||||
// Item rows carry [edit, delete]
|
||||
await user.click(itemButtons('T-shirt')[0]);
|
||||
|
||||
const input = screen.getByDisplayValue('T-shirt');
|
||||
await user.clear(input);
|
||||
@@ -401,14 +357,7 @@ describe('PackingTemplateManager', () => {
|
||||
await screen.findByText('T-shirt');
|
||||
expect(screen.getByText('Shorts')).toBeInTheDocument();
|
||||
|
||||
// Find the Trash2 button in the T-shirt row
|
||||
const itemRow = screen.getByText('T-shirt').closest('div')!;
|
||||
const trashBtns = Array.from(itemRow.querySelectorAll('button')).filter(
|
||||
b => b.className.includes('opacity-0')
|
||||
);
|
||||
// Second opacity-0 button is the delete (trash) button
|
||||
const trashBtn = trashBtns[1] || trashBtns[0];
|
||||
await user.click(trashBtn as HTMLElement);
|
||||
await user.click(itemButtons('T-shirt')[1]);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('T-shirt')).not.toBeInTheDocument());
|
||||
expect(screen.getByText('Shorts')).toBeInTheDocument();
|
||||
@@ -489,16 +438,7 @@ describe('PackingTemplateManager', () => {
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
|
||||
const beachTripText = screen.getByText('Beach Trip');
|
||||
const row = beachTripText.closest('div')!.parentElement!;
|
||||
const editBtn = row.querySelector('button.hover\\:bg-slate-100') as HTMLElement | null;
|
||||
if (editBtn) {
|
||||
await user.click(editBtn);
|
||||
} else {
|
||||
const allBtns = screen.getAllByRole('button');
|
||||
const editBtns = allBtns.filter(b => b.className.includes('hover:bg-slate-100'));
|
||||
await user.click(editBtns[0]);
|
||||
}
|
||||
await user.click(templateButtons('Beach Trip')[2]);
|
||||
|
||||
const input = screen.getByDisplayValue('Beach Trip');
|
||||
await user.type(input, '{Escape}');
|
||||
|
||||
@@ -2,7 +2,22 @@ import { useState, useEffect, useRef } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plus, Trash2, Edit2, Package, X, Check, ChevronDown, ChevronRight, FolderPlus } from 'lucide-react'
|
||||
import { Plus, Trash2, Pencil, Package, X, Check, ChevronRight, FolderPlus, Loader2 } from 'lucide-react'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { COMPOSER } from '../Packing/packingPopoverStyles'
|
||||
import { SettingRows, SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON } from '../Settings/settingsKit'
|
||||
|
||||
/** The planner's composer (#2541): a framed field on the card with its confirm and cancel beside it. */
|
||||
const COMPOSER_ROW = 'flex items-center gap-2 rounded-[10px] border border-edge bg-surface-card py-1 pl-3 pr-1'
|
||||
const COMPOSER_INPUT = 'min-w-0 flex-1 border-0 bg-transparent py-1 font-medium text-content outline-none placeholder:text-content-faint'
|
||||
const COMPOSER_CANCEL = 'grid h-7 w-7 flex-none place-items-center rounded-[8px] text-content-faint hover:bg-surface-secondary hover:text-content'
|
||||
/** A name being renamed in place, sized to the row it sits in. */
|
||||
const ROW_INPUT = 'block min-w-0 rounded-[8px] border border-edge bg-surface-input px-2 py-1 text-content outline-none focus:ring-2 focus:ring-[color:var(--text-primary)]'
|
||||
/** The quieter icon action of a category head or an item row, named by its tooltip. */
|
||||
const SMALL_ICON_BUTTON = 'grid h-7 w-7 flex-none place-items-center rounded-[8px] text-content-faint transition-colors hover:bg-surface-card hover:text-content'
|
||||
/** An item's actions show while the pointer or the focus is on its row. */
|
||||
const REVEAL = 'opacity-0 focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100'
|
||||
|
||||
interface TemplateCategory { id: number; template_id: number; name: string; sort_order: number }
|
||||
interface TemplateItem { id: number; category_id: number; name: string; sort_order: number; weight_grams?: number | null; quantity?: number | null; bag_name?: string | null }
|
||||
@@ -156,164 +171,212 @@ export default function PackingTemplateManager() {
|
||||
} catch { toast.error(t('admin.packingTemplates.deleteItemError')) }
|
||||
}
|
||||
|
||||
const inputStyle = 'w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent outline-none'
|
||||
const btnIcon = 'p-1.5 rounded-lg transition-colors'
|
||||
const composerConfirmClass = (enabled: boolean) =>
|
||||
`grid h-7 w-7 flex-none place-items-center rounded-[8px] bg-accent text-accent-text transition-opacity disabled:cursor-default ${enabled ? 'hover:opacity-90' : 'opacity-40'}`
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.packingTemplates.title')}</h2>
|
||||
<p className="mt-1 text-caption text-content-faint">{t('admin.packingTemplates.subtitle')}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 transition-colors">
|
||||
<Plus className="w-4 h-4" /> <span className="hidden sm:inline">{t('admin.packingTemplates.create')}</span>
|
||||
<SettingsCard
|
||||
icon={Package}
|
||||
title={t('admin.packingTemplates.title')}
|
||||
hint={t('admin.packingTemplates.subtitle')}
|
||||
badge={!isLoading && templates.length > 0 ? <StatusPill>{templates.length}</StatusPill> : undefined}
|
||||
action={
|
||||
<button type="button" onClick={() => setShowCreate(true)} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
||||
<Plus size={14} strokeWidth={2.2} /> <span className="hidden sm:inline">{t('admin.packingTemplates.create')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create template */}
|
||||
}
|
||||
>
|
||||
{/* Create template: the planner's composer, a framed field with its confirm beside it */}
|
||||
{showCreate && (
|
||||
<div className="px-5 py-3 border-b border-slate-100 flex items-center gap-3">
|
||||
<Package size={16} className="text-slate-400 flex-shrink-0" />
|
||||
<div className={COMPOSER_ROW} style={{ boxShadow: COMPOSER.boxShadow }}>
|
||||
<Package size={15} className="flex-none text-content-faint" />
|
||||
<input autoFocus value={createName} onChange={e => setCreateName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') void handleCreateTemplate(); if (e.key === 'Escape') setShowCreate(false) }}
|
||||
placeholder={t('admin.packingTemplates.namePlaceholder')} className={inputStyle} />
|
||||
<button type="button" onClick={handleCreateTemplate} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={16} /></button>
|
||||
<button type="button" onClick={() => setShowCreate(false)} className={`${btnIcon} text-slate-400 hover:text-slate-600`}><X size={16} /></button>
|
||||
placeholder={t('admin.packingTemplates.namePlaceholder')} className={COMPOSER_INPUT} style={fs(13, 'body')} />
|
||||
<Tooltip label={t('common.save')}>
|
||||
<button type="button" onClick={handleCreateTemplate} aria-label={t('common.save')} className={composerConfirmClass(!!createName.trim())}>
|
||||
<Check size={14} strokeWidth={2.5} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('common.cancel')}>
|
||||
<button type="button" onClick={() => setShowCreate(false)} aria-label={t('common.cancel')} className={COMPOSER_CANCEL}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Template list */}
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center"><div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" /></div>
|
||||
<div className="grid place-items-center py-8">
|
||||
<Loader2 size={20} className="animate-spin text-content-faint" />
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-slate-400">{t('admin.packingTemplates.empty')}</div>
|
||||
<div className="rounded-[12px] border border-dashed border-edge px-4 py-6 text-center">
|
||||
<SettingsHint>{t('admin.packingTemplates.empty')}</SettingsHint>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-100">
|
||||
{templates.map(tmpl => (
|
||||
<div key={tmpl.id}>
|
||||
{/* Template row */}
|
||||
<div className="px-5 py-3 flex items-center gap-3 hover:bg-slate-50 transition-colors">
|
||||
<button type="button" onClick={() => toggleExpand(tmpl.id)} className="text-slate-400 flex-shrink-0 p-0 bg-transparent border-none cursor-pointer">
|
||||
{expandedId === tmpl.id ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
|
||||
</button>
|
||||
<Package size={16} className="text-slate-400 flex-shrink-0" />
|
||||
{editingTemplate === tmpl.id ? (
|
||||
<input autoFocus value={editTemplateName} onChange={e => setEditTemplateName(e.target.value)}
|
||||
onBlur={() => handleRenameTemplate(tmpl.id)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') void handleRenameTemplate(tmpl.id); if (e.key === 'Escape') setEditingTemplate(null) }}
|
||||
className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm" />
|
||||
) : (
|
||||
<button type="button" onClick={() => toggleExpand(tmpl.id)} className="flex-1 text-left bg-transparent border-0 p-0 text-sm font-medium text-slate-700 cursor-pointer">{tmpl.name}</button>
|
||||
)}
|
||||
<span className="text-xs text-slate-400 px-2 py-0.5 bg-slate-100 rounded-full">
|
||||
{tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')}
|
||||
</span>
|
||||
<button type="button" onClick={() => { setEditingTemplate(tmpl.id); setEditTemplateName(tmpl.name) }}
|
||||
className={`${btnIcon} hover:bg-slate-100 text-slate-400 hover:text-slate-700`}><Edit2 size={14} /></button>
|
||||
<button type="button" onClick={() => handleDeleteTemplate(tmpl.id)}
|
||||
className={`${btnIcon} hover:bg-red-50 text-slate-400 hover:text-red-500`}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
<SettingRows>
|
||||
{templates.map(tmpl => {
|
||||
const expanded = expandedId === tmpl.id
|
||||
return (
|
||||
<div key={tmpl.id}>
|
||||
{/* Template row */}
|
||||
<div data-row="template" className={`flex items-center gap-2.5 px-3 py-2.5 transition-colors ${expanded ? 'bg-surface-secondary' : 'hover:bg-surface-secondary'}`}>
|
||||
<Tooltip label={expanded ? t('common.collapse') : t('common.expand')}>
|
||||
<button type="button" onClick={() => toggleExpand(tmpl.id)} aria-expanded={expanded}
|
||||
aria-label={expanded ? t('common.collapse') : t('common.expand')}
|
||||
className="grid h-7 w-7 flex-none place-items-center rounded-[8px] text-content-faint hover:bg-surface-tertiary hover:text-content">
|
||||
<ChevronRight size={15} strokeWidth={2.2} className={`transition-transform duration-150 ${expanded ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary">
|
||||
<Package size={15} strokeWidth={1.9} />
|
||||
</span>
|
||||
{editingTemplate === tmpl.id ? (
|
||||
<input autoFocus value={editTemplateName} onChange={e => setEditTemplateName(e.target.value)}
|
||||
onBlur={() => handleRenameTemplate(tmpl.id)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') void handleRenameTemplate(tmpl.id); if (e.key === 'Escape') setEditingTemplate(null) }}
|
||||
aria-label={t('common.rename')}
|
||||
className={`${ROW_INPUT} flex-1 font-semibold`} style={fs(13, 'body')} />
|
||||
) : (
|
||||
<button type="button" onClick={() => toggleExpand(tmpl.id)} className="min-w-0 flex-1 truncate text-left font-semibold text-content" style={fs(13, 'body')}>{tmpl.name}</button>
|
||||
)}
|
||||
<span className="max-sm:hidden">
|
||||
<StatusPill>{tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')}</StatusPill>
|
||||
</span>
|
||||
<Tooltip label={t('common.rename')}>
|
||||
<button type="button" onClick={() => { setEditingTemplate(tmpl.id); setEditTemplateName(tmpl.name) }}
|
||||
aria-label={t('common.rename')} className={SETTINGS_ICON_BUTTON}><Pencil size={14} strokeWidth={2} /></button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('common.delete')}>
|
||||
<button type="button" onClick={() => handleDeleteTemplate(tmpl.id)}
|
||||
aria-label={t('common.delete')} className={`${SETTINGS_ICON_BUTTON} hover:!text-danger`}><Trash2 size={14} strokeWidth={2} /></button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expandedId === tmpl.id && (
|
||||
<div className="px-5 pb-4 ml-8 space-y-3">
|
||||
{categories.map(cat => {
|
||||
const catItems = items.filter(i => i.category_id === cat.id)
|
||||
return (
|
||||
<div key={cat.id} className="border border-slate-200 rounded-lg overflow-hidden">
|
||||
{/* Category header */}
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-50">
|
||||
{editingCatId === cat.id ? (
|
||||
<>
|
||||
{/* Expanded content: the template's categories as the packing list draws them */}
|
||||
{expanded && (
|
||||
<div className="flex flex-col gap-2.5 border-t border-edge-faint bg-surface-secondary px-3 py-3 sm:pl-[52px]">
|
||||
{categories.map(cat => {
|
||||
const catItems = items.filter(i => i.category_id === cat.id)
|
||||
return (
|
||||
<div key={cat.id} className="overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
|
||||
{/* Category header */}
|
||||
<div data-row="category" className="flex items-center gap-1.5 border-b border-edge-faint bg-surface-tertiary py-1.5 pl-3 pr-1.5">
|
||||
{editingCatId === cat.id ? (
|
||||
<input autoFocus value={editCatName} onChange={e => setEditCatName(e.target.value)}
|
||||
onBlur={() => handleRenameCategory(cat.id)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') void handleRenameCategory(cat.id); if (e.key === 'Escape') setEditingCatId(null) }}
|
||||
className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm font-semibold" />
|
||||
</>
|
||||
) : (
|
||||
<span className="flex-1 text-xs font-bold text-slate-500 uppercase tracking-wider">{cat.name}</span>
|
||||
)}
|
||||
<span className="text-xs text-slate-400">{catItems.length}</span>
|
||||
<button type="button" onClick={() => { setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) }}
|
||||
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Plus size={13} /></button>
|
||||
<button type="button" onClick={() => { setEditingCatId(cat.id); setEditCatName(cat.name) }}
|
||||
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Edit2 size={13} /></button>
|
||||
<button type="button" onClick={() => handleDeleteCategory(cat.id)}
|
||||
className={`${btnIcon} text-slate-400 hover:text-red-500`}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
{(catItems.length > 0 || addingItemToCatId === cat.id) && (
|
||||
<div className="divide-y divide-slate-50">
|
||||
{catItems.map(item => (
|
||||
<div key={item.id} className="flex items-center gap-3 px-4 py-2 group">
|
||||
{editingItemId === item.id ? (
|
||||
<>
|
||||
<input autoFocus value={editItemName} onChange={e => setEditItemName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') void handleRenameItem(item.id); if (e.key === 'Escape') setEditingItemId(null) }}
|
||||
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
|
||||
<button type="button" onClick={() => handleRenameItem(item.id)} className="p-1 text-slate-600 hover:text-slate-900"><Check size={13} /></button>
|
||||
<button type="button" onClick={() => setEditingItemId(null)} className="p-1 text-slate-400"><X size={13} /></button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1 text-sm text-slate-700">{item.name}</span>
|
||||
{templateItemMeta(item) && (
|
||||
<span className="text-caption text-content-faint tabular-nums truncate max-w-[45%]">{templateItemMeta(item)}</span>
|
||||
)}
|
||||
<button type="button" onClick={() => { setEditingItemId(item.id); setEditItemName(item.name) }}
|
||||
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-slate-700 transition-all"><Edit2 size={12} /></button>
|
||||
<button type="button" onClick={() => handleDeleteItem(item.id)}
|
||||
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-red-500 transition-all"><Trash2 size={12} /></button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add item inline */}
|
||||
{addingItemToCatId === cat.id && (
|
||||
<div className="flex items-center gap-2 px-4 py-2">
|
||||
<input ref={addItemRef} value={newItemName} onChange={e => setNewItemName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && newItemName.trim()) void handleAddItem(cat.id); if (e.key === 'Escape') { setAddingItemToCatId(null); setNewItemName('') } }}
|
||||
placeholder={t('admin.packingTemplates.itemName')}
|
||||
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
|
||||
<button type="button" onClick={() => handleAddItem(cat.id)} disabled={!newItemName.trim()}
|
||||
className="p-1.5 rounded-lg bg-slate-900 text-white disabled:bg-slate-300 hover:bg-slate-700 transition-colors"><Plus size={13} /></button>
|
||||
<button type="button" onClick={() => { setAddingItemToCatId(null); setNewItemName('') }}
|
||||
className="p-1 text-slate-400 hover:text-slate-600"><X size={13} /></button>
|
||||
</div>
|
||||
aria-label={t('common.rename')}
|
||||
className={`${ROW_INPUT} flex-1 font-semibold`} style={fs(12.5, 'body')} />
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 truncate font-geist font-bold uppercase tracking-[.08em] text-content-muted" style={fs(10.5)}>{cat.name}</span>
|
||||
)}
|
||||
<span className="min-w-[20px] flex-none rounded-full bg-surface-card px-1.5 py-px text-center font-geist font-bold tabular-nums text-content-faint" style={fs(10)}>{catItems.length}</span>
|
||||
<Tooltip label={t('common.add')}>
|
||||
<button type="button" onClick={() => { setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) }}
|
||||
aria-label={t('common.add')} className={SMALL_ICON_BUTTON}><Plus size={13} strokeWidth={2.2} /></button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('common.rename')}>
|
||||
<button type="button" onClick={() => { setEditingCatId(cat.id); setEditCatName(cat.name) }}
|
||||
aria-label={t('common.rename')} className={SMALL_ICON_BUTTON}><Pencil size={12} strokeWidth={2} /></button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('common.delete')}>
|
||||
<button type="button" onClick={() => handleDeleteCategory(cat.id)}
|
||||
aria-label={t('common.delete')} className={`${SMALL_ICON_BUTTON} hover:!text-danger`}><Trash2 size={12} strokeWidth={2} /></button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Add category button */}
|
||||
{addingCategory ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input autoFocus value={newCatName} onChange={e => setNewCatName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') void handleAddCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }}
|
||||
placeholder={t('admin.packingTemplates.categoryName')}
|
||||
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm" />
|
||||
<button type="button" onClick={handleAddCategory} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={15} /></button>
|
||||
<button type="button" onClick={() => { setAddingCategory(false); setNewCatName('') }} className={`${btnIcon} text-slate-400`}><X size={15} /></button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={() => setAddingCategory(true)}
|
||||
className="flex items-center gap-2 px-3 py-2.5 w-full text-sm text-slate-400 hover:text-slate-600 border border-dashed border-slate-200 rounded-lg hover:border-slate-400 transition-colors">
|
||||
<FolderPlus size={14} /> {t('admin.packingTemplates.addCategory')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Items */}
|
||||
{(catItems.length > 0 || addingItemToCatId === cat.id) && (
|
||||
<div className="divide-y divide-edge-faint">
|
||||
{catItems.map(item => (
|
||||
<div key={item.id} data-row="item" className="group flex min-h-[40px] items-center gap-2 py-1.5 pl-3 pr-1.5">
|
||||
{editingItemId === item.id ? (
|
||||
<>
|
||||
<input autoFocus value={editItemName} onChange={e => setEditItemName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') void handleRenameItem(item.id); if (e.key === 'Escape') setEditingItemId(null) }}
|
||||
aria-label={t('common.rename')}
|
||||
className={`${ROW_INPUT} flex-1`} style={fs(12.5, 'body')} />
|
||||
<Tooltip label={t('common.save')}>
|
||||
<button type="button" onClick={() => handleRenameItem(item.id)} aria-label={t('common.save')} className={composerConfirmClass(true)}><Check size={13} strokeWidth={2.5} /></button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('common.cancel')}>
|
||||
<button type="button" onClick={() => setEditingItemId(null)} aria-label={t('common.cancel')} className={COMPOSER_CANCEL}><X size={13} /></button>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span aria-hidden className="h-[7px] w-[7px] flex-none rounded-full border-[1.5px] border-edge" />
|
||||
<span className="min-w-0 flex-1 truncate text-content" style={fs(12.5, 'body')}>{item.name}</span>
|
||||
{templateItemMeta(item) && (
|
||||
<span className="max-w-[45%] truncate font-geist tabular-nums text-content-faint" style={fs(11)}>{templateItemMeta(item)}</span>
|
||||
)}
|
||||
<Tooltip label={t('common.rename')}>
|
||||
<button type="button" onClick={() => { setEditingItemId(item.id); setEditItemName(item.name) }}
|
||||
aria-label={t('common.rename')} className={`${SMALL_ICON_BUTTON} ${REVEAL}`}><Pencil size={12} strokeWidth={2} /></button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('common.delete')}>
|
||||
<button type="button" onClick={() => handleDeleteItem(item.id)}
|
||||
aria-label={t('common.delete')} className={`${SMALL_ICON_BUTTON} ${REVEAL} hover:!text-danger`}><Trash2 size={12} strokeWidth={2} /></button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add item inline */}
|
||||
{addingItemToCatId === cat.id && (
|
||||
<div className="flex items-center gap-2 py-1.5 pl-3 pr-1.5">
|
||||
<input ref={addItemRef} value={newItemName} onChange={e => setNewItemName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && newItemName.trim()) void handleAddItem(cat.id); if (e.key === 'Escape') { setAddingItemToCatId(null); setNewItemName('') } }}
|
||||
placeholder={t('admin.packingTemplates.itemName')}
|
||||
className={`${ROW_INPUT} flex-1`} style={fs(12.5, 'body')} />
|
||||
<Tooltip label={t('common.add')}>
|
||||
<button type="button" onClick={() => handleAddItem(cat.id)} disabled={!newItemName.trim()}
|
||||
aria-label={t('common.add')} className={composerConfirmClass(!!newItemName.trim())}><Plus size={13} strokeWidth={2.5} /></button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('common.cancel')}>
|
||||
<button type="button" onClick={() => { setAddingItemToCatId(null); setNewItemName('') }}
|
||||
aria-label={t('common.cancel')} className={COMPOSER_CANCEL}><X size={13} /></button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Add category */}
|
||||
{addingCategory ? (
|
||||
<div className={COMPOSER_ROW} style={{ boxShadow: COMPOSER.boxShadow }}>
|
||||
<input autoFocus value={newCatName} onChange={e => setNewCatName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') void handleAddCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }}
|
||||
placeholder={t('admin.packingTemplates.categoryName')}
|
||||
className={COMPOSER_INPUT} style={fs(13, 'body')} />
|
||||
<Tooltip label={t('common.save')}>
|
||||
<button type="button" onClick={handleAddCategory} aria-label={t('common.save')} className={composerConfirmClass(!!newCatName.trim())}><Check size={14} strokeWidth={2.5} /></button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('common.cancel')}>
|
||||
<button type="button" onClick={() => { setAddingCategory(false); setNewCatName('') }} aria-label={t('common.cancel')} className={COMPOSER_CANCEL}><X size={14} /></button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={() => setAddingCategory(true)}
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-[10px] border border-dashed border-edge px-3 py-2 font-semibold text-content-muted transition-colors hover:border-content-faint hover:text-content"
|
||||
style={fs(12, 'body')}>
|
||||
<FolderPlus size={13} strokeWidth={2.2} /> {t('admin.packingTemplates.addCategory')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</SettingRows>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { usePermissionsStore, PermissionLevel } from '../../store/permissionsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Save, Loader2, RotateCcw } from 'lucide-react'
|
||||
import { Save, Loader2, RotateCcw, ShieldCheck } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { DialogSection, fs } from '../shared/DialogShell'
|
||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsCard, StatusPill } from '../Settings/settingsKit'
|
||||
|
||||
interface PermissionEntry {
|
||||
key: string
|
||||
@@ -89,84 +91,74 @@ export default function PermissionsPanel(): React.ReactElement {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" />
|
||||
</div>
|
||||
<SettingsCard icon={ShieldCheck} title={t('perm.title')} hint={t('perm.subtitle')}>
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 size={20} className="animate-spin text-content-faint" />
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">{t('perm.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('perm.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button"
|
||||
onClick={handleReset}
|
||||
disabled={saving}
|
||||
title={t('perm.resetDefaults')}
|
||||
aria-label={t('perm.resetDefaults')}
|
||||
className="flex items-center justify-center gap-1.5 px-0 sm:px-3 py-1.5 text-sm w-8 sm:w-auto border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">{t('perm.resetDefaults')}</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !dirty}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 disabled:bg-slate-400 transition-colors"
|
||||
>
|
||||
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100">
|
||||
{CATEGORIES.map(cat => (
|
||||
<div key={cat.id} className="px-6 py-4">
|
||||
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">
|
||||
{t(`perm.cat.${cat.id}`)}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{cat.keys.map(key => {
|
||||
const entry = entryMap.get(key)
|
||||
if (!entry) return null
|
||||
const currentLevel = values[key] || entry.defaultLevel
|
||||
const isDefault = currentLevel === entry.defaultLevel
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-700">{t(`perm.action.${key}`)}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t(`perm.actionHint.${key}`)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isDefault && (
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700">
|
||||
{t('perm.customized')}
|
||||
</span>
|
||||
)}
|
||||
<CustomSelect
|
||||
value={currentLevel}
|
||||
onChange={(val) => handleChange(key, val as PermissionLevel)}
|
||||
options={entry.allowedLevels.map(l => ({
|
||||
value: l,
|
||||
label: t(LEVEL_LABELS[l] || l),
|
||||
}))}
|
||||
style={{ minWidth: 160 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<SettingsCard
|
||||
icon={ShieldCheck}
|
||||
title={t('perm.title')}
|
||||
hint={t('perm.subtitle')}
|
||||
action={
|
||||
<div className="flex items-center gap-2" style={fs(12.5, 'body')}>
|
||||
<button type="button"
|
||||
onClick={handleReset}
|
||||
disabled={saving}
|
||||
aria-label={t('perm.resetDefaults')}
|
||||
className={SETTINGS_BUTTON}
|
||||
>
|
||||
<RotateCcw size={14} strokeWidth={2.2} />
|
||||
<span>{t('perm.resetDefaults')}</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !dirty}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} strokeWidth={2.2} />}
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={`flex flex-col gap-5 transition-opacity ${saving ? 'opacity-60' : ''}`}>
|
||||
{CATEGORIES.map(cat => (
|
||||
<DialogSection key={cat.id} label={t(`perm.cat.${cat.id}`)}>
|
||||
<SettingRows>
|
||||
{cat.keys.map(key => {
|
||||
const entry = entryMap.get(key)
|
||||
if (!entry) return null
|
||||
const currentLevel = values[key] || entry.defaultLevel
|
||||
const isDefault = currentLevel === entry.defaultLevel
|
||||
return (
|
||||
<SettingRow
|
||||
key={key}
|
||||
label={t(`perm.action.${key}`)}
|
||||
hint={t(`perm.actionHint.${key}`)}
|
||||
control={<>
|
||||
{!isDefault && <StatusPill tone="warning">{t('perm.customized')}</StatusPill>}
|
||||
<CustomSelect
|
||||
value={currentLevel}
|
||||
onChange={(val) => handleChange(key, val as PermissionLevel)}
|
||||
options={entry.allowedLevels.map(l => ({
|
||||
value: l,
|
||||
label: t(LEVEL_LABELS[l] || l),
|
||||
}))}
|
||||
style={{ minWidth: 180 }}
|
||||
/>
|
||||
</>}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</SettingRows>
|
||||
</DialogSection>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { declaredPoiCategories } from './pluginCaps'
|
||||
|
||||
/** The category's own colour with a white glyph on it, as the explore pill draws a chip that is on. */
|
||||
const SWATCH = 'grid h-6 w-6 flex-none place-items-center rounded-full text-white shadow-sm' // theme-lint-disable: the glyph sits on the plugin's category colour, not on a theme surface
|
||||
|
||||
interface Props {
|
||||
pluginId: string
|
||||
/** The plugin's declared permissions: the list only shows with the grant that serves it. */
|
||||
@@ -28,15 +31,15 @@ export default function PluginPoiCategoryList({ pluginId, permissions, categorie
|
||||
return (
|
||||
<div className={className}>
|
||||
<h4 className={titleClassName}>{t('admin.plugins.poiCategoriesTitle')}</h4>
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
<ul className="m-0 mt-2 flex list-none flex-col gap-1.5 p-0">
|
||||
{declared.map(({ id, label, Icon, color }) => (
|
||||
<li key={id} className={`flex items-center gap-2.5 py-0.5 ${itemClassName}`}>
|
||||
<li key={id} className={`flex min-w-0 items-center gap-2.5 py-0.5 ${itemClassName}`}>
|
||||
<span aria-hidden="true" data-testid="poi-category-swatch"
|
||||
className="grid h-6 w-6 shrink-0 place-items-center rounded-full text-white"
|
||||
className={SWATCH}
|
||||
style={{ backgroundColor: color }}>
|
||||
<Icon size={13} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="min-w-0">{label}</span>
|
||||
<span className="min-w-0 truncate">{label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { INPUT, LABEL } from '../shared/dialogParts'
|
||||
|
||||
export interface RoutingDefaults {
|
||||
routing_base_url?: string
|
||||
@@ -10,6 +12,11 @@ interface RoutingFieldsProps {
|
||||
defaults: RoutingDefaults
|
||||
onSave: (patch: RoutingDefaults) => Promise<void>
|
||||
onReset: (key: keyof RoutingDefaults) => Promise<void>
|
||||
/**
|
||||
* The phone's hint look. A caller that passes it keeps the phone's own field
|
||||
* styling as well; without it the fields take the desktop editors' look
|
||||
* (eyebrow label, boxed input, faint hint).
|
||||
*/
|
||||
hintClassName?: string
|
||||
}
|
||||
|
||||
@@ -18,11 +25,31 @@ const ROUTERS = [
|
||||
{ key: 'valhalla_base_url', label: 'settings.valhallaBase', hint: 'settings.valhallaBaseHint', placeholder: 'https://valhalla.example.org' },
|
||||
] as const
|
||||
|
||||
function RoutingUrlField({ router, defaults, onSave, onReset, hintClassName = 'text-caption leading-relaxed text-content-faint' }: RoutingFieldsProps & { router: typeof ROUTERS[number] }) {
|
||||
// The phone screen's look, unchanged: MAdminDefaultUserSettings passes its own hint class.
|
||||
const PHONE = {
|
||||
wrap: 'min-w-0 space-y-1.5',
|
||||
head: 'flex flex-wrap items-baseline gap-x-2 gap-y-1',
|
||||
label: 'text-body font-medium text-content-secondary',
|
||||
reset: 'text-caption text-content-muted underline disabled:opacity-50',
|
||||
input: 'min-h-11 w-full min-w-0 rounded-lg border border-edge bg-surface-input px-3 py-2 text-body text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50',
|
||||
}
|
||||
|
||||
// The desktop editors' look (shared/dialogParts): an eyebrow over a boxed field.
|
||||
const DESKTOP = {
|
||||
wrap: 'min-w-0',
|
||||
head: 'flex flex-wrap items-baseline justify-between gap-x-2',
|
||||
label: LABEL,
|
||||
reset: 'mb-[5px] rounded-full font-medium text-content-muted underline-offset-2 hover:text-content hover:underline disabled:opacity-50',
|
||||
input: INPUT,
|
||||
}
|
||||
|
||||
function RoutingUrlField({ router, defaults, onSave, onReset, hintClassName }: RoutingFieldsProps & { router: typeof ROUTERS[number] }) {
|
||||
const { t } = useTranslation()
|
||||
const id = useId()
|
||||
const [draft, setDraft] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const phone = hintClassName !== undefined
|
||||
const look = phone ? PHONE : DESKTOP
|
||||
const saved = defaults[router.key] ?? ''
|
||||
const commit = async () => {
|
||||
if (draft === null) return
|
||||
@@ -38,11 +65,11 @@ function RoutingUrlField({ router, defaults, onSave, onReset, hintClassName = 't
|
||||
finally { setDraft(null); setSaving(false) }
|
||||
}
|
||||
return (
|
||||
<div className="min-w-0 space-y-1.5" aria-busy={saving}>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<label htmlFor={id} className="text-body font-medium text-content-secondary">{t(router.label)}</label>
|
||||
<div className={`${look.wrap} ${!phone && saving ? 'opacity-60' : ''}`} aria-busy={saving}>
|
||||
<div className={look.head}>
|
||||
<label htmlFor={id} className={look.label}>{t(router.label)}</label>
|
||||
{defaults[router.key] !== undefined && (
|
||||
<button type="button" disabled={saving} onClick={reset} className="text-caption text-content-muted underline disabled:opacity-50">
|
||||
<button type="button" disabled={saving} onClick={reset} className={look.reset} style={phone ? undefined : fs(11)}>
|
||||
{t('admin.defaultSettings.resetToBuiltIn')}
|
||||
</button>
|
||||
)}
|
||||
@@ -52,13 +79,16 @@ function RoutingUrlField({ router, defaults, onSave, onReset, hintClassName = 't
|
||||
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur() }}
|
||||
placeholder={router.placeholder} spellCheck={false} autoComplete="off" autoCapitalize="none"
|
||||
aria-describedby={`${id}-hint`}
|
||||
className="min-h-11 w-full min-w-0 rounded-lg border border-edge bg-surface-input px-3 py-2 text-body text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
|
||||
className={look.input}
|
||||
/>
|
||||
<p id={`${id}-hint`} className={hintClassName}>{t(router.hint)}</p>
|
||||
{phone
|
||||
? <p id={`${id}-hint`} className={hintClassName}>{t(router.hint)}</p>
|
||||
: <p id={`${id}-hint`} className="m-0 mt-1 leading-normal text-content-faint" style={fs(11)}>{t(router.hint)}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RoutingInstanceFields(props: RoutingFieldsProps) {
|
||||
return <div className="flex flex-col gap-3">{ROUTERS.map(router => <RoutingUrlField key={router.key} router={router} {...props} />)}</div>
|
||||
const phone = props.hintClassName !== undefined
|
||||
return <div className={phone ? 'flex flex-col gap-3' : 'flex flex-col gap-4'}>{ROUTERS.map(router => <RoutingUrlField key={router.key} router={router} {...props} />)}</div>
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { useId, useState } from 'react'
|
||||
import { ChevronRight, GraduationCap, Loader2, MapPinned, Plus, Trash2, WifiOff } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSchoolHolidayCatalog } from './useSchoolHolidayCatalog'
|
||||
import SchoolHolidayRegionEditor, { holidayButtonClass, holidayInputClass } from './SchoolHolidayRegionEditor'
|
||||
import SchoolHolidayRegionEditor from './SchoolHolidayRegionEditor'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import ErrorBoundary from '../shared/ErrorBoundary'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { AddRowButton, EditorField, GRID_2, INPUT, LABEL, PANEL } from '../shared/dialogParts'
|
||||
import { SettingRows, SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON } from '../Settings/settingsKit'
|
||||
|
||||
export default function SchoolHolidayCatalog() {
|
||||
return <ErrorBoundary boundaryId="school-holiday-catalog"><Catalog /></ErrorBoundary>
|
||||
@@ -19,47 +23,80 @@ function Catalog() {
|
||||
const [code, setCode] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [remove, setRemove] = useState<{ label: string; action: () => Promise<boolean> } | null>(null)
|
||||
const fieldId = useId()
|
||||
const selectedCountry = state.catalog.countries.find(item => item.code === country)?.code || state.catalog.countries[0]?.code || ''
|
||||
const regions = state.catalog.regions.filter(region => region.country === selectedCountry)
|
||||
const disabled = state.busy || state.loading || state.offline
|
||||
const addRegion = () => state.setEditor({ id: 0, country: selectedCountry, name: '', code: '', revision: 0, holidays: [] })
|
||||
|
||||
return <section className="space-y-4 rounded-2xl border border-edge bg-surface-card p-4 sm:p-6">
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">{t('schoolCatalog.title')}</h2>
|
||||
<p className="mt-1 text-caption text-content-faint">{t('schoolCatalog.hint')}</p>
|
||||
</div>
|
||||
{state.offline && <p role="status" className="text-body text-warning">{t('schoolCatalog.offline')}</p>}
|
||||
{state.loading && <p role="status" className="text-body text-content-muted">{t('common.loading')}</p>}
|
||||
{state.error && !state.editor && <div role="alert" className="space-y-2 text-body text-danger">{state.error}<button type="button" disabled={disabled} onClick={() => void state.refresh()} className={`${holidayButtonClass} block`}>{t('schoolCatalog.retry')}</button></div>}
|
||||
<fieldset disabled={disabled} className="space-y-4">
|
||||
return <SettingsCard
|
||||
icon={GraduationCap}
|
||||
title={t('schoolCatalog.title')}
|
||||
hint={t('schoolCatalog.hint')}
|
||||
badge={state.catalog.countries.length > 0 ? <StatusPill>{state.catalog.regions.length}</StatusPill> : undefined}
|
||||
>
|
||||
{state.offline && <p role="status" className="m-0 flex items-center gap-2 rounded-[12px] bg-warning-soft px-3.5 py-2.5 text-warning" style={fs(12.5, 'body')}><WifiOff size={14} className="flex-none" />{t('schoolCatalog.offline')}</p>}
|
||||
{state.loading && <p role="status" className="m-0 flex items-center gap-2 text-content-muted" style={fs(12.5, 'body')}><Loader2 size={14} className="flex-none animate-spin" />{t('common.loading')}</p>}
|
||||
{state.error && !state.editor && <div role="alert" className="flex flex-wrap items-center gap-3 rounded-[12px] bg-danger-soft px-3.5 py-2.5 text-danger" style={fs(12.5, 'body')}>
|
||||
<span className="min-w-0 flex-1 basis-48">{state.error}</span>
|
||||
<button type="button" disabled={disabled} onClick={() => void state.refresh()} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>{t('schoolCatalog.retry')}</button>
|
||||
</div>}
|
||||
<fieldset disabled={disabled} className={`m-0 flex min-w-0 flex-col gap-4 border-0 p-0 ${state.busy ? 'opacity-60' : ''}`}>
|
||||
{state.catalog.countries.length > 0 && <div className="flex items-end gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-1 text-body"><span>{t('schoolCatalog.country')}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className={LABEL}>{t('schoolCatalog.country')}</span>
|
||||
<CustomSelect value={selectedCountry} onChange={value => setCountry(String(value))} options={state.catalog.countries.map(item => ({ value: item.code, label: item.name }))} placeholder={t('schoolCatalog.country')} searchable disabled={disabled} />
|
||||
</div>
|
||||
<button type="button" disabled={adding} title={t('schoolCatalog.addCountry')} aria-label={t('schoolCatalog.addCountry')} onClick={() => setAdding(true)} className={`${holidayButtonClass} inline-flex shrink-0 items-center justify-center`}><Plus size={18} /></button>
|
||||
<button type="button" disabled={regions.length > 0} title={t('schoolCatalog.deleteHint')} className={holidayButtonClass} aria-label={t('schoolCatalog.deleteCountry')} onClick={() => setRemove({ label: selectedCountry, action: () => state.deleteCountry(selectedCountry) })}><Trash2 size={18} /></button>
|
||||
<Tooltip label={t('schoolCatalog.addCountry')}>
|
||||
<button type="button" disabled={adding} aria-label={t('schoolCatalog.addCountry')} onClick={() => setAdding(true)} className={`${SETTINGS_ICON_BUTTON} h-[38px] w-[38px]`}><Plus size={16} strokeWidth={2.2} /></button>
|
||||
</Tooltip>
|
||||
{/* The hint is the reason the button is off, so it has to show while it is: the
|
||||
wrapper takes the pointer the disabled button lets through. */}
|
||||
<Tooltip label={t('schoolCatalog.deleteHint')}>
|
||||
<span className="inline-flex flex-none">
|
||||
<button type="button" disabled={regions.length > 0} className={`${SETTINGS_ICON_BUTTON} h-[38px] w-[38px] hover:!text-danger disabled:pointer-events-none`} aria-label={t('schoolCatalog.deleteCountry')} onClick={() => setRemove({ label: selectedCountry, action: () => state.deleteCountry(selectedCountry) })}><Trash2 size={15} strokeWidth={2} /></button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>}
|
||||
{!adding && state.catalog.countries.length === 0 && <button type="button" onClick={() => setAdding(true)} className={`${holidayButtonClass} flex items-center gap-2`}><Plus size={16} />{t('schoolCatalog.addCountry')}</button>}
|
||||
{adding && <form className="space-y-3 rounded-xl border border-edge p-3" onSubmit={async event => {
|
||||
{!adding && state.catalog.countries.length === 0 && <div>
|
||||
<button type="button" onClick={() => setAdding(true)} className={SETTINGS_BUTTON} style={fs(13, 'body')}><Plus size={14} strokeWidth={2.2} />{t('schoolCatalog.addCountry')}</button>
|
||||
</div>}
|
||||
{adding && <form className={PANEL} onSubmit={async event => {
|
||||
event.preventDefault()
|
||||
if (await state.createCountry({ code, name: name.trim() })) { setCountry(code); setAdding(false); setCode(''); setName('') }
|
||||
}}>
|
||||
<label className="block space-y-1 text-body"><span>{t('schoolCatalog.country')}</span><input autoFocus required maxLength={100} className={holidayInputClass} value={name} onChange={event => setName(event.target.value)} /></label>
|
||||
<label className="block space-y-1 text-body"><span>{t('schoolCatalog.code')}</span><input required pattern="[A-Z]{2}" maxLength={2} className={holidayInputClass} value={code} onChange={event => setCode(event.target.value.toUpperCase())} placeholder="US" /></label>
|
||||
<div className="flex gap-2"><button type="submit" className={holidayButtonClass}>{t('common.save')}</button><button type="button" onClick={() => setAdding(false)} className={holidayButtonClass}>{t('common.cancel')}</button></div>
|
||||
<div className={GRID_2}>
|
||||
<EditorField label={t('schoolCatalog.country')} htmlFor={`${fieldId}-name`}>
|
||||
<input id={`${fieldId}-name`} autoFocus required maxLength={100} className={INPUT} value={name} onChange={event => setName(event.target.value)} />
|
||||
</EditorField>
|
||||
<EditorField label={t('schoolCatalog.code')} htmlFor={`${fieldId}-code`}>
|
||||
<input id={`${fieldId}-code`} required pattern="[A-Z]{2}" maxLength={2} className={`${INPUT} font-geist uppercase tracking-[.08em]`} value={code} onChange={event => setCode(event.target.value.toUpperCase())} placeholder="US" />
|
||||
</EditorField>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={() => setAdding(false)} className={SETTINGS_BUTTON} style={fs(13, 'body')}>{t('common.cancel')}</button>
|
||||
<button type="submit" className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>{t('common.save')}</button>
|
||||
</div>
|
||||
</form>}
|
||||
{selectedCountry && <>
|
||||
{regions.length === 0 && <p className="text-body text-content-muted">{t('schoolCatalog.empty')}</p>}
|
||||
<ul className="divide-y divide-edge">{regions.map(region => <li key={region.id} className="flex items-center gap-2 py-2">
|
||||
<button type="button" className={`${holidayButtonClass} min-w-0 flex-1 text-left`} onClick={() => void state.openRegion(region.id)}>{region.name}</button>
|
||||
<button type="button" title={t('schoolCatalog.addRegion')} aria-label={t('schoolCatalog.addRegion')} className={`${holidayButtonClass} inline-flex shrink-0 items-center justify-center`} onClick={addRegion}><Plus size={18} /></button>
|
||||
<button type="button" aria-label={`${t('common.delete')} ${region.name}`} className={holidayButtonClass} onClick={() => setRemove({ label: region.name, action: () => state.deleteRegion(region.id, region.revision) })}><Trash2 size={18} /></button>
|
||||
</li>)}</ul>
|
||||
{regions.length === 0 && <button type="button" className={`${holidayButtonClass} flex items-center gap-2`} onClick={addRegion}><Plus size={16} />{t('schoolCatalog.addRegion')}</button>}
|
||||
{regions.length === 0
|
||||
? <div className="rounded-[12px] border border-dashed border-edge px-4 py-5 text-center"><SettingsHint>{t('schoolCatalog.empty')}</SettingsHint></div>
|
||||
: <SettingRows>
|
||||
{regions.map(region => <div key={region.id} className="flex items-center gap-2 py-1.5 pl-1.5 pr-3">
|
||||
<button type="button" className="group flex min-w-0 flex-1 items-center gap-3 rounded-[10px] px-2 py-1.5 text-left transition-colors hover:bg-surface-secondary" onClick={() => void state.openRegion(region.id)}>
|
||||
<span aria-hidden className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary"><MapPinned size={15} strokeWidth={1.9} /></span>
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(13, 'body')}>{region.name}</span>
|
||||
<ChevronRight aria-hidden size={15} className="flex-none text-content-faint transition-transform group-hover:translate-x-0.5" />
|
||||
</button>
|
||||
<Tooltip label={`${t('common.delete')} ${region.name}`}>
|
||||
<button type="button" aria-label={`${t('common.delete')} ${region.name}`} className={`${SETTINGS_ICON_BUTTON} hover:!text-danger`} onClick={() => setRemove({ label: region.name, action: () => state.deleteRegion(region.id, region.revision) })}><Trash2 size={14} strokeWidth={2} /></button>
|
||||
</Tooltip>
|
||||
</div>)}
|
||||
</SettingRows>}
|
||||
<AddRowButton onClick={addRegion}>{t('schoolCatalog.addRegion')}</AddRowButton>
|
||||
</>}
|
||||
</fieldset>
|
||||
{state.editor && <SchoolHolidayRegionEditor key={state.editor.id} region={state.editor} busy={state.busy} offline={state.offline} error={state.offline ? t('schoolCatalog.offline') : state.error} onClose={() => state.setEditor(null)} onSave={body => { if (state.editor) void state.saveRegion(state.editor.country, state.editor.id, body) }} />}
|
||||
<ConfirmDialog isOpen={Boolean(remove)} onClose={() => setRemove(null)} title={t('common.delete')} message={`${remove?.label || ''}. ${t('schoolCatalog.deleteHint')}`} onConfirm={() => { if (remove) void remove.action(); setRemove(null) }} />
|
||||
</section>
|
||||
</SettingsCard>
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { CalendarRange, Trash2 } from 'lucide-react'
|
||||
import { schoolHolidayRegionRequestSchema, type SchoolHolidayRegionDetail, type SchoolHolidayRegionRequest } from '@trek/shared'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import Modal from '../shared/Modal'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import { AddRowButton, EditorField, GRID_2, INPUT, LABEL, PANEL } from '../shared/dialogParts'
|
||||
import { SettingsHint } from '../Settings/settingsKit'
|
||||
|
||||
export const holidayInputClass = 'w-full rounded-lg border border-edge bg-surface-input px-3 py-2 text-body text-content'
|
||||
export const holidayButtonClass = 'rounded-lg border border-edge px-3 py-2 text-body font-medium text-content hover:bg-surface-hover disabled:opacity-50'
|
||||
/** The dialog body's padded column, kept inside the form so the footer's Save submits it. */
|
||||
const BODY = 'flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto px-6 pb-6 pt-5'
|
||||
const ALERT = 'm-0 rounded-[12px] bg-danger-soft px-3.5 py-2.5 text-danger'
|
||||
|
||||
export default function SchoolHolidayRegionEditor({ region, busy, offline = false, error, onClose, onSave }: {
|
||||
region: SchoolHolidayRegionDetail
|
||||
@@ -27,49 +31,79 @@ export default function SchoolHolidayRegionEditor({ region, busy, offline = fals
|
||||
const body = { name, revision: region.revision, holidays: holidays.map(({ name, startDate, endDate }) => ({ name, startDate, endDate })) }
|
||||
const dirty = name !== region.name || JSON.stringify(body.holidays) !== JSON.stringify(region.holidays)
|
||||
const close = () => { if (!busy) { if (dirty) setDiscard(true); else onClose() } }
|
||||
const labelId = `${fieldId}-title`
|
||||
|
||||
return <>
|
||||
<Modal isOpen onClose={close} title={t('schoolCatalog.region')} size="xl">
|
||||
<form className="space-y-4" onSubmit={event => {
|
||||
<DialogShell
|
||||
onClose={close}
|
||||
labelledBy={labelId}
|
||||
width="editor"
|
||||
align="top"
|
||||
blocked={discard}
|
||||
bodyClassName="flex min-h-0 flex-1 flex-col"
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={<DialogTile><CalendarRange size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={close}
|
||||
eyebrow={t('schoolCatalog.title')}
|
||||
title={name.trim() || t('schoolCatalog.region')}
|
||||
sub={region.country}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<form className="flex min-h-0 flex-1 flex-col" onSubmit={event => {
|
||||
event.preventDefault()
|
||||
const parsed = schoolHolidayRegionRequestSchema.safeParse(body)
|
||||
setInvalid(!parsed.success)
|
||||
if (parsed.success) onSave(parsed.data)
|
||||
}}>
|
||||
<fieldset disabled={busy || offline} className="space-y-4">
|
||||
<label className="block space-y-1 text-body"><span>{t('schoolCatalog.region')}</span>
|
||||
<input autoFocus required maxLength={150} className={holidayInputClass} value={name} onChange={event => setName(event.target.value)} />
|
||||
</label>
|
||||
<p className="text-caption text-content-muted">{t('schoolCatalog.periodHint')}</p>
|
||||
{holidays.map((holiday, index) => <div key={holiday.key} className="space-y-2 rounded-xl border border-edge p-3">
|
||||
<div className="space-y-1 text-body">
|
||||
<label htmlFor={`${fieldId}-holiday-${holiday.key}`}>{t('schoolCatalog.name')}</label>
|
||||
<div className="flex items-stretch gap-2">
|
||||
<input id={`${fieldId}-holiday-${holiday.key}`} required maxLength={150} className={`${holidayInputClass} min-w-0 flex-1`} value={holiday.name} onChange={event => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, name: event.target.value } : row))} />
|
||||
<button type="button" className="flex shrink-0 items-center justify-center rounded-[10px] border border-edge px-[9px] text-content-faint transition-colors hover:border-[color:var(--text-faint)] hover:text-content disabled:opacity-50" aria-label={`${t('common.delete')} ${holiday.name || index + 1}`} onClick={() => setHolidays(rows => rows.filter(row => row.key !== holiday.key))}><Trash2 size={13} /></button>
|
||||
<div className={BODY}>
|
||||
<fieldset disabled={busy || offline} className={`m-0 flex min-w-0 flex-col gap-5 border-0 p-0 ${busy ? 'opacity-60' : ''}`}>
|
||||
<EditorField label={t('schoolCatalog.region')} htmlFor={`${fieldId}-region`}>
|
||||
<input id={`${fieldId}-region`} autoFocus required maxLength={150} className={INPUT} value={name} onChange={event => setName(event.target.value)} />
|
||||
</EditorField>
|
||||
<SettingsHint>{t('schoolCatalog.periodHint')}</SettingsHint>
|
||||
{holidays.map((holiday, index) => {
|
||||
const removeLabel = `${t('common.delete')} ${holiday.name || index + 1}`
|
||||
return <div key={holiday.key} className={PANEL}>
|
||||
<EditorField label={t('schoolCatalog.name')} htmlFor={`${fieldId}-holiday-${holiday.key}`}>
|
||||
<div className="flex items-stretch gap-2">
|
||||
<input id={`${fieldId}-holiday-${holiday.key}`} required maxLength={150} className={`${INPUT} flex-1`} value={holiday.name} onChange={event => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, name: event.target.value } : row))} />
|
||||
<Tooltip label={removeLabel}>
|
||||
<button type="button" className="grid w-[38px] flex-none place-items-center rounded-[10px] border border-edge bg-surface-card text-content-faint transition-colors hover:border-content-faint hover:text-danger disabled:cursor-default disabled:opacity-50" aria-label={removeLabel} onClick={() => setHolidays(rows => rows.filter(row => row.key !== holiday.key))}>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</EditorField>
|
||||
<div className={GRID_2}>
|
||||
<div role="group" aria-label={t('schoolCatalog.start')} className="min-w-0">
|
||||
<span className={LABEL}>{t('schoolCatalog.start')}</span>
|
||||
<CustomDatePicker key={String(busy || offline)} value={holiday.startDate} placeholder={t('schoolCatalog.start')} max={holiday.endDate || undefined} onChange={startDate => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, startDate } : row))} />
|
||||
</div>
|
||||
<div role="group" aria-label={t('schoolCatalog.end')} className="min-w-0">
|
||||
<span className={LABEL}>{t('schoolCatalog.end')}</span>
|
||||
<CustomDatePicker key={String(busy || offline)} value={holiday.endDate} placeholder={t('schoolCatalog.end')} min={holiday.startDate || undefined} onChange={endDate => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, endDate } : row))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<div role="group" aria-label={t('schoolCatalog.start')} className="min-w-0 space-y-1 text-caption"><span>{t('schoolCatalog.start')}</span>
|
||||
<CustomDatePicker key={String(busy || offline)} value={holiday.startDate} placeholder={t('schoolCatalog.start')} max={holiday.endDate || undefined} onChange={startDate => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, startDate } : row))} />
|
||||
</div>
|
||||
<div role="group" aria-label={t('schoolCatalog.end')} className="min-w-0 space-y-1 text-caption"><span>{t('schoolCatalog.end')}</span>
|
||||
<CustomDatePicker key={String(busy || offline)} value={holiday.endDate} placeholder={t('schoolCatalog.end')} min={holiday.startDate || undefined} onChange={endDate => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, endDate } : row))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>)}
|
||||
<button type="button" disabled={holidays.length >= 500} className={`${holidayButtonClass} flex items-center gap-2`} onClick={() => {
|
||||
setHolidays(rows => [...rows, { key: nextKey, name: '', startDate: '', endDate: '' }]); setNextKey(key => key + 1)
|
||||
}}><Plus size={16} />{t('schoolCatalog.addPeriod')}</button>
|
||||
{invalid && <p role="alert" className="text-body text-danger">{t('schoolCatalog.invalid')}</p>}
|
||||
{error && <p role="alert" className="text-body text-danger">{error}</p>}
|
||||
</fieldset>
|
||||
<div className="flex justify-end gap-2 border-t border-edge pt-4">
|
||||
<button type="button" className={holidayButtonClass} onClick={close}>{t('common.cancel')}</button>
|
||||
<button type="submit" disabled={busy || offline} aria-busy={busy} className="rounded-lg bg-accent px-4 py-2 text-body font-semibold text-accent-text disabled:opacity-50">{busy ? t('common.loading') : t('common.save')}</button>
|
||||
</div>
|
||||
})}
|
||||
<AddRowButton disabled={holidays.length >= 500} onClick={() => {
|
||||
setHolidays(rows => [...rows, { key: nextKey, name: '', startDate: '', endDate: '' }]); setNextKey(key => key + 1)
|
||||
}}>{t('schoolCatalog.addPeriod')}</AddRowButton>
|
||||
{invalid && <p role="alert" className={ALERT} style={fs(12.5, 'body')}>{t('schoolCatalog.invalid')}</p>}
|
||||
{error && <p role="alert" className={ALERT} style={fs(12.5, 'body')}>{error}</p>}
|
||||
</fieldset>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={close}>{t('common.cancel')}</DialogButton>
|
||||
<DialogButton type="submit" variant="primary" disabled={busy || offline} aria-busy={busy}>{busy ? t('common.loading') : t('common.save')}</DialogButton>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
</DialogShell>
|
||||
<ConfirmDialog isOpen={discard} onClose={() => setDiscard(false)} onConfirm={onClose} title={t('common.cancel')} message={t('schoolCatalog.discard')} />
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Activity, FolderTree, HardDrive } from 'lucide-react'
|
||||
import React, { useEffect, useId, useRef, useState } from 'react'
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
ArrowRightLeft,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
Cloud,
|
||||
Copy,
|
||||
FolderTree,
|
||||
HardDrive,
|
||||
Loader2,
|
||||
Lock,
|
||||
Pencil,
|
||||
PlugZap,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
STORAGE_CATEGORIES,
|
||||
type StorageBackend,
|
||||
@@ -14,7 +34,17 @@ import { relativeTime } from '../../../utils/relativeTime'
|
||||
import ConfirmDialog from '../../shared/ConfirmDialog'
|
||||
import CustomSelect from '../../shared/CustomSelect'
|
||||
import { useToast } from '../../shared/Toast'
|
||||
import { Tooltip } from '../../shared/Tooltip'
|
||||
import { NEUTRAL_TINT, fs } from '../../shared/DialogShell'
|
||||
import Section from '../../Settings/Section'
|
||||
import {
|
||||
SETTINGS_BUTTON,
|
||||
SETTINGS_BUTTON_PRIMARY,
|
||||
SETTINGS_ICON_BUTTON,
|
||||
SettingRows,
|
||||
SettingsHint,
|
||||
StatusPill,
|
||||
} from '../../Settings/settingsKit'
|
||||
import BackendForm, { type BackendFormMirrorProps } from './BackendForm'
|
||||
import {
|
||||
CACHE_CATEGORIES,
|
||||
@@ -43,6 +73,17 @@ import { useStorageAdmin } from './useStorageAdmin'
|
||||
const categoryNames = (t: (key: string) => string, ids: readonly string[]): string =>
|
||||
ids.map((id) => t(`storage.category.${id}`)).join(', ')
|
||||
|
||||
/** The compact buttons of a backend row: white on a hairline, a size under the card's own. */
|
||||
const ROW_BUTTON = 'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 py-1.5 font-medium text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:cursor-default disabled:opacity-50'
|
||||
const ROW_BUTTON_PRIMARY = 'inline-flex items-center gap-1.5 rounded-[10px] bg-accent px-2.5 py-1.5 font-medium text-accent-text hover:opacity-90 disabled:cursor-default disabled:opacity-50'
|
||||
/** The icon square on the left of a row. */
|
||||
const TILE = 'grid h-9 w-9 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-muted'
|
||||
/** What a row says under its head line, lined up with the name rather than the tile. */
|
||||
const DETAILS = 'mt-2 flex flex-col items-start gap-1.5 empty:hidden sm:pl-12'
|
||||
const META = 'm-0 leading-snug text-content-faint'
|
||||
|
||||
const TYPE_ICON: Record<string, LucideIcon> = { local: HardDrive, s3: Cloud, mirror: Copy }
|
||||
|
||||
function TestResult({ result, failedLabel, okLabel }: {
|
||||
result: StorageTestResponse | undefined
|
||||
failedLabel: string
|
||||
@@ -50,19 +91,37 @@ function TestResult({ result, failedLabel, okLabel }: {
|
||||
}): React.ReactElement | null {
|
||||
if (result === undefined) return null
|
||||
return (
|
||||
<div className="mt-2 space-y-0.5">
|
||||
<p className="text-xs font-semibold text-content">{result.ok ? okLabel : failedLabel}</p>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<StatusPill
|
||||
tone={result.ok ? 'success' : 'danger'}
|
||||
icon={result.ok ? <Check size={10} strokeWidth={2.6} /> : <X size={10} strokeWidth={2.6} />}
|
||||
>
|
||||
{result.ok ? okLabel : failedLabel}
|
||||
</StatusPill>
|
||||
{result.targets.map((target) => (
|
||||
<p key={target.name} className="text-xs text-content-faint">
|
||||
{target.ok ? '✓' : '✗'} {target.name}
|
||||
{target.error ? ` — ${target.error}` : ''}
|
||||
<p key={target.name} className="m-0 flex min-w-0 items-start gap-1.5 text-content-faint" style={fs(11.5)}>
|
||||
{target.ok
|
||||
? <Check size={12} strokeWidth={2.4} className="mt-[1px] flex-none text-success" />
|
||||
: <X size={12} strokeWidth={2.4} className="mt-[1px] flex-none text-danger" />}
|
||||
<span className="min-w-0 break-words">
|
||||
<span className="font-geist font-semibold text-content-secondary">{target.name}</span>
|
||||
{target.error ? ` — ${target.error}` : ''}
|
||||
</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LINK_BUTTON_STYLE: React.CSSProperties = { background: 'none', border: 'none', cursor: 'pointer' }
|
||||
/** A thin bar for a running sync or move. */
|
||||
function Progress({ done, total }: { done: number; total: number }): React.ReactElement {
|
||||
const share = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0
|
||||
return (
|
||||
<div className="h-1.5 w-full max-w-[280px] overflow-hidden rounded-full bg-surface-tertiary" aria-hidden="true">
|
||||
<div className="h-full rounded-full bg-accent transition-[width] duration-300" style={{ width: `${share}%` }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminStoragePanel(): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
@@ -94,6 +153,7 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
// confirmed the first — a ref (not state) so the guard is visible on that
|
||||
// very next synchronous re-render, not just after a state-driven one.
|
||||
const migrationStartInFlight = useRef(false)
|
||||
const migrateTitleId = useId()
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingPromptCheck.current || !admin.state) return
|
||||
@@ -144,12 +204,20 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
}, [migrationQueue, admin.state])
|
||||
|
||||
if (admin.loading) {
|
||||
return <p className="text-sm italic p-4 text-content-faint">{t('storage.loading')}</p>
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-1 py-4 text-content-faint" style={fs(12.5, 'body')}>
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
{t('storage.loading')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!admin.state || !admin.draft) {
|
||||
return (
|
||||
<div role="alert" className="rounded-xl border p-4 text-sm border-edge bg-surface-card text-content">
|
||||
{admin.loadError || t('common.error')}
|
||||
<div role="alert" className="flex items-start gap-3 rounded-2xl border border-edge-faint bg-danger-soft px-4 py-3">
|
||||
<AlertTriangle size={16} strokeWidth={2.2} className="mt-0.5 flex-none text-danger" />
|
||||
<p className="m-0 min-w-0 flex-1 break-words leading-normal text-content" style={fs(13, 'body')}>
|
||||
{admin.loadError || t('common.error')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -299,49 +367,81 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col">
|
||||
{state.configError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-xl border p-3 mb-4 text-sm"
|
||||
style={{ borderColor: 'var(--warning)', background: 'var(--warning-soft)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{t('storage.configError.banner', { error: state.configError })}
|
||||
<div className="mb-5 flex items-start gap-3 rounded-2xl border border-edge-faint bg-warning-soft px-4 py-3">
|
||||
<AlertTriangle size={16} strokeWidth={2.2} className="mt-0.5 flex-none text-warning" />
|
||||
<p role="alert" className="m-0 min-w-0 flex-1 break-words leading-normal text-content" style={fs(13, 'body')}>
|
||||
{t('storage.configError.banner', { error: state.configError })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Section title={t('storage.health.title')} icon={Activity}>
|
||||
{state.health.replicaFailures.length === 0 ? (
|
||||
<p className="text-sm text-content-faint">{t('storage.health.allClear')}</p>
|
||||
<p className="m-0 flex items-center gap-2 text-content-muted" style={fs(12.5, 'body')}>
|
||||
<CheckCircle2 size={15} strokeWidth={2.2} className="flex-none text-success" />
|
||||
{t('storage.health.allClear')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
<SettingRows>
|
||||
{state.health.replicaFailures.map((failure) => (
|
||||
<li key={`${failure.backend}-${failure.key}-${failure.at}`} className="text-sm text-content">
|
||||
{t('storage.health.failureLine', {
|
||||
op: failure.op,
|
||||
key: failure.key,
|
||||
backend: failure.backend,
|
||||
error: failure.error,
|
||||
})}
|
||||
<span className="text-content-faint"> · {relativeTime(failure.at, locale)}</span>
|
||||
</li>
|
||||
<div key={`${failure.backend}-${failure.key}-${failure.at}`} className="flex items-start gap-2.5 px-3.5 py-2.5">
|
||||
<AlertTriangle size={14} strokeWidth={2.2} className="mt-0.5 flex-none text-danger" />
|
||||
<p className="m-0 min-w-0 flex-1 break-words leading-snug text-content" style={fs(12.5, 'body')}>
|
||||
<span>
|
||||
{t('storage.health.failureLine', {
|
||||
op: failure.op,
|
||||
key: failure.key,
|
||||
backend: failure.backend,
|
||||
error: failure.error,
|
||||
})}
|
||||
</span>
|
||||
<span className="whitespace-nowrap text-content-faint"> · {relativeTime(failure.at, locale)}</span>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</SettingRows>
|
||||
)}
|
||||
{state.seedFilePresent && <p className="text-xs mt-2 text-content-faint">{t('storage.health.seedFile')}</p>}
|
||||
{state.seedFilePresent && <SettingsHint>{t('storage.health.seedFile')}</SettingsHint>}
|
||||
</Section>
|
||||
|
||||
<Section title={t('storage.backends.title')} icon={HardDrive}>
|
||||
<p className="text-sm text-content-faint" style={{ marginTop: -8 }}>{t('storage.description')}</p>
|
||||
<p className="text-xs mb-2 text-content-faint">
|
||||
{state.usage
|
||||
? t('storage.usage.computed', { age: relativeTime(state.usage.computedAt, locale) })
|
||||
: t('storage.usage.never')}
|
||||
{' '}
|
||||
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={handleRefreshStats}>
|
||||
<Section
|
||||
title={t('storage.backends.title')}
|
||||
icon={HardDrive}
|
||||
hint={t('storage.description')}
|
||||
action={
|
||||
editing ? undefined : (
|
||||
<button type="button"
|
||||
onClick={() =>
|
||||
setEditing({
|
||||
initial: null,
|
||||
originalName: null,
|
||||
mirror: { candidates: replicaCandidates(rows, null), initialTargets: [] },
|
||||
})
|
||||
}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.2} />
|
||||
{t('storage.backends.add')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<p className="m-0 min-w-0 flex-1 text-content-faint" style={fs(11.5)}>
|
||||
{state.usage
|
||||
? t('storage.usage.computed', { age: relativeTime(state.usage.computedAt, locale) })
|
||||
: t('storage.usage.never')}
|
||||
</p>
|
||||
<button type="button" className={ROW_BUTTON} style={fs(12, 'body')} onClick={handleRefreshStats}>
|
||||
<RefreshCw size={13} strokeWidth={2.2} />
|
||||
{state.usage ? t('storage.usage.refresh') : t('storage.usage.compute')}
|
||||
</button>
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
</div>
|
||||
|
||||
<SettingRows>
|
||||
{rows.map((row) => {
|
||||
const resultKey = row.mirrorName ?? row.name
|
||||
const result = testResultFor(resultKey)
|
||||
@@ -352,150 +452,172 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
: row.backend
|
||||
const rowUsage = usageSums?.[row.name]
|
||||
const backfill = row.mirrorName ? state.backfills.find((b) => b.backend === row.mirrorName) : undefined
|
||||
const TypeIcon = TYPE_ICON[row.type] ?? HardDrive
|
||||
return (
|
||||
<div key={row.name} data-testid={`storage-backend-${row.name}`} className="rounded-xl border p-4 border-edge-secondary">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-content">{row.name}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-secondary">
|
||||
{t(`storage.type.${row.type}`)}
|
||||
<div key={row.name} data-testid={`storage-backend-${row.name}`} className="px-3.5 py-3">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<span className={TILE}>
|
||||
<TypeIcon size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-faint">
|
||||
{t(`storage.source.${row.source}`)}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() =>
|
||||
row.mirrorName
|
||||
? admin.testMirror(resultKey, mirrorProbeTargets(draft, state, testCandidate))
|
||||
: admin.test(testCandidate)
|
||||
}
|
||||
>
|
||||
{t('storage.actions.test')}
|
||||
</button>
|
||||
{row.source !== 'env' && (
|
||||
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => startEdit(row)}>
|
||||
{t('storage.actions.edit')}
|
||||
<div className="min-w-0 flex-1 basis-48">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<span className="min-w-0 truncate font-geist font-semibold text-content" style={fs(13.5, 'body')}>{row.name}</span>
|
||||
<StatusPill>{t(`storage.type.${row.type}`)}</StatusPill>
|
||||
<StatusPill icon={row.source === 'env' ? <Lock size={10} strokeWidth={2.4} /> : undefined}>
|
||||
{t(`storage.source.${row.source}`)}
|
||||
</StatusPill>
|
||||
</div>
|
||||
<p className="m-0 mt-0.5 text-content-faint" style={fs(11.5)}>
|
||||
{row.categories.length > 0
|
||||
? t('storage.backends.usedBy', { categories: categoryNames(t, row.categories) })
|
||||
: t('storage.backends.unused')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-none items-center gap-1.5">
|
||||
<button type="button"
|
||||
className={ROW_BUTTON}
|
||||
style={fs(12, 'body')}
|
||||
onClick={() =>
|
||||
row.mirrorName
|
||||
? admin.testMirror(resultKey, mirrorProbeTargets(draft, state, testCandidate))
|
||||
: admin.test(testCandidate)
|
||||
}
|
||||
>
|
||||
<PlugZap size={13} strokeWidth={2.2} />
|
||||
{t('storage.actions.test')}
|
||||
</button>
|
||||
)}
|
||||
{row.source === 'settings' && (
|
||||
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: row.name, degenerate: false })}>
|
||||
{t('storage.actions.remove')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{row.categories.length > 0
|
||||
? t('storage.backends.usedBy', { categories: categoryNames(t, row.categories) })
|
||||
: t('storage.backends.unused')}
|
||||
</p>
|
||||
{rowUsage && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.usage.line', { objects: String(rowUsage.objects), size: formatBytes(rowUsage.bytes) })}
|
||||
{row.name === 'uploads-local' && state.usage!.legacyPhotos.objects > 0
|
||||
? ` (${t('storage.usage.legacyNote')})`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
{row.mirrorTargets.length > 0 && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.mirror.mirroredTo', { targets: row.mirrorTargets.join(', ') })}
|
||||
</p>
|
||||
)}
|
||||
{row.replicaOf.length > 0 && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.mirror.replicaOf', { primaries: row.replicaOf.join(', ') })}
|
||||
</p>
|
||||
)}
|
||||
{row.source === 'env' && <p className="text-xs mt-1 text-content-faint">{t('storage.backends.envReadOnly')}</p>}
|
||||
{result === 'running' ? (
|
||||
<p className="text-xs mt-2 text-content-faint">{t('storage.test.running')}</p>
|
||||
) : (
|
||||
<TestResult
|
||||
result={result as StorageTestResponse | undefined}
|
||||
okLabel={t('storage.test.ok')}
|
||||
failedLabel={t('storage.test.failed')}
|
||||
/>
|
||||
)}
|
||||
{row.mirrorTargets.length > 0 && (
|
||||
<div className="mt-2">
|
||||
{backfill?.status === 'running' ? (
|
||||
<>
|
||||
<p className="text-xs text-content-faint">
|
||||
{t('storage.sync.running', { done: String(backfill.done), total: String(backfill.total) })}
|
||||
</p>
|
||||
<p className="text-xs text-content-faint">
|
||||
{t('storage.sync.counts', {
|
||||
copied: String(backfill.copied),
|
||||
skipped: String(backfill.skipped),
|
||||
failed: String(backfill.failed),
|
||||
})}
|
||||
</p>
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary mt-1"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => handleCancelBackfill(row)}
|
||||
>
|
||||
{t('storage.sync.cancel')}
|
||||
{row.source !== 'env' && (
|
||||
<Tooltip label={t('storage.actions.edit')}>
|
||||
<button type="button" aria-label={t('storage.actions.edit')} className={SETTINGS_ICON_BUTTON} onClick={() => startEdit(row)}>
|
||||
<Pencil size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Terminal statuses render ALONGSIDE the button (not instead-of) —
|
||||
a completed/cancelled/errored backfill must stay re-runnable
|
||||
without a page reload. */}
|
||||
{backfill?.status === 'done' && (
|
||||
<p className="text-xs text-content-faint">
|
||||
{t('storage.sync.done', {
|
||||
</Tooltip>
|
||||
)}
|
||||
{row.source === 'settings' && (
|
||||
<Tooltip label={t('storage.actions.remove')}>
|
||||
<button type="button" aria-label={t('storage.actions.remove')} className={`${SETTINGS_ICON_BUTTON} hover:!text-danger`} onClick={() => setConfirmRemove({ name: row.name, degenerate: false })}>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={DETAILS}>
|
||||
{rowUsage && (
|
||||
<p className={`${META} font-geist tabular-nums`} style={fs(11.5)}>
|
||||
{t('storage.usage.line', { objects: String(rowUsage.objects), size: formatBytes(rowUsage.bytes) })}
|
||||
{row.name === 'uploads-local' && state.usage!.legacyPhotos.objects > 0
|
||||
? ` (${t('storage.usage.legacyNote')})`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
{row.mirrorTargets.length > 0 && (
|
||||
<p className={META} style={fs(11.5)}>
|
||||
{t('storage.mirror.mirroredTo', { targets: row.mirrorTargets.join(', ') })}
|
||||
</p>
|
||||
)}
|
||||
{row.replicaOf.length > 0 && (
|
||||
<p className={META} style={fs(11.5)}>
|
||||
{t('storage.mirror.replicaOf', { primaries: row.replicaOf.join(', ') })}
|
||||
</p>
|
||||
)}
|
||||
{row.source === 'env' && <p className={META} style={fs(11.5)}>{t('storage.backends.envReadOnly')}</p>}
|
||||
{result === 'running' ? (
|
||||
<p className={`${META} inline-flex items-center gap-1.5`} style={fs(11.5)}>
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
{t('storage.test.running')}
|
||||
</p>
|
||||
) : (
|
||||
<TestResult
|
||||
result={result as StorageTestResponse | undefined}
|
||||
okLabel={t('storage.test.ok')}
|
||||
failedLabel={t('storage.test.failed')}
|
||||
/>
|
||||
)}
|
||||
{row.mirrorTargets.length > 0 && (
|
||||
<div className="flex flex-col items-start gap-1.5">
|
||||
{backfill?.status === 'running' ? (
|
||||
<>
|
||||
<p className={`${META} font-geist tabular-nums`} style={fs(11.5)}>
|
||||
{t('storage.sync.running', { done: String(backfill.done), total: String(backfill.total) })}
|
||||
</p>
|
||||
<Progress done={backfill.done} total={backfill.total} />
|
||||
<p className={`${META} font-geist tabular-nums`} style={fs(11.5)}>
|
||||
{t('storage.sync.counts', {
|
||||
copied: String(backfill.copied),
|
||||
deleted: String(backfill.deleted),
|
||||
skipped: String(backfill.skipped),
|
||||
failed: String(backfill.failed),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{backfill?.status === 'cancelled' && (
|
||||
<p className="text-xs text-content-faint">{t('storage.sync.cancelled')}</p>
|
||||
)}
|
||||
{backfill?.status === 'error' && (
|
||||
<p className="text-xs text-content-faint">
|
||||
{t('storage.sync.error', { error: backfill.error ?? '' })}
|
||||
</p>
|
||||
)}
|
||||
{syncPrompt !== row.name && (
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => handleStartBackfill(row)}
|
||||
className={ROW_BUTTON}
|
||||
style={fs(12, 'body')}
|
||||
onClick={() => handleCancelBackfill(row)}
|
||||
>
|
||||
{t('storage.sync.now')}
|
||||
<X size={13} strokeWidth={2.2} />
|
||||
{t('storage.sync.cancel')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{syncPrompt === row.name && (
|
||||
<div className="mt-2 rounded-lg border p-2 border-edge-secondary">
|
||||
<p className="text-xs text-content">{t('storage.sync.prompt')}</p>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => handleStartBackfill(row)}
|
||||
>
|
||||
{t('storage.sync.now')}
|
||||
</button>
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => setSyncPrompt(null)}
|
||||
>
|
||||
{t('storage.sync.dismiss')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Terminal statuses render ALONGSIDE the button (not instead-of) —
|
||||
a completed/cancelled/errored backfill must stay re-runnable
|
||||
without a page reload. */}
|
||||
{backfill?.status === 'done' && (
|
||||
<p className={`${META} font-geist tabular-nums`} style={fs(11.5)}>
|
||||
{t('storage.sync.done', {
|
||||
copied: String(backfill.copied),
|
||||
deleted: String(backfill.deleted),
|
||||
failed: String(backfill.failed),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{backfill?.status === 'cancelled' && (
|
||||
<p className={META} style={fs(11.5)}>{t('storage.sync.cancelled')}</p>
|
||||
)}
|
||||
{backfill?.status === 'error' && (
|
||||
<p className="m-0 text-danger" style={fs(11.5)}>
|
||||
{t('storage.sync.error', { error: backfill.error ?? '' })}
|
||||
</p>
|
||||
)}
|
||||
{syncPrompt !== row.name && (
|
||||
<button type="button"
|
||||
className={ROW_BUTTON}
|
||||
style={fs(12, 'body')}
|
||||
onClick={() => handleStartBackfill(row)}
|
||||
>
|
||||
<RefreshCw size={13} strokeWidth={2.2} />
|
||||
{t('storage.sync.now')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{syncPrompt === row.name && (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-[12px] border border-edge-faint bg-surface-secondary px-3 py-2.5">
|
||||
<p className="m-0 min-w-0 flex-1 basis-48 text-content" style={fs(12.5, 'body')}>{t('storage.sync.prompt')}</p>
|
||||
<div className="flex flex-none items-center gap-1.5">
|
||||
<button type="button"
|
||||
className={ROW_BUTTON}
|
||||
style={fs(12, 'body')}
|
||||
onClick={() => setSyncPrompt(null)}
|
||||
>
|
||||
{t('storage.sync.dismiss')}
|
||||
</button>
|
||||
<button type="button"
|
||||
className={ROW_BUTTON_PRIMARY}
|
||||
style={fs(12, 'body')}
|
||||
onClick={() => handleStartBackfill(row)}
|
||||
>
|
||||
<RefreshCw size={13} strokeWidth={2.2} />
|
||||
{t('storage.sync.now')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -504,123 +626,119 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
const result = testResultFor(backend.name)
|
||||
const primary = backend.type === 'mirror' ? backend.options.primary : ''
|
||||
return (
|
||||
<div key={backend.name} data-testid={`storage-backend-${backend.name}`} className="rounded-xl border p-4 border-edge-secondary">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-content">{backend.name}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-secondary">
|
||||
{t('storage.type.mirror')}
|
||||
<div key={backend.name} data-testid={`storage-backend-${backend.name}`} className="px-3.5 py-3">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<span className={`${TILE} !bg-warning-soft !text-warning`}>
|
||||
<AlertTriangle size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => admin.test(backend)}>
|
||||
{t('storage.actions.test')}
|
||||
</button>
|
||||
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: backend.name, degenerate: true })}>
|
||||
{t('storage.actions.remove')}
|
||||
</button>
|
||||
<div className="min-w-0 flex-1 basis-48">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<span className="min-w-0 truncate font-geist font-semibold text-content" style={fs(13.5, 'body')}>{backend.name}</span>
|
||||
<StatusPill>{t('storage.type.mirror')}</StatusPill>
|
||||
</div>
|
||||
<p className="m-0 mt-0.5 text-warning" style={fs(11.5)}>{t(`storage.mirror.degenerate.${reason}`, { primary })}</p>
|
||||
</div>
|
||||
<div className="flex flex-none items-center gap-1.5">
|
||||
<button type="button" className={ROW_BUTTON} style={fs(12, 'body')} onClick={() => admin.test(backend)}>
|
||||
<PlugZap size={13} strokeWidth={2.2} />
|
||||
{t('storage.actions.test')}
|
||||
</button>
|
||||
<Tooltip label={t('storage.actions.remove')}>
|
||||
<button type="button" aria-label={t('storage.actions.remove')} className={`${SETTINGS_ICON_BUTTON} hover:!text-danger`} onClick={() => setConfirmRemove({ name: backend.name, degenerate: true })}>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs mt-1 text-content-faint">{t(`storage.mirror.degenerate.${reason}`, { primary })}</p>
|
||||
{result === 'running' ? (
|
||||
<p className="text-xs mt-2 text-content-faint">{t('storage.test.running')}</p>
|
||||
) : (
|
||||
<TestResult
|
||||
result={result as StorageTestResponse | undefined}
|
||||
okLabel={t('storage.test.ok')}
|
||||
failedLabel={t('storage.test.failed')}
|
||||
/>
|
||||
{(result === 'running' || result !== undefined) && (
|
||||
<div className={DETAILS}>
|
||||
{result === 'running' ? (
|
||||
<p className={`${META} inline-flex items-center gap-1.5`} style={fs(11.5)}>
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
{t('storage.test.running')}
|
||||
</p>
|
||||
) : (
|
||||
<TestResult
|
||||
result={result as StorageTestResponse | undefined}
|
||||
okLabel={t('storage.test.ok')}
|
||||
failedLabel={t('storage.test.failed')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</SettingRows>
|
||||
|
||||
{state.migrations.length > 0 && (
|
||||
<div className="space-y-3 mt-3">
|
||||
<SettingRows>
|
||||
{state.migrations.map((m) => (
|
||||
<div
|
||||
key={m.category}
|
||||
data-testid={`storage-migration-${m.category}`}
|
||||
className="rounded-xl border p-4 border-edge-secondary"
|
||||
className="flex items-start gap-3 px-3.5 py-3"
|
||||
>
|
||||
<p className="text-sm font-semibold text-content">{t(`storage.category.${m.category}`)}</p>
|
||||
{m.status === 'running' ? (
|
||||
<>
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.running', {
|
||||
category: t(`storage.category.${m.category}`),
|
||||
done: String(m.done),
|
||||
total: String(m.total),
|
||||
})}
|
||||
</p>
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary mt-1"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => void handleCancelMigration(m)}
|
||||
>
|
||||
{t('storage.migrate.cancel')}
|
||||
</button>
|
||||
</>
|
||||
) : m.status === 'done' ? (
|
||||
<>
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.done', { copied: String(m.copied), skipped: String(m.skipped) })}
|
||||
</p>
|
||||
{m.failed > 0 && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.doneFailures', { failed: String(m.failed) })}
|
||||
</p>
|
||||
)}
|
||||
{m.reclaimable && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.reclaimable', {
|
||||
objects: String(m.reclaimable.objects),
|
||||
size: formatBytes(m.reclaimable.bytes),
|
||||
from: m.from,
|
||||
<span className={TILE}>
|
||||
<ArrowRightLeft size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col items-start gap-1">
|
||||
<p className="m-0 font-semibold text-content" style={fs(13.5, 'body')}>{t(`storage.category.${m.category}`)}</p>
|
||||
{m.status === 'running' ? (
|
||||
<>
|
||||
<p className={`${META} font-geist tabular-nums`} style={fs(11.5)}>
|
||||
{t('storage.migrate.running', {
|
||||
category: t(`storage.category.${m.category}`),
|
||||
done: String(m.done),
|
||||
total: String(m.total),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : m.status === 'failed' ? (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.failed', { error: m.error ?? '' })}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs mt-1 text-content-faint">{t('storage.migrate.cancelled')}</p>
|
||||
)}
|
||||
<Progress done={m.done} total={m.total} />
|
||||
<button type="button"
|
||||
className={`${ROW_BUTTON} mt-1`}
|
||||
style={fs(12, 'body')}
|
||||
onClick={() => void handleCancelMigration(m)}
|
||||
>
|
||||
<X size={13} strokeWidth={2.2} />
|
||||
{t('storage.migrate.cancel')}
|
||||
</button>
|
||||
</>
|
||||
) : m.status === 'done' ? (
|
||||
<>
|
||||
<p className={`${META} font-geist tabular-nums`} style={fs(11.5)}>
|
||||
{t('storage.migrate.done', { copied: String(m.copied), skipped: String(m.skipped) })}
|
||||
</p>
|
||||
{m.failed > 0 && (
|
||||
<p className="m-0 text-danger" style={fs(11.5)}>
|
||||
{t('storage.migrate.doneFailures', { failed: String(m.failed) })}
|
||||
</p>
|
||||
)}
|
||||
{m.reclaimable && (
|
||||
<p className={META} style={fs(11.5)}>
|
||||
{t('storage.migrate.reclaimable', {
|
||||
objects: String(m.reclaimable.objects),
|
||||
size: formatBytes(m.reclaimable.bytes),
|
||||
from: m.from,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : m.status === 'failed' ? (
|
||||
<p className="m-0 text-danger" style={fs(11.5)}>
|
||||
{t('storage.migrate.failed', { error: m.error ?? '' })}
|
||||
</p>
|
||||
) : (
|
||||
<p className={META} style={fs(11.5)}>{t('storage.migrate.cancelled')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<BackendForm
|
||||
initial={editing.initial}
|
||||
backendNames={backendNames}
|
||||
mirror={editing.mirror}
|
||||
onCommit={commitBackend}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
) : (
|
||||
<button type="button"
|
||||
onClick={() =>
|
||||
setEditing({
|
||||
initial: null,
|
||||
originalName: null,
|
||||
mirror: { candidates: replicaCandidates(rows, null), initialTargets: [] },
|
||||
})
|
||||
}
|
||||
style={{
|
||||
padding: '8px 20px', borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
border: '2px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{t('storage.backends.add')}
|
||||
</button>
|
||||
</SettingRows>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={t('storage.categories.title')} icon={FolderTree}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{STORAGE_CATEGORIES.map((category) => {
|
||||
// The admin state's category record is exhaustive by schema contract.
|
||||
const stateEntry = state.categories[category]!
|
||||
@@ -628,16 +746,22 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
const changed = selectedPrimary !== primaryNameOf(state, draft, stateEntry.backend)
|
||||
const viaMirror = effective[category] !== selectedPrimary
|
||||
return (
|
||||
<div key={category} data-testid={`storage-category-${category}`}>
|
||||
<label className="block text-sm font-medium mb-1 text-content-secondary">
|
||||
{t(`storage.category.${category}`)}
|
||||
<span className="ml-2 px-1.5 py-0.5 rounded border font-mono font-normal text-xs border-edge text-content-faint">
|
||||
<div
|
||||
key={category}
|
||||
data-testid={`storage-category-${category}`}
|
||||
className={`flex min-w-0 flex-col gap-2.5 rounded-[14px] border bg-surface-card p-3.5 ${changed ? 'border-edge' : 'border-edge-faint'}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="m-0 font-semibold text-content" style={fs(13.5, 'body')}>{t(`storage.category.${category}`)}</p>
|
||||
<p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11.5)}>{t(`storage.categoryDesc.${category}`)}</p>
|
||||
</div>
|
||||
<code className="flex-none rounded-full bg-surface-tertiary px-2 py-[2px] font-geist font-semibold text-content-muted" style={fs(10.5)}>
|
||||
{category}
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs mb-1.5 text-content-faint">{t(`storage.categoryDesc.${category}`)}</p>
|
||||
</code>
|
||||
</div>
|
||||
{state.usage?.categories[category] && (
|
||||
<p className="text-xs mb-1.5 text-content-faint">
|
||||
<p className={`${META} font-geist tabular-nums`} style={fs(11.5)}>
|
||||
{t('storage.usage.line', {
|
||||
objects: String(state.usage.categories[category]!.objects),
|
||||
size: formatBytes(state.usage.categories[category]!.bytes),
|
||||
@@ -655,15 +779,14 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
: row.name,
|
||||
}))}
|
||||
size="sm"
|
||||
style={{ maxWidth: 320 }}
|
||||
/>
|
||||
{changed && (
|
||||
<p role="alert" className="text-xs mt-1 text-content-faint">
|
||||
<p role="alert" className="m-0 rounded-[10px] bg-warning-soft px-2.5 py-1.5 leading-snug text-warning" style={fs(11.5)}>
|
||||
{t('storage.categories.reassignWarning')}
|
||||
</p>
|
||||
)}
|
||||
{viaMirror && CACHE_CATEGORIES.includes(category) && (
|
||||
<p role="note" className="text-xs mt-1 text-content-faint">
|
||||
<p role="note" className="m-0 leading-snug text-content-faint" style={fs(11.5)}>
|
||||
{t('storage.mirror.cacheWarning')}
|
||||
</p>
|
||||
)}
|
||||
@@ -673,89 +796,111 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-2xl border border-edge-faint bg-surface-secondary px-4 py-3">
|
||||
<button type="button"
|
||||
onClick={save}
|
||||
disabled={!admin.dirty || admin.saving}
|
||||
style={{
|
||||
padding: '10px 20px', borderRadius: 10, cursor: admin.dirty && !admin.saving ? 'pointer' : 'default',
|
||||
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600,
|
||||
border: '2px solid var(--text-primary)', background: 'var(--bg-hover)', color: 'var(--text-primary)',
|
||||
opacity: admin.dirty && !admin.saving ? 1 : 0.5,
|
||||
}}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{admin.saving ? <Loader2 size={14} strokeWidth={2.2} className="animate-spin" /> : <Check size={14} strokeWidth={2.2} />}
|
||||
{t('storage.save')}
|
||||
</button>
|
||||
{admin.dirty && <span className="text-xs text-content-faint">{t('storage.unsaved')}</span>}
|
||||
{admin.dirty && <StatusPill tone="warning">{t('storage.unsaved')}</StatusPill>}
|
||||
{migrationQueue.length > 0 && (
|
||||
<SettingsHint className="min-w-0 flex-1">
|
||||
{t('storage.migrate.queued', { categories: categoryNames(t, migrationQueue.map((c) => c.category)) })}
|
||||
</SettingsHint>
|
||||
)}
|
||||
</div>
|
||||
{migrationQueue.length > 0 && (
|
||||
<p className="text-xs mt-2 text-content-faint">
|
||||
{t('storage.migrate.queued', { categories: categoryNames(t, migrationQueue.map((c) => c.category)) })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{migratePromptOpen && (
|
||||
<div className="rounded-lg border px-3 py-2 mt-2 border-edge bg-surface-secondary" role="alertdialog">
|
||||
<p className="text-sm text-content">{t('storage.migrate.promptTitle')}</p>
|
||||
{migrationCandidates.map((c) => (
|
||||
<p key={c.category} className="text-xs mt-1 text-content-secondary">
|
||||
{c.objects === null
|
||||
? t('storage.migrate.promptLineUnknown', {
|
||||
category: t(`storage.category.${c.category}`),
|
||||
from: c.from,
|
||||
to: c.to,
|
||||
})
|
||||
: t('storage.migrate.promptLine', {
|
||||
category: t(`storage.category.${c.category}`),
|
||||
objects: String(c.objects),
|
||||
size: formatBytes(c.bytes ?? 0),
|
||||
from: c.from,
|
||||
to: c.to,
|
||||
})}
|
||||
</p>
|
||||
))}
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-labelledby={migrateTitleId}
|
||||
className="mt-3 overflow-hidden rounded-2xl border border-edge-faint bg-surface-card shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b border-edge-faint px-4 py-3" style={{ background: NEUTRAL_TINT }}>
|
||||
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-card text-content-secondary shadow-sm">
|
||||
<ArrowRightLeft size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<p id={migrateTitleId} className="m-0 min-w-0 flex-1 font-bold text-content" style={fs(14, 'body')}>{t('storage.migrate.promptTitle')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 px-4 py-3">
|
||||
{migrationCandidates.map((c) => (
|
||||
<p key={c.category} className="m-0 font-geist tabular-nums text-content-secondary" style={fs(12.5, 'body')}>
|
||||
{c.objects === null
|
||||
? t('storage.migrate.promptLineUnknown', {
|
||||
category: t(`storage.category.${c.category}`),
|
||||
from: c.from,
|
||||
to: c.to,
|
||||
})
|
||||
: t('storage.migrate.promptLine', {
|
||||
category: t(`storage.category.${c.category}`),
|
||||
objects: String(c.objects),
|
||||
size: formatBytes(c.bytes ?? 0),
|
||||
from: c.from,
|
||||
to: c.to,
|
||||
})}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-edge-faint px-4 py-3">
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => void moveAndSave()}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(13, 'body')}
|
||||
onClick={() => setMigratePromptOpen(false)}
|
||||
>
|
||||
{t('storage.migrate.move')}
|
||||
{t('storage.migrate.promptCancel')}
|
||||
</button>
|
||||
<span className="flex-1" />
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(13, 'body')}
|
||||
onClick={() => void routeOnlySave()}
|
||||
>
|
||||
{t('storage.migrate.routeOnly')}
|
||||
</button>
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => setMigratePromptOpen(false)}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
style={fs(13, 'body')}
|
||||
onClick={() => void moveAndSave()}
|
||||
>
|
||||
{t('storage.migrate.promptCancel')}
|
||||
{t('storage.migrate.move')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{admin.saveError && (
|
||||
<div role="alert" className="text-sm mt-2 text-content">
|
||||
<p>{admin.saveError}</p>
|
||||
<div role="alert" className="mt-3 flex flex-wrap items-start gap-3 rounded-2xl border border-edge-faint bg-danger-soft px-4 py-3">
|
||||
<AlertTriangle size={16} strokeWidth={2.2} className="mt-0.5 flex-none text-danger" />
|
||||
<p className="m-0 min-w-0 flex-1 basis-56 break-words leading-normal text-content" style={fs(13, 'body')}>{admin.saveError}</p>
|
||||
{/* A 409 leaves the draft pinned to a version the server has moved
|
||||
past; "save again" can never clear it, so the banner offers the
|
||||
one action that can — see useStorageAdmin.discardDraft. */}
|
||||
{admin.saveConflict && (
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary mt-1"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
className={ROW_BUTTON}
|
||||
style={fs(12, 'body')}
|
||||
onClick={() => void admin.discardDraft()}
|
||||
>
|
||||
<RotateCcw size={13} strokeWidth={2.2} />
|
||||
{t('storage.discardAndReload')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<BackendForm
|
||||
initial={editing.initial}
|
||||
backendNames={backendNames}
|
||||
mirror={editing.mirror}
|
||||
onCommit={commitBackend}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={confirmRemove !== null}
|
||||
onClose={() => setConfirmRemove(null)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useId, useState } from 'react'
|
||||
import { HardDrive } from 'lucide-react'
|
||||
import {
|
||||
STORAGE_BACKEND_TYPES,
|
||||
STORAGE_BACKEND_TYPE_IDS,
|
||||
@@ -8,6 +9,18 @@ import {
|
||||
} from '@trek/shared'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import CustomSelect from '../../shared/CustomSelect'
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogShell,
|
||||
DialogTile,
|
||||
FooterSpacer,
|
||||
NEUTRAL_TINT,
|
||||
fs,
|
||||
} from '../../shared/DialogShell'
|
||||
import { EditorField, GRID_2, INPUT } from '../../shared/dialogParts'
|
||||
|
||||
type FieldValues = Record<string, string | string[]>
|
||||
|
||||
@@ -41,15 +54,17 @@ interface BackendFormProps {
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const LABEL_CLASS = 'block text-sm font-medium mb-1.5 text-content-secondary'
|
||||
const INPUT_CLASS =
|
||||
'mt-1.5 w-full px-3 py-2 border rounded-lg text-sm border-edge bg-surface-card text-content'
|
||||
/** A box of checkbox rows, the white list the settings cards use. */
|
||||
const CHECK_ROWS = 'divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card'
|
||||
const CHECK_ROW = 'flex cursor-pointer items-center gap-3 px-3.5 py-2.5 text-content hover:bg-surface-secondary'
|
||||
const CHECKBOX = 'h-4 w-4 flex-none cursor-pointer accent-accent'
|
||||
|
||||
/**
|
||||
* Renders whatever STORAGE_BACKEND_TYPES declares, by field kind. The raw
|
||||
* `mirror` type is hidden from the type select — mirrors are composed via the
|
||||
* Mirror-targets prop block and synthesized by the panels (the ref-kind
|
||||
* renderers below stay for future registry types).
|
||||
* renderers below stay for future registry types). Drawn as the planner's
|
||||
* editor dialog: head band, fields under eyebrows, the answers in the footer.
|
||||
*/
|
||||
export default function BackendForm({
|
||||
initial,
|
||||
@@ -59,6 +74,8 @@ export default function BackendForm({
|
||||
onCancel,
|
||||
}: BackendFormProps): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const labelId = useId()
|
||||
const fieldId = (key: string) => `${labelId}-${key}`
|
||||
const [type, setType] = useState<StorageBackendTypeId>(initial?.type ?? 'local')
|
||||
const [name, setName] = useState(initial?.name ?? '')
|
||||
const [values, setValues] = useState<FieldValues>(() => valuesOf(initial))
|
||||
@@ -104,8 +121,7 @@ export default function BackendForm({
|
||||
const value = values[field.key]
|
||||
if (field.kind === 'backend-ref') {
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<span className={LABEL_CLASS}>{t(field.labelKey)}</span>
|
||||
<EditorField key={field.key} label={t(field.labelKey)}>
|
||||
<CustomSelect
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(next) => setValue(field.key, String(next))}
|
||||
@@ -113,19 +129,19 @@ export default function BackendForm({
|
||||
placeholder={t(field.labelKey)}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</EditorField>
|
||||
)
|
||||
}
|
||||
if (field.kind === 'backend-ref-list') {
|
||||
const selected = Array.isArray(value) ? value : []
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<span className={LABEL_CLASS}>{t(field.labelKey)}</span>
|
||||
<div className="space-y-1">
|
||||
<EditorField key={field.key} label={t(field.labelKey)} className="col-span-full">
|
||||
<div className={CHECK_ROWS}>
|
||||
{refOptions.map((candidate) => (
|
||||
<label key={candidate} className="flex items-center gap-2 text-sm text-content">
|
||||
<label key={candidate} className={CHECK_ROW} style={fs(13, 'body')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={CHECKBOX}
|
||||
checked={selected.includes(candidate)}
|
||||
onChange={(e) =>
|
||||
setValue(
|
||||
@@ -136,84 +152,112 @@ export default function BackendForm({
|
||||
)
|
||||
}
|
||||
/>
|
||||
{candidate}
|
||||
<span className="min-w-0 truncate font-geist">{candidate}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</EditorField>
|
||||
)
|
||||
}
|
||||
const inputType = field.kind === 'secret' ? 'password' : field.kind === 'number' ? 'number' : 'text'
|
||||
return (
|
||||
<label key={field.key} className={LABEL_CLASS}>
|
||||
{t(field.labelKey)}
|
||||
<EditorField
|
||||
key={field.key}
|
||||
label={t(field.labelKey)}
|
||||
htmlFor={fieldId(field.key)}
|
||||
hint={field.helpKey ? t(field.helpKey) : undefined}
|
||||
>
|
||||
<input
|
||||
id={fieldId(field.key)}
|
||||
type={inputType}
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(e) => setValue(field.key, e.target.value)}
|
||||
placeholder={field.defaultValue !== undefined ? String(field.defaultValue) : ''}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className={INPUT_CLASS}
|
||||
className={`${INPUT} ${inputType === 'number' ? 'tabular-nums' : ''}`}
|
||||
/>
|
||||
{field.helpKey && <span className="block text-xs mt-1 font-normal text-content-faint">{t(field.helpKey)}</span>}
|
||||
</label>
|
||||
</EditorField>
|
||||
)
|
||||
}
|
||||
|
||||
const visibleCandidates = mirror ? mirror.candidates.filter((candidate) => candidate !== name.trim()) : []
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border p-4 space-y-4 border-edge bg-surface-card">
|
||||
<p className="text-sm font-semibold text-content">
|
||||
{initial ? t('storage.form.editTitle') : t('storage.form.addTitle')}
|
||||
</p>
|
||||
|
||||
<label className={LABEL_CLASS}>
|
||||
{t('storage.form.name')}
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className={INPUT_CLASS}
|
||||
<DialogShell
|
||||
onClose={onCancel}
|
||||
labelledBy={labelId}
|
||||
width="editor"
|
||||
align="top"
|
||||
discardGuard={{ name, type, values, targets }}
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={<DialogTile><HardDrive size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={onCancel}
|
||||
title={initial ? t('storage.form.editTitle') : t('storage.form.addTitle')}
|
||||
/>
|
||||
</label>
|
||||
{duplicate && (
|
||||
<p className="text-xs text-content-faint" role="alert">
|
||||
{t('storage.form.duplicateName', { name: name.trim() })}
|
||||
</p>
|
||||
)}
|
||||
}
|
||||
footer={
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={onCancel}>{t('storage.form.cancel')}</DialogButton>
|
||||
<DialogButton variant="primary" onClick={apply} disabled={!canApply}>
|
||||
{t('storage.form.apply')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
>
|
||||
<div className={GRID_2}>
|
||||
<div className="min-w-0">
|
||||
<EditorField label={t('storage.form.name')} htmlFor={fieldId('name')}>
|
||||
<input
|
||||
id={fieldId('name')}
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className={`${INPUT} font-geist`}
|
||||
/>
|
||||
</EditorField>
|
||||
{duplicate && (
|
||||
<p className="m-0 mt-1 text-danger" style={fs(11)} role="alert">
|
||||
{t('storage.form.duplicateName', { name: name.trim() })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className={LABEL_CLASS}>{t('storage.form.type')}</span>
|
||||
<CustomSelect
|
||||
value={type}
|
||||
onChange={(next) => {
|
||||
setType(next as StorageBackendTypeId)
|
||||
setValues({}) // a different type has different fields
|
||||
}}
|
||||
options={STORAGE_BACKEND_TYPE_IDS.filter((id) => id !== 'mirror').map((id) => ({
|
||||
value: id,
|
||||
label: t(`storage.type.${id}`),
|
||||
}))}
|
||||
size="sm"
|
||||
disabled={initial !== null}
|
||||
/>
|
||||
<EditorField label={t('storage.form.type')}>
|
||||
<CustomSelect
|
||||
value={type}
|
||||
onChange={(next) => {
|
||||
setType(next as StorageBackendTypeId)
|
||||
setValues({}) // a different type has different fields
|
||||
}}
|
||||
options={STORAGE_BACKEND_TYPE_IDS.filter((id) => id !== 'mirror').map((id) => ({
|
||||
value: id,
|
||||
label: t(`storage.type.${id}`),
|
||||
}))}
|
||||
size="sm"
|
||||
disabled={initial !== null}
|
||||
/>
|
||||
</EditorField>
|
||||
</div>
|
||||
|
||||
{fields.map(renderField)}
|
||||
{fields.length > 0 && <div className={GRID_2}>{fields.map(renderField)}</div>}
|
||||
|
||||
{mirror && (
|
||||
<div>
|
||||
<span className={LABEL_CLASS}>{t('storage.mirror.targets')}</span>
|
||||
<p className="text-xs mb-1 text-content-faint">{t('storage.mirror.targetsHelp')}</p>
|
||||
<div className="space-y-1">
|
||||
{mirror.candidates
|
||||
.filter((candidate) => candidate !== name.trim())
|
||||
.map((candidate) => (
|
||||
<label key={candidate} className="flex items-center gap-2 text-sm text-content">
|
||||
<DialogSection label={t('storage.mirror.targets')}>
|
||||
<p className="m-0 mb-2.5 leading-normal text-content-faint" style={fs(11.5)}>{t('storage.mirror.targetsHelp')}</p>
|
||||
{visibleCandidates.length > 0 && (
|
||||
<div className={CHECK_ROWS}>
|
||||
{visibleCandidates.map((candidate) => (
|
||||
<label key={candidate} className={CHECK_ROW} style={fs(13, 'body')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={CHECKBOX}
|
||||
checked={targets.includes(candidate)}
|
||||
onChange={(e) =>
|
||||
setTargets(
|
||||
@@ -223,42 +267,18 @@ export default function BackendForm({
|
||||
)
|
||||
}
|
||||
/>
|
||||
{candidate}
|
||||
<span className="min-w-0 truncate font-geist">{candidate}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{targets.length > 0 && (
|
||||
<p className="text-xs mt-1 text-content-faint" role="note">
|
||||
<p className="m-0 mt-2.5 rounded-[12px] bg-warning-soft px-3 py-2 leading-normal text-warning" style={fs(11.5, 'body')} role="note">
|
||||
{t('storage.mirror.latencyNote')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</DialogSection>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button"
|
||||
onClick={apply}
|
||||
disabled={!canApply}
|
||||
style={{
|
||||
padding: '8px 20px', borderRadius: 10, cursor: canApply ? 'pointer' : 'default',
|
||||
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600,
|
||||
border: '2px solid var(--text-primary)', background: 'var(--bg-hover)',
|
||||
color: 'var(--text-primary)', opacity: canApply ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
{t('storage.form.apply')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
padding: '8px 20px', borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
border: '2px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{t('storage.form.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogShell>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user