mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
feat(settings,admin): every tab and popup in the planner's design
This commit is contained in:
@@ -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