mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
94ac7673a5
The settings pages had grown into a mix of styles: folds, dialogs behind rows, full-width rows next to boxed ones, pills for every figure and buttons in four sizes. Every page now uses the same kit. - Cards keep their tinted head band; every part of a card is a rounded block with its own small head (SettingsBlock), and rows sit in rounded boxes. - Nothing folds and nothing hides behind a dialog: the MCP config, OAuth scopes, mail how-to, AI privacy note and addon sub-options are all shown. - Each page is split into two columns balanced by length (SettingsColumns). - Long explanations moved into an info mark next to the label (HintMark). - One button size everywhere (text-control); tab names are one word. - Admin: the old Settings tab is split into Security and Services (?tab=settings still opens Security, and the phone keeps its single Settings section for both); the Integration group is gone, live flights and mail import sit next to the addons they configure. - Addons: one card per type with rows, no search, filter or fold. - Quota, TREK API facts, mail import status and Google's daily limit are compact lines and strips instead of tiles and pills. - Language is a select; routing has its own card under the defaults; storage saves from the foot of its categories card. - Unused keys, FoldSummary and the page-width wrapper are removed.
298 lines
13 KiB
TypeScript
298 lines
13 KiB
TypeScript
/**
|
|
* Offline settings tab (#1135) — controls for:
|
|
* - Offline mode: a force-offline switch that first downloads everything, then
|
|
* routes the app to the cache + mutation queue.
|
|
* - Prepare for offline: an awaited, progress-tracked full download.
|
|
* - What to store: a map-tiles toggle plus a per-trip on/off.
|
|
* - Sync conflicts: a keep-mine / keep-theirs resolver and a default strategy.
|
|
* - Cache stats + clear.
|
|
*
|
|
* All of the logic lives in `useOfflineSettings`, shared with the phone twin
|
|
* `MSettingsOffline`; this file is the desktop markup over it.
|
|
*/
|
|
import React, { useState } from 'react'
|
|
import { RefreshCw, Trash2, Database, CloudOff, Download, Check, GitMerge, Map as MapIcon, AlertTriangle } from 'lucide-react'
|
|
import Section from './Section'
|
|
import ToggleSwitch from './ToggleSwitch'
|
|
import { SettingRow, SettingRows, SettingsHint, StatusPill, SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SettingsColumns } from './settingsKit'
|
|
import { useOfflineSettings, offlineNoticeKey, isOfflineNoticeWarning, offlineProgressLabel, offlineRowMeta } from './useOfflineSettings'
|
|
import { useTranslation } from '../../i18n'
|
|
import CustomSelect from '../shared/CustomSelect'
|
|
import ConfirmDialog from '../shared/ConfirmDialog'
|
|
import { fs } from '../shared/DialogShell'
|
|
import type { ConflictStrategy } from '../../sync/offlinePrefs'
|
|
import type { QueuedMutation } from '../../db/offlineDb'
|
|
|
|
const EYEBROW = 'm-0 mb-2 font-geist font-bold uppercase tracking-[.08em] text-content-faint'
|
|
|
|
function conflictName(m: QueuedMutation): string {
|
|
const body = (m.body ?? {}) as { name?: unknown }
|
|
const server = (m.conflictServer ?? {}) as { name?: unknown }
|
|
return (typeof body.name === 'string' && body.name)
|
|
|| (typeof server.name === 'string' && server.name)
|
|
|| `#${m.entityId ?? ''}`
|
|
}
|
|
|
|
export default function OfflineTab(): React.ReactElement {
|
|
const { t, locale } = useTranslation()
|
|
const {
|
|
offline, forced,
|
|
rows, allTrips, pendingCount, failedCount, conflicts,
|
|
syncing, clearing, loading, preparing, progress, notice, prefs, canClear,
|
|
runPrepare, handleToggleForce, handleResync, handleClear,
|
|
handleToggleTiles, tripStorageState, handleToggleTrip, resolveConflict,
|
|
handleConflictStrategy, retryFailed, discardFailed,
|
|
} = useOfflineSettings()
|
|
const [confirmClear, setConfirmClear] = useState(false)
|
|
|
|
const progressLabel = offlineProgressLabel(progress, t)
|
|
|
|
return (
|
|
<div>
|
|
{/* Two columns by length: what the mode does and what is cached on the
|
|
left, the per-trip choice of what to keep on the right. */}
|
|
<SettingsColumns
|
|
left={<>
|
|
{/* Offline mode + prepare */}
|
|
<Section title={t('settings.offline.mode.title')} icon={CloudOff}>
|
|
<SettingRows>
|
|
<SettingRow
|
|
label={t('settings.offline.mode.force')}
|
|
hint={t('settings.offline.mode.forceHint')}
|
|
control={<ToggleSwitch on={forced} onToggle={handleToggleForce} label={t('settings.offline.mode.force')} />}
|
|
>
|
|
{forced && (
|
|
<p className="m-0 rounded-[10px] bg-warning-soft px-3 py-2 text-warning" style={fs(12, 'body')}>
|
|
{t('settings.offline.mode.active')}
|
|
</p>
|
|
)}
|
|
</SettingRow>
|
|
|
|
<SettingRow label={t('settings.offline.prepare.title')} hint={t('settings.offline.prepare.hint')} stacked>
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<button type="button"
|
|
onClick={runPrepare}
|
|
disabled={preparing || offline}
|
|
className={SETTINGS_BUTTON}
|
|
>
|
|
{preparing
|
|
? <RefreshCw size={14} className="animate-spin" />
|
|
: <Download size={14} />}
|
|
{preparing ? t('settings.offline.prepare.running') : t('settings.offline.prepare.button')}
|
|
</button>
|
|
<button type="button"
|
|
onClick={handleResync}
|
|
disabled={syncing || offline}
|
|
className={SETTINGS_BUTTON}
|
|
>
|
|
<RefreshCw size={14} className={syncing ? 'animate-spin' : undefined} />
|
|
{syncing ? t('settings.offline.resyncing') : t('settings.offline.resync')}
|
|
</button>
|
|
</div>
|
|
{preparing && progress && (
|
|
<div>
|
|
<div className="h-1.5 overflow-hidden rounded-full bg-surface-tertiary">
|
|
<div
|
|
className="h-full rounded-full bg-accent transition-[width] duration-200"
|
|
style={{ width: `${progress.total ? Math.round((progress.current / progress.total) * 100) : 100}%` }}
|
|
/>
|
|
</div>
|
|
<div className="mt-1.5 font-geist tabular-nums text-content-muted" style={fs(11)}>
|
|
{progressLabel}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{!preparing && notice && notice.kind !== 'load-failed' && (
|
|
<div
|
|
className={`flex items-center gap-1.5 ${isOfflineNoticeWarning(notice) ? 'text-warning' : 'text-success'}`}
|
|
style={fs(12, 'body')}
|
|
>
|
|
{isOfflineNoticeWarning(notice) ? <AlertTriangle size={14} className="flex-none" /> : <Check size={14} className="flex-none" />}
|
|
{t(offlineNoticeKey(notice), notice.kind === 'stored' ? { count: notice.trips } : undefined)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</SettingRow>
|
|
</SettingRows>
|
|
</Section>
|
|
|
|
{/* Conflicts (only when there are any) */}
|
|
{conflicts.length > 0 && (
|
|
<Section
|
|
title={t('settings.offline.conflicts.title')}
|
|
icon={GitMerge}
|
|
badge={<StatusPill tone="danger">{conflicts.length}</StatusPill>}
|
|
>
|
|
<SettingsHint>{t('settings.offline.conflicts.hint')}</SettingsHint>
|
|
<SettingRows>
|
|
{conflicts.map(c => (
|
|
<div key={c.id} className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-3.5 py-3">
|
|
<span className="min-w-0 flex-1 basis-48 truncate font-medium text-content" style={fs(13, 'body')}>
|
|
{t('settings.offline.conflicts.item', { name: conflictName(c) })}
|
|
</span>
|
|
<div className="flex flex-none gap-2">
|
|
<button type="button" onClick={() => resolveConflict(c.id, true)} className={SETTINGS_BUTTON}>
|
|
{t('settings.offline.conflicts.keepMine')}
|
|
</button>
|
|
<button type="button" onClick={() => resolveConflict(c.id, false)} className={SETTINGS_BUTTON}>
|
|
{t('settings.offline.conflicts.keepServer')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</SettingRows>
|
|
<SettingRows>
|
|
<SettingRow
|
|
label={t('settings.offline.conflicts.strategyTitle')}
|
|
htmlFor="offline-conflict-strategy"
|
|
control={
|
|
<CustomSelect
|
|
id="offline-conflict-strategy"
|
|
value={prefs.conflictStrategy}
|
|
onChange={v => handleConflictStrategy(v as ConflictStrategy)}
|
|
options={[
|
|
{ value: 'ask', label: t('settings.offline.conflicts.strategy.ask') },
|
|
{ value: 'mine', label: t('settings.offline.conflicts.strategy.mine') },
|
|
{ value: 'server', label: t('settings.offline.conflicts.strategy.server') },
|
|
]}
|
|
menuFit="content"
|
|
style={{ minWidth: 240 }}
|
|
/>
|
|
}
|
|
/>
|
|
</SettingRows>
|
|
</Section>
|
|
)}
|
|
|
|
{/* Cache stats + list + clear */}
|
|
<Section
|
|
title={t('settings.offline.cache.title')}
|
|
icon={Database}
|
|
action={
|
|
<button type="button"
|
|
onClick={() => setConfirmClear(true)}
|
|
disabled={clearing || !canClear}
|
|
className={SETTINGS_BUTTON_DANGER}
|
|
>
|
|
<Trash2 size={14} />
|
|
{t('settings.offline.clear')}
|
|
</button>
|
|
}
|
|
>
|
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
<Stat label={t('settings.offline.stats.trips')} value={rows.length} />
|
|
<Stat label={t('settings.offline.stats.pending')} value={pendingCount} />
|
|
{conflicts.length > 0 && <Stat label={t('settings.offline.stats.conflicts')} value={conflicts.length} danger />}
|
|
{failedCount > 0 && <Stat label={t('settings.offline.stats.failed')} value={failedCount} danger />}
|
|
</div>
|
|
|
|
{failedCount > 0 && (
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<SettingsHint>{t('settings.offline.failed.hint')}</SettingsHint>
|
|
<div className="flex gap-2">
|
|
<button type="button" onClick={() => void retryFailed()} disabled={offline} className={SETTINGS_BUTTON}>
|
|
<RefreshCw size={14} />
|
|
{t('settings.offline.failed.retry')}
|
|
</button>
|
|
<button type="button" onClick={() => void discardFailed()} className={SETTINGS_BUTTON_DANGER}>
|
|
<Trash2 size={14} />
|
|
{t('settings.offline.failed.discard')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<div className="flex items-center gap-2 text-content-faint" style={fs(12, 'body')}>
|
|
<RefreshCw size={13} className="animate-spin" />
|
|
<span>{t('settings.offline.loading')}</span>
|
|
</div>
|
|
) : rows.length === 0 ? (
|
|
<SettingsHint>
|
|
{t(notice?.kind === 'load-failed' ? 'settings.offline.notice.loadFailed' : 'settings.offline.empty')}
|
|
</SettingsHint>
|
|
) : (
|
|
<SettingRows>
|
|
{rows.map(({ trip, meta, placeCount, fileCount }) => (
|
|
<div key={trip.id} className="flex items-center justify-between gap-4 px-3.5 py-3">
|
|
<div className="flex min-w-0 flex-col gap-0.5">
|
|
<span className="truncate font-semibold text-content" style={fs(13, 'body')}>
|
|
{trip.title}
|
|
</span>
|
|
<span className="truncate font-geist tabular-nums text-content-faint" style={fs(11.5)}>
|
|
{offlineRowMeta({ trip, placeCount, fileCount }, t, locale)}
|
|
</span>
|
|
</div>
|
|
<span className="flex-none font-geist tabular-nums text-content-muted" style={fs(11.5)}>
|
|
{meta.lastSyncedAt
|
|
? new Date(meta.lastSyncedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
|
: '—'}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</SettingRows>
|
|
)}
|
|
</Section>
|
|
</>}
|
|
right={<>
|
|
{/* What to store offline */}
|
|
<Section title={t('settings.offline.storage.title')} icon={MapIcon}>
|
|
<SettingRows>
|
|
<SettingRow
|
|
label={t('settings.offline.storage.tiles')}
|
|
hint={t('settings.offline.storage.tilesHint')}
|
|
control={<ToggleSwitch on={prefs.cacheTiles} onToggle={handleToggleTiles} label={t('settings.offline.storage.tiles')} />}
|
|
/>
|
|
</SettingRows>
|
|
{allTrips.length > 0 && (
|
|
<div>
|
|
<p className={EYEBROW} style={fs(10)}>{t('settings.offline.storage.tripsTitle')}</p>
|
|
<SettingRows>
|
|
{allTrips.map((trip) => {
|
|
const { on, dateEligible } = tripStorageState(trip)
|
|
return (
|
|
<div key={trip.id} className="flex items-center justify-between gap-4 px-3.5 py-3">
|
|
<div className="min-w-0">
|
|
<div className="truncate font-medium text-content" style={fs(13, 'body')}>
|
|
{trip.title}
|
|
</div>
|
|
<div className={on ? 'text-success' : 'text-content-faint'} style={fs(11.5)}>
|
|
{on ? t('settings.offline.storage.tripOn') : t('settings.offline.storage.tripOff')}
|
|
{!dateEligible && `. ${t('settings.offline.storage.tripFinished')}`}
|
|
</div>
|
|
</div>
|
|
<ToggleSwitch on={on} onToggle={() => handleToggleTrip(trip)} label={trip.title} />
|
|
</div>
|
|
)
|
|
})}
|
|
</SettingRows>
|
|
</div>
|
|
)}
|
|
</Section>
|
|
</>}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
isOpen={confirmClear}
|
|
onClose={() => setConfirmClear(false)}
|
|
onConfirm={() => { void handleClear() }}
|
|
title={t('settings.offline.clear')}
|
|
message={t('settings.offline.clearConfirm')}
|
|
confirmLabel={t('settings.offline.clear')}
|
|
danger
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** One counter tile: the number big, what it counts under it. */
|
|
function Stat({ label, value, danger }: { label: string; value: number; danger?: boolean }) {
|
|
return (
|
|
<div className="min-w-0 rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3">
|
|
<div className={`font-geist font-bold tabular-nums ${danger ? 'text-danger' : 'text-content'}`} style={fs(20, 'subtitle')}>{value}</div>
|
|
<div className="truncate text-content-faint" style={fs(11.5)}>{label}</div>
|
|
</div>
|
|
)
|
|
}
|