Files
TREK/client/src/components/Admin/BackupPanel.tsx
T
Maurice 31859651d7 feat(help): rewrite the help center for v5 with a Tours section, new pictures in every language, and fix the tour planner bugs it surfaced
Every help screen checked against the v5 redesign. Tours is documented for
the first time, as a screen of its own with twelve screens under it, and
notifications get help of their own. All pictures are taken again on a
realistic demo trip, with short loops where a still cannot show the motion.
The English is plain and every language is written naturally rather than
word for word.

The switcher shows two levels at most, a screen's overview lists its
sub-screens as cards above its guides, each wiki page is linked once, and
the help sidebar is wider.

Fixes found along the way: Save to Collection keeps a tour's route, a track
dropped on the places column opens the Tours import, tour Share on desktop,
the unsaved tour draft lives on the account, a route click no longer adds
an end point, Trip places leaves the trip's tours out, Escape closes a
stage's day menu, confirms before destructive deletes, DWD warnings sharing
one id kept apart, a booking's date moves with it, and smaller fixes across
notifications, bookings, the day plan, stays, Atlas, Collections, Vacay and
the admin screens.
2026-10-22 22:29:35 +02:00

344 lines
13 KiB
TypeScript

import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, Loader2 } from 'lucide-react'
import { useTranslation } from '../../i18n'
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 { DAYS_OF_MONTH, DAYS_OF_WEEK, HOURS, INTERVAL_OPTIONS, KEEP_OPTIONS, useBackupAdmin } from './useBackupAdmin'
/** 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'
export default function BackupPanel() {
const {
backups, isLoading, isCreating, restoringFile, isUploading, autoSettings, autoSettingsSaving, autoSettingsDirty,
serverTimezone, restoreConfirm, setRestoreConfirm, deleteTarget, setDeleteTarget, fileInputRef, is12h,
loadBackups, handleCreate, handleDownload, handleRestore, handleUploadRestore, executeRestore, handleDelete,
executeDelete, handleAutoSettingsChange, handleSaveAutoSettings, formatSize, formatDate, isAuto,
} = useBackupAdmin()
const { t } = useTranslation()
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>
{/* 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>
<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>
</>
)
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="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 className="flex flex-none items-center gap-1.5">
<button type="button"
onClick={() => handleDownload(backup.filename)}
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 */}
<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 && (
<>
<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' && (
<SettingRow
label={t('backup.auto.hour')}
hint={serverTimezone ? t('backup.auto.hourHintTz', { format: is12h ? '12h' : '24h', timezone: serverTimezone }) : t('backup.auto.hourHint', { format: is12h ? '12h' : '24h' })}
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' && (
<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' && (
<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 */}
<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">
<button type="button"
onClick={handleSaveAutoSettings}
disabled={autoSettingsSaving || !autoSettingsDirty}
className={SETTINGS_BUTTON_PRIMARY}
style={fs(13, 'body')}
>
{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>
)
}