mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-30 18:46:00 +00:00
feat(offline): Settings -> Offline controls and a status banner
The Offline tab gains a force-offline switch, a prepare-for-offline download with progress, per-trip and map-tile storage toggles, and a conflict resolver with a default strategy. The floating status pill now reflects forced-offline and unresolved conflicts.
This commit is contained in:
@@ -7,16 +7,20 @@ vi.mock('../../sync/mutationQueue', () => ({
|
||||
mutationQueue: {
|
||||
pendingCount: vi.fn(),
|
||||
failedCount: vi.fn(),
|
||||
conflictCount: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { mutationQueue } from '../../sync/mutationQueue'
|
||||
import { _resetNetworkMode } from '../../sync/networkMode'
|
||||
|
||||
const pendingCount = mutationQueue.pendingCount as ReturnType<typeof vi.fn>
|
||||
const failedCount = mutationQueue.failedCount as ReturnType<typeof vi.fn>
|
||||
const conflictCount = mutationQueue.conflictCount as ReturnType<typeof vi.fn>
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
_resetNetworkMode()
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true })
|
||||
})
|
||||
|
||||
@@ -24,15 +28,27 @@ describe('OfflineBanner (B3 surface)', () => {
|
||||
it('shows the failed pill when failedCount > 0 while online', async () => {
|
||||
pendingCount.mockResolvedValue(0)
|
||||
failedCount.mockResolvedValue(2)
|
||||
conflictCount.mockResolvedValue(0)
|
||||
|
||||
render(<OfflineBanner />)
|
||||
|
||||
expect(await screen.findByText(/2 changes failed to sync/i)).toBeInTheDocument()
|
||||
expect(await screen.findByText(/failed to sync: 2/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('stays hidden when online with nothing pending or failed', async () => {
|
||||
it('shows the conflict pill when conflicts exist while online', async () => {
|
||||
pendingCount.mockResolvedValue(0)
|
||||
failedCount.mockResolvedValue(0)
|
||||
conflictCount.mockResolvedValue(3)
|
||||
|
||||
render(<OfflineBanner />)
|
||||
|
||||
expect(await screen.findByText(/conflicts: 3/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('stays hidden when online with nothing pending, failed or conflicting', async () => {
|
||||
pendingCount.mockResolvedValue(0)
|
||||
failedCount.mockResolvedValue(0)
|
||||
conflictCount.mockResolvedValue(0)
|
||||
|
||||
const { container } = render(<OfflineBanner />)
|
||||
// Give the async poll a tick to resolve.
|
||||
|
||||
@@ -1,49 +1,44 @@
|
||||
/**
|
||||
* OfflineBanner — connectivity + sync state indicator.
|
||||
*
|
||||
* States:
|
||||
* N failed → red pill "N changes failed to sync" (takes priority)
|
||||
* offline + N queued → amber pill "Offline · N queued"
|
||||
* offline + 0 queued → amber pill "Offline"
|
||||
* online + N pending → blue pill "Syncing N…"
|
||||
* online + 0 pending → hidden
|
||||
* Priority (highest first):
|
||||
* N failed → red pill "Failed to sync: N" (changes were dropped)
|
||||
* N conflicts → purple pill "Conflicts: N" (need resolving)
|
||||
* offline → amber pill "Offline" / "Offline mode" / "Offline · N queued"
|
||||
* online + N → blue pill "Syncing N…"
|
||||
* online + 0 → hidden
|
||||
*
|
||||
* Rendered as a small floating pill anchored to the bottom-center of the
|
||||
* viewport so it never competes with top navigation or sticky modal
|
||||
* headers. On mobile it hovers just above the bottom tab bar.
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { WifiOff, RefreshCw, AlertTriangle } from 'lucide-react'
|
||||
import { WifiOff, RefreshCw, AlertTriangle, GitMerge } from 'lucide-react'
|
||||
import { mutationQueue } from '../../sync/mutationQueue'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
const POLL_MS = 3_000
|
||||
|
||||
export default function OfflineBanner(): React.ReactElement | null {
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||
const { t } = useTranslation()
|
||||
const { offline, forced } = useNetworkMode()
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
const [failedCount, setFailedCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const onOnline = () => setIsOnline(true)
|
||||
const onOffline = () => setIsOnline(false)
|
||||
window.addEventListener('online', onOnline)
|
||||
window.addEventListener('offline', onOffline)
|
||||
return () => {
|
||||
window.removeEventListener('online', onOnline)
|
||||
window.removeEventListener('offline', onOffline)
|
||||
}
|
||||
}, [])
|
||||
const [conflictCount, setConflictCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function poll() {
|
||||
const [n, failed] = await Promise.all([
|
||||
const [n, failed, conflicts] = await Promise.all([
|
||||
mutationQueue.pendingCount(),
|
||||
mutationQueue.failedCount(),
|
||||
mutationQueue.conflictCount(),
|
||||
])
|
||||
if (!cancelled) {
|
||||
setPendingCount(n)
|
||||
setFailedCount(failed)
|
||||
setConflictCount(conflicts)
|
||||
}
|
||||
}
|
||||
poll()
|
||||
@@ -51,22 +46,34 @@ export default function OfflineBanner(): React.ReactElement | null {
|
||||
return () => { cancelled = true; clearInterval(id) }
|
||||
}, [])
|
||||
|
||||
const hidden = isOnline && pendingCount === 0 && failedCount === 0
|
||||
const hidden = !offline && pendingCount === 0 && failedCount === 0 && conflictCount === 0
|
||||
if (hidden) return null
|
||||
|
||||
const offline = !isOnline
|
||||
// Failed mutations are the most important signal — they mean data was dropped.
|
||||
// Conflicts come next (they still need a decision), then plain offline status.
|
||||
const failed = failedCount > 0
|
||||
const bg = failed ? '#b91c1c' : offline ? '#92400e' : '#1e40af'
|
||||
const text = '#fff'
|
||||
const conflict = !failed && conflictCount > 0
|
||||
const bg = failed ? '#b91c1c' : conflict ? '#6d28d9' : offline ? '#92400e' : '#1e40af'
|
||||
|
||||
const label = failed
|
||||
? `${failedCount} change${failedCount !== 1 ? 's' : ''} failed to sync`
|
||||
: offline
|
||||
? pendingCount > 0
|
||||
? `Offline · ${pendingCount} queued`
|
||||
: 'Offline'
|
||||
: `Syncing ${pendingCount}…`
|
||||
let label: string
|
||||
let icon: React.ReactElement
|
||||
if (failed) {
|
||||
label = t('settings.offline.banner.failed', { count: failedCount })
|
||||
icon = <AlertTriangle size={12} />
|
||||
} else if (conflict) {
|
||||
label = t('settings.offline.banner.conflicts', { count: conflictCount })
|
||||
icon = <GitMerge size={12} />
|
||||
} else if (offline) {
|
||||
label = pendingCount > 0
|
||||
? t('settings.offline.banner.queued', { count: pendingCount })
|
||||
: forced
|
||||
? t('settings.offline.banner.forced')
|
||||
: t('settings.offline.banner.offline')
|
||||
icon = <WifiOff size={12} />
|
||||
} else {
|
||||
label = t('settings.offline.banner.syncing', { count: pendingCount })
|
||||
icon = <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -81,7 +88,7 @@ export default function OfflineBanner(): React.ReactElement | null {
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 9999,
|
||||
background: bg,
|
||||
color: text,
|
||||
color: '#fff',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
@@ -94,12 +101,7 @@ export default function OfflineBanner(): React.ReactElement | null {
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{failed
|
||||
? <AlertTriangle size={12} />
|
||||
: offline
|
||||
? <WifiOff size={12} />
|
||||
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
}
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
/**
|
||||
* Offline settings tab — shows cached trips, storage info, and controls
|
||||
* to re-sync or clear the offline cache.
|
||||
* 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.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Wifi, RefreshCw, Trash2, Database } from 'lucide-react'
|
||||
import { RefreshCw, Trash2, Database, CloudOff, Download, Check, GitMerge, Map as MapIcon } from 'lucide-react'
|
||||
import Section from './Section'
|
||||
import { offlineDb, clearAll } from '../../db/offlineDb'
|
||||
import { tripSyncManager } from '../../sync/tripSyncManager'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import { offlineDb, clearAll, clearTripData } from '../../db/offlineDb'
|
||||
import { tripsApi } from '../../api/client'
|
||||
import { tripSyncManager, type PrepareProgress } from '../../sync/tripSyncManager'
|
||||
import { mutationQueue } from '../../sync/mutationQueue'
|
||||
import type { SyncMeta } from '../../db/offlineDb'
|
||||
import { clearTileCache } from '../../sync/tilePrefetcher'
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode'
|
||||
import {
|
||||
getOfflinePrefs, setCacheTiles, setConflictStrategy,
|
||||
isTripOfflineEnabled, setTripOfflineEnabled, onOfflinePrefsChange,
|
||||
type ConflictStrategy,
|
||||
} from '../../sync/offlinePrefs'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { SyncMeta, QueuedMutation } from '../../db/offlineDb'
|
||||
import type { Trip } from '../../types'
|
||||
|
||||
interface CachedTripRow {
|
||||
@@ -18,24 +34,43 @@ interface CachedTripRow {
|
||||
fileCount: number
|
||||
}
|
||||
|
||||
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 } = useTranslation()
|
||||
const { offline, forced, setForced } = useNetworkMode()
|
||||
const [rows, setRows] = useState<CachedTripRow[]>([])
|
||||
const [allTrips, setAllTrips] = useState<Trip[]>([])
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
const [failedCount, setFailedCount] = useState(0)
|
||||
const [conflicts, setConflicts] = useState<QueuedMutation[]>([])
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [clearing, setClearing] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [preparing, setPreparing] = useState(false)
|
||||
const [progress, setProgress] = useState<PrepareProgress | null>(null)
|
||||
const [prefs, setPrefs] = useState(getOfflinePrefs())
|
||||
|
||||
useEffect(() => onOfflinePrefsChange(() => setPrefs(getOfflinePrefs())), [])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [metas, pending, failed] = await Promise.all([
|
||||
const [metas, pending, failed, conflictList] = await Promise.all([
|
||||
offlineDb.syncMeta.toArray(),
|
||||
mutationQueue.pendingCount(),
|
||||
mutationQueue.failedCount(),
|
||||
mutationQueue.conflicts(),
|
||||
])
|
||||
setPendingCount(pending)
|
||||
setFailedCount(failed)
|
||||
setConflicts(conflictList)
|
||||
|
||||
const result: CachedTripRow[] = []
|
||||
for (const meta of metas) {
|
||||
@@ -49,6 +84,18 @@ export default function OfflineTab(): React.ReactElement {
|
||||
}
|
||||
result.sort((a, b) => (a.trip.start_date ?? '').localeCompare(b.trip.start_date ?? ''))
|
||||
setRows(result)
|
||||
|
||||
// The per-trip storage toggles are driven by the FULL trip list, not just
|
||||
// the cached ones, so a trip turned off stays visible and re-enableable.
|
||||
try {
|
||||
const trips = isEffectivelyOffline()
|
||||
? await offlineDb.trips.toArray()
|
||||
: await tripsApi.list().then(r => (r as { trips: Trip[] }).trips).catch(() => offlineDb.trips.toArray())
|
||||
trips.sort((a, b) => (a.start_date ?? '').localeCompare(b.start_date ?? ''))
|
||||
setAllTrips(trips)
|
||||
} catch {
|
||||
setAllTrips([])
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -56,6 +103,29 @@ export default function OfflineTab(): React.ReactElement {
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const runPrepare = useCallback(async () => {
|
||||
setPreparing(true)
|
||||
setProgress(null)
|
||||
try {
|
||||
await tripSyncManager.prepareForOffline(p => setProgress(p))
|
||||
await load()
|
||||
} finally {
|
||||
setPreparing(false)
|
||||
}
|
||||
}, [load])
|
||||
|
||||
async function handleToggleForce() {
|
||||
if (!forced) {
|
||||
// Turning offline mode on: download everything first (while still online),
|
||||
// then engage so the app has all it needs before the network drops.
|
||||
if (navigator.onLine) await runPrepare()
|
||||
setForced(true)
|
||||
} else {
|
||||
// Back online: lifting the switch flushes the queue + re-syncs (syncTriggers).
|
||||
setForced(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResync() {
|
||||
setSyncing(true)
|
||||
try {
|
||||
@@ -67,7 +137,7 @@ export default function OfflineTab(): React.ReactElement {
|
||||
}
|
||||
|
||||
async function handleClear() {
|
||||
if (!window.confirm('Clear all offline trip data? You can re-sync anytime while online.')) return
|
||||
if (!window.confirm(t('settings.offline.clearConfirm'))) return
|
||||
setClearing(true)
|
||||
try {
|
||||
await clearAll()
|
||||
@@ -77,104 +147,272 @@ export default function OfflineTab(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleTiles() {
|
||||
const next = !prefs.cacheTiles
|
||||
setCacheTiles(next)
|
||||
// Turning tiles off reclaims the bulk tile storage straight away.
|
||||
if (!next) await clearTileCache()
|
||||
}
|
||||
|
||||
async function handleToggleTrip(tripId: number) {
|
||||
const next = !isTripOfflineEnabled(tripId)
|
||||
setTripOfflineEnabled(tripId, next)
|
||||
if (!next) {
|
||||
await clearTripData(tripId)
|
||||
await load()
|
||||
} else if (navigator.onLine) {
|
||||
tripSyncManager.syncAll().then(load).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveConflict(id: string, keepMine: boolean) {
|
||||
if (keepMine) await mutationQueue.resolveKeepMine(id)
|
||||
else await mutationQueue.resolveKeepServer(id)
|
||||
await load()
|
||||
}
|
||||
|
||||
const formatDate = (d: string | null | undefined) =>
|
||||
d ? new Date(d).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—'
|
||||
|
||||
const progressLabel = progress
|
||||
? `${t(`settings.offline.prepare.phase.${progress.phase === 'done' ? 'trips' : progress.phase}`)} · ${progress.current}/${progress.total}`
|
||||
: ''
|
||||
|
||||
return (
|
||||
<Section title="Offline Cache" icon={Database}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<div>
|
||||
{/* Offline mode + prepare */}
|
||||
<Section title={t('settings.offline.mode.title')} icon={CloudOff}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<Row
|
||||
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="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', margin: 0 }}>
|
||||
{t('settings.offline.mode.active')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Stats row */}
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Stat label="Cached trips" value={rows.length} />
|
||||
<Stat label="Pending changes" value={pendingCount} />
|
||||
{failedCount > 0 && <Stat label="Failed changes" value={failedCount} danger />}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={handleResync}
|
||||
disabled={syncing || !navigator.onLine}
|
||||
className="border border-edge bg-surface-secondary text-content"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px',
|
||||
borderRadius: 8,
|
||||
cursor: syncing || !navigator.onLine ? 'not-allowed' : 'pointer',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, opacity: !navigator.onLine ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} style={syncing ? { animation: 'spin 1s linear infinite' } : {}} />
|
||||
{syncing ? 'Syncing…' : 'Re-sync now'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleClear}
|
||||
disabled={clearing || rows.length === 0}
|
||||
className="border border-edge bg-surface-secondary text-[#ef4444]"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px',
|
||||
borderRadius: 8,
|
||||
cursor: clearing || rows.length === 0 ? 'not-allowed' : 'pointer',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, opacity: rows.length === 0 ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Clear cache
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cached trip list */}
|
||||
{loading ? (
|
||||
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>Loading…</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
|
||||
No trips cached yet. Connect to internet to sync.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{rows.map(({ trip, meta, placeCount, fileCount }) => (
|
||||
<div
|
||||
key={trip.id}
|
||||
className="border border-edge bg-surface-secondary"
|
||||
style={{
|
||||
padding: '10px 14px', borderRadius: 8,
|
||||
display: 'flex', flexDirection: 'column', gap: 2,
|
||||
}}
|
||||
<div style={{ borderTop: '1px solid var(--border-secondary, #e5e7eb)', paddingTop: 16 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 'calc(14px * var(--fs-scale-body, 1))', marginBottom: 4 }} className="text-content">
|
||||
{t('settings.offline.prepare.title')}
|
||||
</div>
|
||||
<p className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 0, marginBottom: 12 }}>
|
||||
{t('settings.offline.prepare.hint')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={runPrepare}
|
||||
disabled={preparing || offline}
|
||||
className="border border-edge bg-surface-secondary text-content"
|
||||
style={btnStyle(preparing || offline)}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span className="text-content" style={{ fontWeight: 600, fontSize: 'calc(14px * var(--fs-scale-body, 1))' }}>
|
||||
{trip.title}
|
||||
</span>
|
||||
<span className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>
|
||||
<Wifi size={10} style={{ display: 'inline', marginRight: 3 }} />
|
||||
{meta.lastSyncedAt
|
||||
? new Date(meta.lastSyncedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||
: '—'}
|
||||
</span>
|
||||
{preparing
|
||||
? <RefreshCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
: <Download size={14} />}
|
||||
{preparing ? t('settings.offline.prepare.running') : t('settings.offline.prepare.button')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResync}
|
||||
disabled={syncing || offline}
|
||||
className="border border-edge bg-surface-secondary text-content"
|
||||
style={btnStyle(syncing || offline)}
|
||||
>
|
||||
<RefreshCw size={14} style={syncing ? { animation: 'spin 1s linear infinite' } : {}} />
|
||||
{syncing ? t('settings.offline.resyncing') : t('settings.offline.resync')}
|
||||
</button>
|
||||
</div>
|
||||
{preparing && progress && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div style={{ height: 6, borderRadius: 3, overflow: 'hidden', background: 'var(--border-primary, #e5e7eb)' }}>
|
||||
<div style={{
|
||||
height: '100%', borderRadius: 3, background: 'var(--accent, #4F46E5)',
|
||||
width: `${progress.total ? Math.round((progress.current / progress.total) * 100) : 100}%`,
|
||||
transition: 'width 0.2s',
|
||||
}} />
|
||||
</div>
|
||||
<span className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
|
||||
{formatDate(trip.start_date)} – {formatDate(trip.end_date)}
|
||||
{' · '}
|
||||
{placeCount} place{placeCount !== 1 ? 's' : ''}
|
||||
{' · '}
|
||||
{fileCount} file{fileCount !== 1 ? 's' : ''}
|
||||
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 4 }}>
|
||||
{progressLabel}{progress.label ? ` · ${progress.label}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!preparing && progress?.phase === 'done' && (
|
||||
<div className="text-content-muted" style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 10, color: '#10b981' }}>
|
||||
<Check size={14} /> {t('settings.offline.prepare.done')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Conflicts (only when there are any) */}
|
||||
{conflicts.length > 0 && (
|
||||
<Section title={t('settings.offline.conflicts.title')} icon={GitMerge}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', margin: 0 }}>
|
||||
{t('settings.offline.conflicts.hint')}
|
||||
</p>
|
||||
{conflicts.map(c => (
|
||||
<div key={c.id} className="border border-edge bg-surface-secondary" style={{ padding: '10px 14px', borderRadius: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<span className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500 }}>
|
||||
{t('settings.offline.conflicts.item', { name: conflictName(c) })}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={() => resolveConflict(c.id, true)} className="border border-edge bg-surface-card text-content" style={smallBtnStyle()}>
|
||||
{t('settings.offline.conflicts.keepMine')}
|
||||
</button>
|
||||
<button onClick={() => resolveConflict(c.id, false)} className="border border-edge bg-surface-card text-content" style={smallBtnStyle()}>
|
||||
{t('settings.offline.conflicts.keepServer')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Row
|
||||
label={t('settings.offline.conflicts.strategyTitle')}
|
||||
control={
|
||||
<select
|
||||
value={prefs.conflictStrategy}
|
||||
onChange={e => setConflictStrategy(e.target.value as ConflictStrategy)}
|
||||
className="border border-edge bg-surface-secondary text-content"
|
||||
style={{ padding: '6px 10px', borderRadius: 8, fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}
|
||||
>
|
||||
<option value="ask">{t('settings.offline.conflicts.strategy.ask')}</option>
|
||||
<option value="mine">{t('settings.offline.conflicts.strategy.mine')}</option>
|
||||
<option value="server">{t('settings.offline.conflicts.strategy.server')}</option>
|
||||
</select>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* What to store offline */}
|
||||
<Section title={t('settings.offline.storage.title')} icon={MapIcon}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<Row
|
||||
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')} />}
|
||||
/>
|
||||
{allTrips.length > 0 && (
|
||||
<div style={{ borderTop: '1px solid var(--border-secondary, #e5e7eb)', paddingTop: 16 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', marginBottom: 8 }} className="text-content">
|
||||
{t('settings.offline.storage.tripsTitle')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{allTrips.map((trip) => {
|
||||
const on = isTripOfflineEnabled(trip.id)
|
||||
return (
|
||||
<div key={trip.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{trip.title}
|
||||
</div>
|
||||
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>
|
||||
{on ? t('settings.offline.storage.tripOn') : t('settings.offline.storage.tripOff')}
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch on={on} onToggle={() => handleToggleTrip(trip.id)} label={trip.title} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Cache stats + list + clear */}
|
||||
<Section title={t('settings.offline.cache.title')} icon={Database}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<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>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={handleClear}
|
||||
disabled={clearing || rows.length === 0}
|
||||
className="border border-edge bg-surface-secondary text-[#ef4444]"
|
||||
style={btnStyle(clearing || rows.length === 0)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('settings.offline.clear')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{t('settings.offline.loading')}</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
|
||||
{t('settings.offline.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{rows.map(({ trip, meta, placeCount, fileCount }) => (
|
||||
<div
|
||||
key={trip.id}
|
||||
className="border border-edge bg-surface-secondary"
|
||||
style={{ padding: '10px 14px', borderRadius: 8, display: 'flex', flexDirection: 'column', gap: 2 }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span className="text-content" style={{ fontWeight: 600, fontSize: 'calc(14px * var(--fs-scale-body, 1))' }}>
|
||||
{trip.title}
|
||||
</span>
|
||||
<span className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>
|
||||
{meta.lastSyncedAt
|
||||
? new Date(meta.lastSyncedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
|
||||
{formatDate(trip.start_date)} – {formatDate(trip.end_date)}
|
||||
{' · '}{placeCount}{' · '}{fileCount}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function btnStyle(disabled: boolean): React.CSSProperties {
|
||||
return {
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px', borderRadius: 8,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, opacity: disabled ? 0.5 : 1,
|
||||
}
|
||||
}
|
||||
|
||||
function smallBtnStyle(): React.CSSProperties {
|
||||
return {
|
||||
padding: '6px 12px', borderRadius: 8, cursor: 'pointer',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
}
|
||||
}
|
||||
|
||||
function Row({ label, hint, control }: { label: string; hint?: string; control: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontWeight: 500, fontSize: 'calc(14px * var(--fs-scale-body, 1))' }}>{label}</div>
|
||||
{hint && <div className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 2 }}>{hint}</div>}
|
||||
</div>
|
||||
</Section>
|
||||
<div style={{ flexShrink: 0 }}>{control}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label, value, danger }: { label: string; value: number; danger?: boolean }) {
|
||||
return (
|
||||
<div className="border border-edge bg-surface-secondary" style={{
|
||||
padding: '8px 14px', borderRadius: 8,
|
||||
minWidth: 100,
|
||||
}}>
|
||||
<div className="border border-edge bg-surface-secondary" style={{ padding: '8px 14px', borderRadius: 8, minWidth: 100 }}>
|
||||
<div style={{ fontSize: 'calc(20px * var(--fs-scale-title, 1))', fontWeight: 700, color: danger ? '#ef4444' : undefined }}
|
||||
className={danger ? undefined : 'text-content'}>{value}</div>
|
||||
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>{label}</div>
|
||||
|
||||
Reference in New Issue
Block a user