mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
31859651d7
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.
344 lines
13 KiB
TypeScript
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>
|
|
)
|
|
}
|