Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc87b0f8e6 | |||
| 58984594b0 | |||
| 61675e1085 | |||
| 2082218f78 | |||
| 5882edcbdf | |||
| b7a47a1d90 | |||
| 48c408302f | |||
| 2b5c5d4a36 | |||
| 7cf04b3357 | |||
| bbd4281dcb | |||
| d2833f7664 | |||
| 2a14080b5b | |||
| 2457fa41e3 | |||
| 87b0fa7bde | |||
| d90f292a21 | |||
| 9e42f828a0 | |||
| 4197e77b1e | |||
| 1373c11de8 | |||
| 0bae3b29dc | |||
| 73e86d28b3 | |||
| ad4721e694 | |||
| 8037b3b63e | |||
| c4cd566da0 | |||
| 3a267905b0 |
+1
-1
@@ -4,7 +4,7 @@ server {
|
|||||||
client_max_body_size 50M;
|
client_max_body_size 50M;
|
||||||
|
|
||||||
# Service worker and app shell must revalidate so PWA updates are detected
|
# Service worker and app shell must revalidate so PWA updates are detected
|
||||||
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest)$ {
|
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1371,6 +1371,140 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-list-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-filter-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sort-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sort-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sort-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sort-group {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sort-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--app-border-subtle);
|
||||||
|
background: var(--app-surface-alt);
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, background-color 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sort-btn:hover {
|
||||||
|
border-color: var(--app-border);
|
||||||
|
color: var(--app-text-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sort-btn.is-active {
|
||||||
|
border-color: var(--app-accent-border);
|
||||||
|
background: var(--app-accent-bg);
|
||||||
|
color: var(--app-accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sort-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--app-accent-focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-filter-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-filter-input-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-filter-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 14px;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-filter-input {
|
||||||
|
width: 100%;
|
||||||
|
padding-left: 42px;
|
||||||
|
padding-right: 42px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-filter-input::-webkit-search-cancel-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-filter-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s, background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-filter-clear:hover {
|
||||||
|
color: var(--app-text-heading);
|
||||||
|
background: var(--app-accent-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-filter-clear:focus-visible {
|
||||||
|
outline: 2px solid var(--app-accent-focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-filter-meta {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.section-title-bar {
|
.section-title-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -2322,6 +2456,14 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-sort-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sort-group {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.logbooks-grid {
|
.logbooks-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
@@ -3632,6 +3774,59 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
.stats-kpi-value {
|
.stats-kpi-value {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-stats-section.form-card {
|
||||||
|
padding: 20px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stats-section .form-header {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stats-section .form-header h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stats-section .stats-subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stats-kpi-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stats-kpi-grid .stats-kpi-card {
|
||||||
|
padding: 10px;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stats-kpi-grid .stats-kpi-icon {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stats-kpi-grid .stats-kpi-icon svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stats-kpi-grid .stats-kpi-label {
|
||||||
|
font-size: 11px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stats-kpi-grid .stats-kpi-value {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stats-kpi-grid .stats-kpi-unit {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.signature-grid {
|
.signature-grid {
|
||||||
|
|||||||
+8
-1
@@ -1,5 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import './App.css'
|
|
||||||
import { DialogProvider } from './components/ModalDialog.tsx'
|
import { DialogProvider } from './components/ModalDialog.tsx'
|
||||||
import AuthOnboarding from './components/AuthOnboarding.tsx'
|
import AuthOnboarding from './components/AuthOnboarding.tsx'
|
||||||
import UserProfilePage from './components/UserProfilePage.tsx'
|
import UserProfilePage from './components/UserProfilePage.tsx'
|
||||||
@@ -29,6 +28,7 @@ import {
|
|||||||
resolveColorScheme,
|
resolveColorScheme,
|
||||||
subscribeToSystemColorScheme
|
subscribeToSystemColorScheme
|
||||||
} from './services/appearance.js'
|
} from './services/appearance.js'
|
||||||
|
import { syncAppearancePrefs } from './services/appearancePrefs.js'
|
||||||
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
|
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
|
||||||
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
|
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
|
||||||
import DemoViewer from './components/DemoViewer.tsx'
|
import DemoViewer from './components/DemoViewer.tsx'
|
||||||
@@ -151,6 +151,13 @@ function App() {
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) return
|
||||||
|
const userId = localStorage.getItem('active_userid')
|
||||||
|
if (!userId) return
|
||||||
|
void syncAppearancePrefs(userId)
|
||||||
|
}, [isAuthenticated])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleOnline = () => {
|
const handleOnline = () => {
|
||||||
setOnline(true)
|
setOnline(true)
|
||||||
|
|||||||
@@ -379,16 +379,37 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
{t('auth.recovery_fallback_warning')}
|
{t('auth.recovery_fallback_warning')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleRecoverySubmit} className="auth-form">
|
<form onSubmit={handleRecoverySubmit} className="auth-form" autoComplete="on">
|
||||||
<textarea
|
{(username.trim() || encryptedPayloads?.username) && (
|
||||||
className="input-textarea"
|
<input
|
||||||
placeholder={t('auth.recovery_placeholder')}
|
type="text"
|
||||||
value={recoveryInput}
|
name="username"
|
||||||
onChange={(e) => setRecoveryInput(e.target.value)}
|
autoComplete="username"
|
||||||
disabled={loading}
|
value={username.trim() || encryptedPayloads?.username || ''}
|
||||||
rows={3}
|
readOnly
|
||||||
required
|
tabIndex={-1}
|
||||||
/>
|
aria-hidden="true"
|
||||||
|
style={{ position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="input-group">
|
||||||
|
<label htmlFor="recovery-key" className="input-label" style={{ display: 'block', marginBottom: '8px', color: '#94a3b8' }}>
|
||||||
|
{t('auth.enter_recovery')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="recovery-key"
|
||||||
|
name="recovery-key"
|
||||||
|
type="password"
|
||||||
|
className="input-text"
|
||||||
|
placeholder={t('auth.recovery_placeholder')}
|
||||||
|
value={recoveryInput}
|
||||||
|
onChange={(e) => setRecoveryInput(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <div className="auth-error">{error}</div>}
|
{error && <div className="auth-error">{error}</div>}
|
||||||
|
|
||||||
|
|||||||
@@ -344,15 +344,36 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
<h2>{t('auth.enter_recovery')}</h2>
|
<h2>{t('auth.enter_recovery')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="recovery-warning">{t('auth.recovery_fallback_warning')}</p>
|
<p className="recovery-warning">{t('auth.recovery_fallback_warning')}</p>
|
||||||
<form onSubmit={handleRecoverySubmit}>
|
<form onSubmit={handleRecoverySubmit} autoComplete="on">
|
||||||
<textarea
|
{(username.trim() || encryptedPayloads?.username) && (
|
||||||
className="input-text"
|
<input
|
||||||
placeholder={t('auth.recovery_placeholder')}
|
type="text"
|
||||||
value={recoveryInput}
|
name="username"
|
||||||
onChange={(e) => setRecoveryInput(e.target.value)}
|
autoComplete="username"
|
||||||
rows={3}
|
value={username.trim() || encryptedPayloads?.username || ''}
|
||||||
required
|
readOnly
|
||||||
/>
|
tabIndex={-1}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="input-group">
|
||||||
|
<label htmlFor="invitation-recovery-key" className="input-label" style={{ display: 'block', marginBottom: '8px', color: '#94a3b8' }}>
|
||||||
|
{t('auth.enter_recovery')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="invitation-recovery-key"
|
||||||
|
name="recovery-key"
|
||||||
|
type="password"
|
||||||
|
className="input-text"
|
||||||
|
placeholder={t('auth.recovery_placeholder')}
|
||||||
|
value={recoveryInput}
|
||||||
|
onChange={(e) => setRecoveryInput(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="auth-actions mt-4">
|
<div className="auth-actions mt-4">
|
||||||
<button type="button" className="btn secondary" onClick={() => setShowRecoveryFallback(false)}>
|
<button type="button" className="btn secondary" onClick={() => setShowRecoveryFallback(false)}>
|
||||||
{t('auth.back')}
|
{t('auth.back')}
|
||||||
|
|||||||
@@ -241,14 +241,15 @@ export default function LogEntriesList({
|
|||||||
|
|
||||||
decryptedEntries.sort(compareTravelDaysChronological)
|
decryptedEntries.sort(compareTravelDaysChronological)
|
||||||
const previousEntry = decryptedEntries.at(-1) ?? null
|
const previousEntry = decryptedEntries.at(-1) ?? null
|
||||||
let { freshwater, fuel, departure } = carryOverFromPreviousDay(previousEntry)
|
let { freshwater, fuel, greywaterLevel, departure } = carryOverFromPreviousDay(previousEntry)
|
||||||
|
|
||||||
if (previousEntry && hasCarryOverFromPreviousDay({ freshwater, fuel, departure })) {
|
if (previousEntry && hasCarryOverFromPreviousDay({ freshwater, fuel, greywaterLevel, departure })) {
|
||||||
const confirmed = await showConfirm(
|
const confirmed = await showConfirm(
|
||||||
t('logs.carry_over_tanks_confirm', {
|
t('logs.carry_over_tanks_confirm', {
|
||||||
departure: departure || '—',
|
departure: departure || '—',
|
||||||
fw: formatTankLiters(freshwater.morning),
|
fw: formatTankLiters(freshwater.morning),
|
||||||
fuel: formatTankLiters(fuel.morning)
|
fuel: formatTankLiters(fuel.morning),
|
||||||
|
greywater: formatTankLiters(greywaterLevel)
|
||||||
}),
|
}),
|
||||||
t('logs.carry_over_tanks_title'),
|
t('logs.carry_over_tanks_title'),
|
||||||
t('logs.carry_over_tanks_yes'),
|
t('logs.carry_over_tanks_yes'),
|
||||||
@@ -257,6 +258,7 @@ export default function LogEntriesList({
|
|||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
freshwater = emptyTankLevels()
|
freshwater = emptyTankLevels()
|
||||||
fuel = emptyTankLevels()
|
fuel = emptyTankLevels()
|
||||||
|
greywaterLevel = 0
|
||||||
departure = ''
|
departure = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,6 +276,7 @@ export default function LogEntriesList({
|
|||||||
destination: '',
|
destination: '',
|
||||||
freshwater,
|
freshwater,
|
||||||
fuel,
|
fuel,
|
||||||
|
...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}),
|
||||||
signSkipper: '',
|
signSkipper: '',
|
||||||
signCrew: '',
|
signCrew: '',
|
||||||
events: []
|
events: []
|
||||||
|
|||||||
@@ -574,13 +574,23 @@ export default function LogEntryEditor({
|
|||||||
setFuelConsumption(cons >= 0 ? String(cons) : '0')
|
setFuelConsumption(cons >= 0 ? String(cons) : '0')
|
||||||
}, [fuelMorning, fuelRefilled, fuelEvening])
|
}, [fuelMorning, fuelRefilled, fuelEvening])
|
||||||
|
|
||||||
|
const fwRefilledNoCapacity =
|
||||||
|
(tankCapacities.freshwaterCapacityL ?? 0) > 0 && fwRefilledMax == null
|
||||||
|
const fuelRefilledNoCapacity =
|
||||||
|
(tankCapacities.fuelCapacityL ?? 0) > 0 && fuelRefilledMax == null
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fwRefilledMax == null) return
|
|
||||||
const refilled = parseFloat(fwRefilled) || 0
|
const refilled = parseFloat(fwRefilled) || 0
|
||||||
|
if (fwRefilledMax == null) {
|
||||||
|
if (fwRefilledNoCapacity && refilled > 0) {
|
||||||
|
setFwRefilled(formatTankLitersForInput(0))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if (refilled > fwRefilledMax) {
|
if (refilled > fwRefilledMax) {
|
||||||
setFwRefilled(formatTankLitersForInput(fwRefilledMax))
|
setFwRefilled(formatTankLitersForInput(fwRefilledMax))
|
||||||
}
|
}
|
||||||
}, [fwRefilledMax, fwMorning])
|
}, [fwRefilledMax, fwRefilled, fwRefilledNoCapacity])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fwEveningMax == null) return
|
if (fwEveningMax == null) return
|
||||||
@@ -588,15 +598,20 @@ export default function LogEntryEditor({
|
|||||||
if (evening > fwEveningMax) {
|
if (evening > fwEveningMax) {
|
||||||
setFwEvening(formatTankLitersForInput(fwEveningMax))
|
setFwEvening(formatTankLitersForInput(fwEveningMax))
|
||||||
}
|
}
|
||||||
}, [fwEveningMax, fwMorning, fwRefilled])
|
}, [fwEveningMax, fwEvening])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fuelRefilledMax == null) return
|
|
||||||
const refilled = parseFloat(fuelRefilled) || 0
|
const refilled = parseFloat(fuelRefilled) || 0
|
||||||
|
if (fuelRefilledMax == null) {
|
||||||
|
if (fuelRefilledNoCapacity && refilled > 0) {
|
||||||
|
setFuelRefilled(formatTankLitersForInput(0))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if (refilled > fuelRefilledMax) {
|
if (refilled > fuelRefilledMax) {
|
||||||
setFuelRefilled(formatTankLitersForInput(fuelRefilledMax))
|
setFuelRefilled(formatTankLitersForInput(fuelRefilledMax))
|
||||||
}
|
}
|
||||||
}, [fuelRefilledMax, fuelMorning])
|
}, [fuelRefilledMax, fuelRefilled, fuelRefilledNoCapacity])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fuelEveningMax == null) return
|
if (fuelEveningMax == null) return
|
||||||
@@ -604,7 +619,7 @@ export default function LogEntryEditor({
|
|||||||
if (evening > fuelEveningMax) {
|
if (evening > fuelEveningMax) {
|
||||||
setFuelEvening(formatTankLitersForInput(fuelEveningMax))
|
setFuelEvening(formatTankLitersForInput(fuelEveningMax))
|
||||||
}
|
}
|
||||||
}, [fuelEveningMax, fuelMorning, fuelRefilled])
|
}, [fuelEveningMax, fuelEvening])
|
||||||
|
|
||||||
// Load yacht sails and tank capacities
|
// Load yacht sails and tank capacities
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1317,8 +1332,8 @@ export default function LogEntryEditor({
|
|||||||
label={t('logs.refilled')}
|
label={t('logs.refilled')}
|
||||||
value={fwRefilled}
|
value={fwRefilled}
|
||||||
onChange={setFwRefilled}
|
onChange={setFwRefilled}
|
||||||
maxLiters={fwRefilledMax ?? tankCapacities.freshwaterCapacityL}
|
maxLiters={fwRefilledMax}
|
||||||
disabled={saving || readOnly}
|
disabled={saving || readOnly || fwRefilledNoCapacity}
|
||||||
titleTooltip={tankCapacityTooltip}
|
titleTooltip={tankCapacityTooltip}
|
||||||
/>
|
/>
|
||||||
<TankLiterInput
|
<TankLiterInput
|
||||||
@@ -1366,8 +1381,8 @@ export default function LogEntryEditor({
|
|||||||
label={t('logs.refilled')}
|
label={t('logs.refilled')}
|
||||||
value={fuelRefilled}
|
value={fuelRefilled}
|
||||||
onChange={setFuelRefilled}
|
onChange={setFuelRefilled}
|
||||||
maxLiters={fuelRefilledMax ?? tankCapacities.fuelCapacityL}
|
maxLiters={fuelRefilledMax}
|
||||||
disabled={saving || readOnly}
|
disabled={saving || readOnly || fuelRefilledNoCapacity}
|
||||||
titleTooltip={tankCapacityTooltip}
|
titleTooltip={tankCapacityTooltip}
|
||||||
/>
|
/>
|
||||||
<TankLiterInput
|
<TankLiterInput
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
|
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
|
||||||
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
|
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
|
||||||
@@ -7,7 +7,7 @@ import BetaBadge from './BetaBadge.tsx'
|
|||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import { logoutUser } from '../services/auth.js'
|
import { logoutUser } from '../services/auth.js'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
|
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
|
||||||
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
||||||
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
||||||
|
|
||||||
@@ -17,6 +17,46 @@ interface LogbookDashboardProps {
|
|||||||
onOpenProfile: () => void
|
onOpenProfile: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logbookMatchesFilter(lb: DecryptedLogbook, query: string, locale: string): boolean {
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
if (!q) return true
|
||||||
|
|
||||||
|
if (lb.title.toLowerCase().includes(q)) return true
|
||||||
|
|
||||||
|
const updated = new Date(lb.updatedAt)
|
||||||
|
const year = updated.getFullYear().toString()
|
||||||
|
if (year.includes(q)) return true
|
||||||
|
|
||||||
|
const dateLabel = updated.toLocaleDateString(locale, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
}).toLowerCase()
|
||||||
|
if (dateLabel.includes(q)) return true
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogbookSortKey = 'name' | 'date'
|
||||||
|
type LogbookSortDirection = 'asc' | 'desc'
|
||||||
|
|
||||||
|
function sortLogbooks(
|
||||||
|
items: DecryptedLogbook[],
|
||||||
|
sortBy: LogbookSortKey,
|
||||||
|
direction: LogbookSortDirection,
|
||||||
|
locale: string
|
||||||
|
): DecryptedLogbook[] {
|
||||||
|
const sorted = [...items]
|
||||||
|
sorted.sort((a, b) => {
|
||||||
|
const cmp =
|
||||||
|
sortBy === 'name'
|
||||||
|
? a.title.localeCompare(b.title, locale, { sensitivity: 'base' })
|
||||||
|
: new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
|
||||||
|
return direction === 'asc' ? cmp : -cmp
|
||||||
|
})
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
|
||||||
export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile }: LogbookDashboardProps) {
|
export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile }: LogbookDashboardProps) {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const { showConfirm } = useDialog()
|
const { showConfirm } = useDialog()
|
||||||
@@ -28,6 +68,10 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [filterQuery, setFilterQuery] = useState('')
|
||||||
|
const [sortBy, setSortBy] = useState<LogbookSortKey>('date')
|
||||||
|
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
|
||||||
|
const filterInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [online, setOnline] = useState(navigator.onLine)
|
const [online, setOnline] = useState(navigator.onLine)
|
||||||
const [username] = useState(localStorage.getItem('active_username') || 'Skipper')
|
const [username] = useState(localStorage.getItem('active_username') || 'Skipper')
|
||||||
|
|
||||||
@@ -156,6 +200,25 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
|
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
|
||||||
const sharedLogbooks = logbooks.filter((lb) => lb.isShared)
|
const sharedLogbooks = logbooks.filter((lb) => lb.isShared)
|
||||||
|
|
||||||
|
const filterActive = filterQuery.trim().length > 0
|
||||||
|
const filteredOwnedLogbooks = useMemo(
|
||||||
|
() => ownedLogbooks.filter((lb) => logbookMatchesFilter(lb, filterQuery, i18n.language)),
|
||||||
|
[ownedLogbooks, filterQuery, i18n.language]
|
||||||
|
)
|
||||||
|
const filteredSharedLogbooks = useMemo(
|
||||||
|
() => sharedLogbooks.filter((lb) => logbookMatchesFilter(lb, filterQuery, i18n.language)),
|
||||||
|
[sharedLogbooks, filterQuery, i18n.language]
|
||||||
|
)
|
||||||
|
const sortedOwnedLogbooks = useMemo(
|
||||||
|
() => sortLogbooks(filteredOwnedLogbooks, sortBy, sortDirection, i18n.language),
|
||||||
|
[filteredOwnedLogbooks, sortBy, sortDirection, i18n.language]
|
||||||
|
)
|
||||||
|
const sortedSharedLogbooks = useMemo(
|
||||||
|
() => sortLogbooks(filteredSharedLogbooks, sortBy, sortDirection, i18n.language),
|
||||||
|
[filteredSharedLogbooks, sortBy, sortDirection, i18n.language]
|
||||||
|
)
|
||||||
|
const filteredLogbookCount = sortedOwnedLogbooks.length + sortedSharedLogbooks.length
|
||||||
|
|
||||||
const renderLogbookCard = (lb: DecryptedLogbook) => {
|
const renderLogbookCard = (lb: DecryptedLogbook) => {
|
||||||
const isEditingTitle = editingLogbookId === lb.id
|
const isEditingTitle = editingLogbookId === lb.id
|
||||||
|
|
||||||
@@ -376,17 +439,115 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
) : logbooks.length === 0 ? (
|
) : logbooks.length === 0 ? (
|
||||||
<div className="dashboard-status-msg glass">{t('dashboard.no_logbooks')}</div>
|
<div className="dashboard-status-msg glass">{t('dashboard.no_logbooks')}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="logbook-sections">
|
<>
|
||||||
{ownedLogbooks.length > 0 && renderLogbookSection(
|
<div className="dashboard-list-controls">
|
||||||
sharedLogbooks.length > 0 ? t('dashboard.section_owned') : t('dashboard.title'),
|
<div className="dashboard-filter-bar">
|
||||||
ownedLogbooks
|
<label className="dashboard-filter-label" htmlFor="logbook-list-filter">
|
||||||
|
{t('dashboard.filter_label')}
|
||||||
|
</label>
|
||||||
|
<div className="dashboard-filter-input-wrap">
|
||||||
|
<Search size={18} className="dashboard-filter-icon" aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
ref={filterInputRef}
|
||||||
|
id="logbook-list-filter"
|
||||||
|
type="search"
|
||||||
|
className="input-text dashboard-filter-input"
|
||||||
|
placeholder={t('dashboard.filter_placeholder')}
|
||||||
|
value={filterQuery}
|
||||||
|
onChange={(e) => setFilterQuery(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
aria-describedby={filterActive ? 'logbook-filter-status' : undefined}
|
||||||
|
/>
|
||||||
|
{filterActive && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dashboard-filter-clear"
|
||||||
|
onClick={() => {
|
||||||
|
setFilterQuery('')
|
||||||
|
filterInputRef.current?.focus()
|
||||||
|
}}
|
||||||
|
title={t('dashboard.filter_clear')}
|
||||||
|
aria-label={t('dashboard.filter_clear')}
|
||||||
|
>
|
||||||
|
<X size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{filterActive && (
|
||||||
|
<p id="logbook-filter-status" className="dashboard-filter-meta" role="status">
|
||||||
|
{t('dashboard.filter_results', { count: filteredLogbookCount })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dashboard-sort-bar">
|
||||||
|
<span className="dashboard-sort-label">{t('dashboard.sort_label')}</span>
|
||||||
|
<div className="dashboard-sort-row">
|
||||||
|
<div className="dashboard-sort-group" role="group" aria-label={t('dashboard.sort_by_label')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`dashboard-sort-btn${sortBy === 'name' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setSortBy('name')}
|
||||||
|
aria-pressed={sortBy === 'name'}
|
||||||
|
aria-label={t('dashboard.sort_by_name')}
|
||||||
|
title={t('dashboard.sort_by_name')}
|
||||||
|
>
|
||||||
|
<CaseSensitive size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`dashboard-sort-btn${sortBy === 'date' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setSortBy('date')}
|
||||||
|
aria-pressed={sortBy === 'date'}
|
||||||
|
aria-label={t('dashboard.sort_by_date')}
|
||||||
|
title={t('dashboard.sort_by_date')}
|
||||||
|
>
|
||||||
|
<CalendarDays size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-sort-group" role="group" aria-label={t('dashboard.sort_dir_label')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`dashboard-sort-btn${sortDirection === 'asc' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setSortDirection('asc')}
|
||||||
|
aria-pressed={sortDirection === 'asc'}
|
||||||
|
aria-label={sortBy === 'name' ? t('dashboard.sort_name_asc') : t('dashboard.sort_date_asc')}
|
||||||
|
title={sortBy === 'name' ? t('dashboard.sort_name_asc') : t('dashboard.sort_date_asc')}
|
||||||
|
>
|
||||||
|
<ArrowUp size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`dashboard-sort-btn${sortDirection === 'desc' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setSortDirection('desc')}
|
||||||
|
aria-pressed={sortDirection === 'desc'}
|
||||||
|
aria-label={sortBy === 'name' ? t('dashboard.sort_name_desc') : t('dashboard.sort_date_desc')}
|
||||||
|
title={sortBy === 'name' ? t('dashboard.sort_name_desc') : t('dashboard.sort_date_desc')}
|
||||||
|
>
|
||||||
|
<ArrowDown size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filterActive && filteredLogbookCount === 0 ? (
|
||||||
|
<div className="dashboard-status-msg glass">{t('dashboard.filter_no_results')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="logbook-sections">
|
||||||
|
{sortedOwnedLogbooks.length > 0 && renderLogbookSection(
|
||||||
|
sortedSharedLogbooks.length > 0 ? t('dashboard.section_owned') : t('dashboard.title'),
|
||||||
|
sortedOwnedLogbooks
|
||||||
|
)}
|
||||||
|
{sortedSharedLogbooks.length > 0 && renderLogbookSection(
|
||||||
|
t('dashboard.section_shared'),
|
||||||
|
sortedSharedLogbooks,
|
||||||
|
t('dashboard.section_shared_hint')
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{sharedLogbooks.length > 0 && renderLogbookSection(
|
</>
|
||||||
t('dashboard.section_shared'),
|
|
||||||
sharedLogbooks,
|
|
||||||
t('dashboard.section_shared_hint')
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import LogbookBackupPanel from './LogbookBackupPanel.tsx'
|
|||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import { apiFetch } from '../services/api.js'
|
import { apiFetch } from '../services/api.js'
|
||||||
|
import {
|
||||||
|
enableCollaboratorChangePush,
|
||||||
|
isCollaboratorPushActive,
|
||||||
|
isPushSupported
|
||||||
|
} from '../services/pushNotifications.js'
|
||||||
|
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
|
||||||
|
|
||||||
interface SettingsFormProps {
|
interface SettingsFormProps {
|
||||||
logbookId?: string | null
|
logbookId?: string | null
|
||||||
@@ -151,6 +157,43 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const promptPushAfterInviteCreated = async () => {
|
||||||
|
if (!isPushSupported()) return
|
||||||
|
if (await isCollaboratorPushActive()) return
|
||||||
|
|
||||||
|
const iosNeedsInstall = isIosDevice() && !isRunningStandalone()
|
||||||
|
|
||||||
|
if (iosNeedsInstall) {
|
||||||
|
await showAlert(
|
||||||
|
t('settings.invite_push_prompt_ios_message'),
|
||||||
|
t('settings.invite_push_prompt_title'),
|
||||||
|
t('settings.invite_push_prompt_later')
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const enable = await showConfirm(
|
||||||
|
t('settings.invite_push_prompt_message'),
|
||||||
|
t('settings.invite_push_prompt_title'),
|
||||||
|
t('settings.invite_push_prompt_enable'),
|
||||||
|
t('settings.invite_push_prompt_later')
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!enable) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await enableCollaboratorChangePush()
|
||||||
|
await showAlert(
|
||||||
|
t('settings.invite_push_prompt_success'),
|
||||||
|
t('settings.invite_push_prompt_title')
|
||||||
|
)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('Failed to enable push after invite:', err)
|
||||||
|
await showAlert(err instanceof Error ? err.message : t('profile.push_error'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleGenerateInvite = async () => {
|
const handleGenerateInvite = async () => {
|
||||||
if (!logbookId) return
|
if (!logbookId) return
|
||||||
setGeneratingInvite(true)
|
setGeneratingInvite(true)
|
||||||
@@ -175,6 +218,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
|
|
||||||
setInviteLink(link)
|
setInviteLink(link)
|
||||||
trackPlausibleEvent(PlausibleEvents.INVITE_GENERATED)
|
trackPlausibleEvent(PlausibleEvents.INVITE_GENERATED)
|
||||||
|
await promptPushAfterInviteCreated()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to generate invite:', err)
|
console.error('Failed to generate invite:', err)
|
||||||
showAlert(err instanceof Error ? err.message : 'Failed to generate invite link.')
|
showAlert(err instanceof Error ? err.message : 'Failed to generate invite link.')
|
||||||
|
|||||||
@@ -726,7 +726,7 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="form-card">
|
<section className="form-card profile-stats-section">
|
||||||
<div className="form-header">
|
<div className="form-header">
|
||||||
<BarChart2 size={24} className="form-icon" />
|
<BarChart2 size={24} className="form-icon" />
|
||||||
<div>
|
<div>
|
||||||
@@ -736,7 +736,7 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(statsTotals || profile) && (
|
{(statsTotals || profile) && (
|
||||||
<div className="stats-kpi-grid">
|
<div className="stats-kpi-grid profile-stats-kpi-grid">
|
||||||
<KpiCard
|
<KpiCard
|
||||||
icon={<BookOpen size={20} />}
|
icon={<BookOpen size={20} />}
|
||||||
label={t('profile.stats_logbooks')}
|
label={t('profile.stats_logbooks')}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import ThemedSelect from './ThemedSelect.tsx'
|
|||||||
import PushNotificationSettings from './PushNotificationSettings.tsx'
|
import PushNotificationSettings from './PushNotificationSettings.tsx'
|
||||||
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
|
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
|
||||||
import { notifyAppearanceChanged } from '../services/appearance.js'
|
import { notifyAppearanceChanged } from '../services/appearance.js'
|
||||||
|
import { saveAppearancePrefsToServer } from '../services/appearancePrefs.js'
|
||||||
import { useAppTour } from '../context/AppTourContext.tsx'
|
import { useAppTour } from '../context/AppTourContext.tsx'
|
||||||
import {
|
import {
|
||||||
getColorSchemePreference,
|
getColorSchemePreference,
|
||||||
@@ -32,6 +33,9 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
|
|||||||
setThemePreference(userId, nextTheme)
|
setThemePreference(userId, nextTheme)
|
||||||
setColorSchemePreference(userId, nextColorScheme)
|
setColorSchemePreference(userId, nextColorScheme)
|
||||||
notifyAppearanceChanged()
|
notifyAppearanceChanged()
|
||||||
|
void saveAppearancePrefsToServer(nextTheme, nextColorScheme).catch((err) => {
|
||||||
|
console.warn('Failed to save appearance prefs to server:', err)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleThemeChange = (nextTheme: string) => {
|
const handleThemeChange = (nextTheme: string) => {
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useRegisterSW } from 'virtual:pwa-register/react'
|
import { useRegisterSW } from 'virtual:pwa-register/react'
|
||||||
|
import {
|
||||||
|
forcePwaRecovery,
|
||||||
|
markReloadAttempt,
|
||||||
|
recentlyAttemptedReload,
|
||||||
|
triggerServiceWorkerUpdate
|
||||||
|
} from '../services/pwaStartup.js'
|
||||||
|
import { isDeployedVersionNewer } from '../services/pwaVersion.js'
|
||||||
|
|
||||||
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000
|
const UPDATE_CHECK_INTERVAL_MS = 15 * 60 * 1000
|
||||||
const UPDATE_SUPPRESS_KEY = 'pwa_update_suppress_until'
|
const UPDATE_SUPPRESS_KEY = 'pwa_update_suppress_until'
|
||||||
const UPDATE_SUPPRESS_MS = 30_000
|
const UPDATE_SUPPRESS_MS = 30_000
|
||||||
const UPDATE_DISMISS_SUPPRESS_MS = 60 * 60 * 1000
|
const UPDATE_DISMISS_SUPPRESS_MS = 15 * 60 * 1000
|
||||||
const UPDATE_RELOAD_FALLBACK_MS = 2000
|
const UPDATE_RELOAD_FALLBACK_MS = 2_000
|
||||||
/** Prevent Android PWA cold-start reload loops from onNeedReload. */
|
const UPDATE_HARD_RECOVERY_MS = 5_000
|
||||||
const PWA_INITIAL_RELOAD_KEY = 'pwa_sw_initial_reload_done'
|
|
||||||
|
|
||||||
function isUpdateSuppressed(): boolean {
|
function isUpdateSuppressed(): boolean {
|
||||||
const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0')
|
const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0')
|
||||||
@@ -22,10 +28,16 @@ function clearUpdateSuppression(): void {
|
|||||||
sessionStorage.removeItem(UPDATE_SUPPRESS_KEY)
|
sessionStorage.removeItem(UPDATE_SUPPRESS_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleUpdateChecks(registration: ServiceWorkerRegistration): () => void {
|
function scheduleUpdateChecks(
|
||||||
|
registration: ServiceWorkerRegistration,
|
||||||
|
onOutdated: () => void
|
||||||
|
): () => void {
|
||||||
const checkForUpdate = () => {
|
const checkForUpdate = () => {
|
||||||
if (isUpdateSuppressed()) return
|
if (isUpdateSuppressed()) return
|
||||||
registration.update().catch(() => {})
|
registration.update().catch(() => {})
|
||||||
|
void isDeployedVersionNewer().then((outdated) => {
|
||||||
|
if (outdated) onOutdated()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onVisibilityChange = () => {
|
const onVisibilityChange = () => {
|
||||||
@@ -34,17 +46,44 @@ function scheduleUpdateChecks(registration: ServiceWorkerRegistration): () => vo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onOnline = () => {
|
||||||
|
checkForUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||||
const intervalId = window.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS)
|
window.addEventListener('online', onOnline)
|
||||||
|
const updateIntervalId = window.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS)
|
||||||
|
|
||||||
|
checkForUpdate()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||||
window.clearInterval(intervalId)
|
window.removeEventListener('online', onOnline)
|
||||||
|
window.clearInterval(updateIntervalId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reloadForServiceWorkerTakeover(): void {
|
||||||
|
if (recentlyAttemptedReload()) return
|
||||||
|
markReloadAttempt()
|
||||||
|
clearUpdateSuppression()
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
export function usePwaUpdate() {
|
export function usePwaUpdate() {
|
||||||
const cleanupRef = useRef<(() => void) | null>(null)
|
const cleanupRef = useRef<(() => void) | null>(null)
|
||||||
|
const reloadFallbackTimerRef = useRef<number | null>(null)
|
||||||
|
const forceRecoveryTimerRef = useRef<number | null>(null)
|
||||||
|
const setNeedRefreshRef = useRef<((value: boolean) => void) | null>(null)
|
||||||
|
const pendingNeedRefreshRef = useRef<boolean | null>(null)
|
||||||
|
|
||||||
|
const applyNeedRefresh = (value: boolean) => {
|
||||||
|
if (setNeedRefreshRef.current) {
|
||||||
|
setNeedRefreshRef.current(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingNeedRefreshRef.current = value
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
needRefresh: [needRefresh, setNeedRefresh],
|
needRefresh: [needRefresh, setNeedRefresh],
|
||||||
@@ -52,39 +91,55 @@ export function usePwaUpdate() {
|
|||||||
} = useRegisterSW({
|
} = useRegisterSW({
|
||||||
immediate: !import.meta.env.DEV,
|
immediate: !import.meta.env.DEV,
|
||||||
onNeedReload() {
|
onNeedReload() {
|
||||||
// First SW takeover requires one reload; guard against repeated reloads on Android PWA resume.
|
reloadForServiceWorkerTakeover()
|
||||||
if (sessionStorage.getItem(PWA_INITIAL_RELOAD_KEY)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sessionStorage.setItem(PWA_INITIAL_RELOAD_KEY, '1')
|
|
||||||
clearUpdateSuppression()
|
|
||||||
setNeedRefresh(false)
|
|
||||||
window.location.reload()
|
|
||||||
},
|
},
|
||||||
onNeedRefresh() {
|
onNeedRefresh() {
|
||||||
if (isUpdateSuppressed()) return
|
if (isUpdateSuppressed()) return
|
||||||
setNeedRefresh(true)
|
applyNeedRefresh(true)
|
||||||
},
|
},
|
||||||
onRegisteredSW(_swUrl: string, registration: ServiceWorkerRegistration | undefined) {
|
onRegisteredSW(_swUrl: string, registration: ServiceWorkerRegistration | undefined) {
|
||||||
if (!registration) return
|
if (!registration) return
|
||||||
|
|
||||||
if (isUpdateSuppressed() || !registration.waiting) {
|
if (isUpdateSuppressed() || !registration.waiting) {
|
||||||
setNeedRefresh(false)
|
applyNeedRefresh(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanupRef.current?.()
|
cleanupRef.current?.()
|
||||||
cleanupRef.current = scheduleUpdateChecks(registration)
|
cleanupRef.current = scheduleUpdateChecks(registration, () => {
|
||||||
|
if (isUpdateSuppressed()) return
|
||||||
|
applyNeedRefresh(true)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setNeedRefreshRef.current = setNeedRefresh
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isUpdateSuppressed()) {
|
if (isUpdateSuppressed()) {
|
||||||
setNeedRefresh(false)
|
setNeedRefresh(false)
|
||||||
|
} else if (pendingNeedRefreshRef.current !== null) {
|
||||||
|
const pending = pendingNeedRefreshRef.current
|
||||||
|
pendingNeedRefreshRef.current = null
|
||||||
|
setNeedRefresh(pending)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void isDeployedVersionNewer().then((outdated) => {
|
||||||
|
if (outdated) {
|
||||||
|
setNeedRefresh(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cleanupRef.current?.()
|
cleanupRef.current?.()
|
||||||
cleanupRef.current = null
|
cleanupRef.current = null
|
||||||
|
if (reloadFallbackTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(reloadFallbackTimerRef.current)
|
||||||
|
reloadFallbackTimerRef.current = null
|
||||||
|
}
|
||||||
|
if (forceRecoveryTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(forceRecoveryTimerRef.current)
|
||||||
|
forceRecoveryTimerRef.current = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [setNeedRefresh])
|
}, [setNeedRefresh])
|
||||||
|
|
||||||
@@ -93,11 +148,24 @@ export function usePwaUpdate() {
|
|||||||
suppressUpdatePrompt()
|
suppressUpdatePrompt()
|
||||||
|
|
||||||
await updateServiceWorker(true)
|
await updateServiceWorker(true)
|
||||||
|
await triggerServiceWorkerUpdate()
|
||||||
|
|
||||||
// vite-plugin-pwa reloads via the "controlling" event; fallback if that does not fire.
|
if (reloadFallbackTimerRef.current !== null) {
|
||||||
window.setTimeout(() => {
|
window.clearTimeout(reloadFallbackTimerRef.current)
|
||||||
window.location.reload()
|
}
|
||||||
|
if (forceRecoveryTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(forceRecoveryTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadFallbackTimerRef.current = window.setTimeout(() => {
|
||||||
|
reloadFallbackTimerRef.current = null
|
||||||
|
reloadForServiceWorkerTakeover()
|
||||||
}, UPDATE_RELOAD_FALLBACK_MS)
|
}, UPDATE_RELOAD_FALLBACK_MS)
|
||||||
|
|
||||||
|
forceRecoveryTimerRef.current = window.setTimeout(() => {
|
||||||
|
forceRecoveryTimerRef.current = null
|
||||||
|
void forcePwaRecovery()
|
||||||
|
}, UPDATE_HARD_RECOVERY_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
const dismissUpdate = () => {
|
const dismissUpdate = () => {
|
||||||
|
|||||||
@@ -193,7 +193,7 @@
|
|||||||
"delete_entry": "Tag löschen",
|
"delete_entry": "Tag löschen",
|
||||||
"delete_confirm": "Bist du sicher, dass du diesen Reisetag unwiderruflich löschen möchtest?",
|
"delete_confirm": "Bist du sicher, dass du diesen Reisetag unwiderruflich löschen möchtest?",
|
||||||
"carry_over_tanks_title": "Daten vom Vortag übernehmen?",
|
"carry_over_tanks_title": "Daten vom Vortag übernehmen?",
|
||||||
"carry_over_tanks_confirm": "Start-Hafen, Frischwasser- und Kraftstoff-Morgenstände vom letzten Reisetag übernehmen?\n\nStart-Hafen: {{departure}}\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L",
|
"carry_over_tanks_confirm": "Start-Hafen, Frischwasser-, Kraftstoff- und Grauwasser-Startstände vom letzten Reisetag übernehmen?\n\nStart-Hafen: {{departure}}\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L\nGrauwasser: {{greywater}} L",
|
||||||
"carry_over_tanks_yes": "Übernehmen",
|
"carry_over_tanks_yes": "Übernehmen",
|
||||||
"carry_over_tanks_no": "Mit 0 starten",
|
"carry_over_tanks_no": "Mit 0 starten",
|
||||||
"event_title": "Chronologisches Ereignisprotokoll",
|
"event_title": "Chronologisches Ereignisprotokoll",
|
||||||
@@ -304,7 +304,23 @@
|
|||||||
"edit_title": "Logbuch umbenennen",
|
"edit_title": "Logbuch umbenennen",
|
||||||
"edit_placeholder": "Neuer Name des Logbuchs",
|
"edit_placeholder": "Neuer Name des Logbuchs",
|
||||||
"edit_success": "Logbuch erfolgreich umbenannt",
|
"edit_success": "Logbuch erfolgreich umbenannt",
|
||||||
"edit_btn": "Umbenennen"
|
"edit_btn": "Umbenennen",
|
||||||
|
"filter_label": "Logbücher filtern",
|
||||||
|
"filter_placeholder": "Name, Jahr oder Datum …",
|
||||||
|
"filter_clear": "Filter zurücksetzen",
|
||||||
|
"filter_results": "{{count}} Treffer",
|
||||||
|
"filter_no_results": "Keine Logbücher passen zu deiner Suche. Probiere einen anderen Namen oder ein anderes Jahr.",
|
||||||
|
"sort_label": "Sortieren",
|
||||||
|
"sort_by_label": "Sortieren nach",
|
||||||
|
"sort_by_name": "Name",
|
||||||
|
"sort_by_date": "Datum",
|
||||||
|
"sort_dir_label": "Reihenfolge",
|
||||||
|
"sort_asc": "Aufsteigend",
|
||||||
|
"sort_desc": "Absteigend",
|
||||||
|
"sort_name_asc": "Name A bis Z",
|
||||||
|
"sort_name_desc": "Name Z bis A",
|
||||||
|
"sort_date_asc": "Älteste zuerst",
|
||||||
|
"sort_date_desc": "Neueste zuerst"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"title": "Benutzerprofil",
|
"title": "Benutzerprofil",
|
||||||
@@ -482,6 +498,12 @@
|
|||||||
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.",
|
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.",
|
||||||
"delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok.json) in den Einstellungen jedes Logbuchs.",
|
"delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok.json) in den Einstellungen jedes Logbuchs.",
|
||||||
"deleting_account": "Konto wird gelöscht…",
|
"deleting_account": "Konto wird gelöscht…",
|
||||||
|
"invite_push_prompt_title": "Push-Benachrichtigungen aktivieren?",
|
||||||
|
"invite_push_prompt_message": "Sobald eingeladene Crewmitglieder Änderungen synchronisieren, kannst du per Push informiert werden. Es werden keine Logbuch-Inhalte im Klartext gesendet.",
|
||||||
|
"invite_push_prompt_ios_message": "Sobald Crewmitglieder Änderungen synchronisieren, kannst du per Push informiert werden. Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), dann Push im Benutzerprofil aktivieren.",
|
||||||
|
"invite_push_prompt_enable": "Jetzt aktivieren",
|
||||||
|
"invite_push_prompt_later": "Später",
|
||||||
|
"invite_push_prompt_success": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
|
||||||
"backup_title": "Backup & Wiederherstellung",
|
"backup_title": "Backup & Wiederherstellung",
|
||||||
"backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.",
|
"backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.",
|
||||||
"backup_export_title": "Backup erstellen",
|
"backup_export_title": "Backup erstellen",
|
||||||
|
|||||||
@@ -193,7 +193,7 @@
|
|||||||
"delete_entry": "Delete Day",
|
"delete_entry": "Delete Day",
|
||||||
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
|
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
|
||||||
"carry_over_tanks_title": "Carry over from previous day?",
|
"carry_over_tanks_title": "Carry over from previous day?",
|
||||||
"carry_over_tanks_confirm": "Use the previous travel day's destination as departure port and closing tank levels as morning levels?\n\nDeparture port: {{departure}}\nFreshwater: {{fw}} L\nFuel: {{fuel}} L",
|
"carry_over_tanks_confirm": "Use the previous travel day's destination as departure port and closing tank levels as morning levels?\n\nDeparture port: {{departure}}\nFreshwater: {{fw}} L\nFuel: {{fuel}} L\nGreywater: {{greywater}} L",
|
||||||
"carry_over_tanks_yes": "Carry over",
|
"carry_over_tanks_yes": "Carry over",
|
||||||
"carry_over_tanks_no": "Start at 0",
|
"carry_over_tanks_no": "Start at 0",
|
||||||
"event_title": "Chronological Event Logbook",
|
"event_title": "Chronological Event Logbook",
|
||||||
@@ -304,7 +304,23 @@
|
|||||||
"edit_title": "Rename Logbook",
|
"edit_title": "Rename Logbook",
|
||||||
"edit_placeholder": "New name of the logbook",
|
"edit_placeholder": "New name of the logbook",
|
||||||
"edit_success": "Logbook renamed successfully",
|
"edit_success": "Logbook renamed successfully",
|
||||||
"edit_btn": "Rename"
|
"edit_btn": "Rename",
|
||||||
|
"filter_label": "Filter logbooks",
|
||||||
|
"filter_placeholder": "Name, year or date …",
|
||||||
|
"filter_clear": "Clear filter",
|
||||||
|
"filter_results": "{{count}} matches",
|
||||||
|
"filter_no_results": "No logbooks match your search. Try a different name or year.",
|
||||||
|
"sort_label": "Sort",
|
||||||
|
"sort_by_label": "Sort by",
|
||||||
|
"sort_by_name": "Name",
|
||||||
|
"sort_by_date": "Date",
|
||||||
|
"sort_dir_label": "Order",
|
||||||
|
"sort_asc": "Ascending",
|
||||||
|
"sort_desc": "Descending",
|
||||||
|
"sort_name_asc": "Name A to Z",
|
||||||
|
"sort_name_desc": "Name Z to A",
|
||||||
|
"sort_date_asc": "Oldest first",
|
||||||
|
"sort_date_desc": "Newest first"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"title": "User profile",
|
"title": "User profile",
|
||||||
@@ -482,6 +498,12 @@
|
|||||||
"delete_account_failed": "Failed to delete account. Please try again.",
|
"delete_account_failed": "Failed to delete account. Please try again.",
|
||||||
"delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok.json) in each logbook's settings.",
|
"delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok.json) in each logbook's settings.",
|
||||||
"deleting_account": "Deleting account…",
|
"deleting_account": "Deleting account…",
|
||||||
|
"invite_push_prompt_title": "Enable push notifications?",
|
||||||
|
"invite_push_prompt_message": "When invited crew members sync changes, you can be notified via push. No logbook content is sent in plain text.",
|
||||||
|
"invite_push_prompt_ios_message": "When crew members sync changes, you can get push notifications. On iPhone/iPad: add the app to your Home Screen (iOS 16.4+), then enable push in your user profile.",
|
||||||
|
"invite_push_prompt_enable": "Enable now",
|
||||||
|
"invite_push_prompt_later": "Later",
|
||||||
|
"invite_push_prompt_success": "Push notifications are active on this device.",
|
||||||
"backup_title": "Backup & restore",
|
"backup_title": "Backup & restore",
|
||||||
"backup_desc": "Full encrypted backup of this logbook (entries, photos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.",
|
"backup_desc": "Full encrypted backup of this logbook (entries, photos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.",
|
||||||
"backup_export_title": "Create backup",
|
"backup_export_title": "Create backup",
|
||||||
|
|||||||
+11
-100
@@ -1,64 +1,8 @@
|
|||||||
:root {
|
/* Minimal app shell — component styles live in App.css / themes.css */
|
||||||
--text: #6b6375;
|
|
||||||
--text-h: #08060d;
|
|
||||||
--bg: #fff;
|
|
||||||
--border: #e5e4e7;
|
|
||||||
--code-bg: #f4f3ec;
|
|
||||||
--accent: #aa3bff;
|
|
||||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
|
||||||
--accent-border: rgba(170, 59, 255, 0.5);
|
|
||||||
--social-bg: rgba(244, 243, 236, 0.5);
|
|
||||||
--shadow:
|
|
||||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
|
||||||
|
|
||||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
*,
|
||||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
*::before,
|
||||||
--mono: ui-monospace, Consolas, monospace;
|
*::after {
|
||||||
|
|
||||||
font: 18px/145% var(--sans);
|
|
||||||
letter-spacing: 0.18px;
|
|
||||||
color-scheme: light dark;
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--bg);
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--text: #9ca3af;
|
|
||||||
--text-h: #f3f4f6;
|
|
||||||
--bg: #16171d;
|
|
||||||
--border: #2e303a;
|
|
||||||
--code-bg: #1f2028;
|
|
||||||
--accent: #c084fc;
|
|
||||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
|
||||||
--accent-border: rgba(192, 132, 252, 0.5);
|
|
||||||
--social-bg: rgba(47, 48, 58, 0.5);
|
|
||||||
--shadow:
|
|
||||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#social .button-icon {
|
|
||||||
filter: invert(1) brightness(2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#root {
|
|
||||||
width: 1126px;
|
|
||||||
max-width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
text-align: center;
|
|
||||||
border-inline: 1px solid var(--border);
|
|
||||||
min-height: 100svh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,44 +10,11 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
#root {
|
||||||
h2 {
|
width: 100%;
|
||||||
font-family: var(--heading);
|
max-width: 100%;
|
||||||
font-weight: 500;
|
min-height: 100svh;
|
||||||
color: var(--text-h);
|
display: flex;
|
||||||
}
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
h1 {
|
|
||||||
font-size: 56px;
|
|
||||||
letter-spacing: -1.68px;
|
|
||||||
margin: 32px 0;
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 36px;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
font-size: 24px;
|
|
||||||
line-height: 118%;
|
|
||||||
letter-spacing: -0.24px;
|
|
||||||
margin: 0 0 8px;
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
code,
|
|
||||||
.counter {
|
|
||||||
font-family: var(--mono);
|
|
||||||
display: inline-flex;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 135%;
|
|
||||||
padding: 4px 8px;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,15 @@ import { createRoot } from 'react-dom/client'
|
|||||||
import 'leaflet/dist/leaflet.css'
|
import 'leaflet/dist/leaflet.css'
|
||||||
import './themes.css'
|
import './themes.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
import './App.css'
|
||||||
import './i18n'
|
import './i18n'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import { applyAppearanceToDocument } from './services/appearance.ts'
|
import { applyAppearanceToDocument } from './services/appearance.ts'
|
||||||
|
import {
|
||||||
|
installStaleAssetRecovery,
|
||||||
|
markReloadAttempt,
|
||||||
|
reconcileVersionOnStartup
|
||||||
|
} from './services/pwaStartup.ts'
|
||||||
|
|
||||||
/** Stale PWA precache on localhost can shadow Vite dev modules. */
|
/** Stale PWA precache on localhost can shadow Vite dev modules. */
|
||||||
async function clearDevServiceWorkerCaches(): Promise<void> {
|
async function clearDevServiceWorkerCaches(): Promise<void> {
|
||||||
@@ -35,8 +41,19 @@ function renderBootstrapError(message: string): void {
|
|||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
applyAppearanceToDocument()
|
applyAppearanceToDocument()
|
||||||
|
installStaleAssetRecovery()
|
||||||
await clearDevServiceWorkerCaches()
|
await clearDevServiceWorkerCaches()
|
||||||
|
|
||||||
|
const startupResult = await reconcileVersionOnStartup()
|
||||||
|
if (startupResult === 'reload') {
|
||||||
|
markReloadAttempt()
|
||||||
|
window.location.reload()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (startupResult === 'recovered') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const rootEl = document.getElementById('root')
|
const rootEl = document.getElementById('root')
|
||||||
if (!rootEl) {
|
if (!rootEl) {
|
||||||
throw new Error('Missing #root element')
|
throw new Error('Missing #root element')
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
fetchAppearancePrefs,
|
||||||
|
saveAppearancePrefsToServer,
|
||||||
|
syncAppearancePrefs
|
||||||
|
} from './appearancePrefs.js'
|
||||||
|
import { setThemePreference } from './userPreferences.js'
|
||||||
|
|
||||||
|
const USER_ID = 'appearance-sync-user'
|
||||||
|
|
||||||
|
vi.mock('./api.js', () => ({
|
||||||
|
apiJson: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { apiJson } from './api.js'
|
||||||
|
|
||||||
|
const mockedApiJson = vi.mocked(apiJson)
|
||||||
|
|
||||||
|
describe('appearancePrefs', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetchAppearancePrefs returns defaults when not authenticated', async () => {
|
||||||
|
await expect(fetchAppearancePrefs()).resolves.toEqual({
|
||||||
|
theme: 'auto',
|
||||||
|
colorScheme: 'auto',
|
||||||
|
persisted: false
|
||||||
|
})
|
||||||
|
expect(mockedApiJson).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('syncAppearancePrefs applies server prefs after cache wipe', async () => {
|
||||||
|
localStorage.setItem('active_userid', USER_ID)
|
||||||
|
mockedApiJson.mockResolvedValueOnce({
|
||||||
|
theme: 'ocean',
|
||||||
|
colorScheme: 'dark',
|
||||||
|
persisted: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const changed = vi.fn()
|
||||||
|
window.addEventListener('appearance-changed', changed)
|
||||||
|
|
||||||
|
await syncAppearancePrefs(USER_ID)
|
||||||
|
|
||||||
|
expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean')
|
||||||
|
expect(localStorage.getItem(`user_pref_color_scheme_${USER_ID}`)).toBe('dark')
|
||||||
|
expect(changed).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('syncAppearancePrefs uploads local prefs when server has none', async () => {
|
||||||
|
localStorage.setItem('active_userid', USER_ID)
|
||||||
|
setThemePreference(USER_ID, 'material')
|
||||||
|
mockedApiJson
|
||||||
|
.mockResolvedValueOnce({ theme: 'auto', colorScheme: 'auto', persisted: false })
|
||||||
|
.mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', persisted: true })
|
||||||
|
|
||||||
|
await syncAppearancePrefs(USER_ID)
|
||||||
|
|
||||||
|
expect(mockedApiJson).toHaveBeenCalledTimes(2)
|
||||||
|
expect(mockedApiJson).toHaveBeenLastCalledWith('/api/auth/appearance-prefs', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ theme: 'material', colorScheme: 'auto' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saveAppearancePrefsToServer skips when not authenticated', async () => {
|
||||||
|
await saveAppearancePrefsToServer('ocean', 'light')
|
||||||
|
expect(mockedApiJson).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('syncAppearancePrefs skips server sync when userId does not match active session', async () => {
|
||||||
|
localStorage.setItem('active_userid', 'session-user')
|
||||||
|
setThemePreference('other-user', 'ocean')
|
||||||
|
mockedApiJson.mockResolvedValue({
|
||||||
|
theme: 'material',
|
||||||
|
colorScheme: 'dark',
|
||||||
|
persisted: true
|
||||||
|
})
|
||||||
|
|
||||||
|
await syncAppearancePrefs('other-user')
|
||||||
|
|
||||||
|
expect(mockedApiJson).not.toHaveBeenCalled()
|
||||||
|
expect(localStorage.getItem('user_pref_theme_other-user')).toBe('ocean')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('syncAppearancePrefs skips server sync when active session is missing', async () => {
|
||||||
|
setThemePreference(USER_ID, 'ocean')
|
||||||
|
|
||||||
|
await syncAppearancePrefs(USER_ID)
|
||||||
|
|
||||||
|
expect(mockedApiJson).not.toHaveBeenCalled()
|
||||||
|
expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { apiJson } from './api.js'
|
||||||
|
import { notifyAppearanceChanged } from './appearance.js'
|
||||||
|
import {
|
||||||
|
getActiveUserId,
|
||||||
|
getColorSchemePreference,
|
||||||
|
getThemePreference,
|
||||||
|
setColorSchemePreference,
|
||||||
|
setThemePreference
|
||||||
|
} from './userPreferences.js'
|
||||||
|
|
||||||
|
const API_BASE = '/api/auth/appearance-prefs'
|
||||||
|
|
||||||
|
export interface AppearancePrefs {
|
||||||
|
theme: string
|
||||||
|
colorScheme: string
|
||||||
|
persisted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasLocalAppearancePrefs(userId: string): boolean {
|
||||||
|
return (
|
||||||
|
localStorage.getItem(`user_pref_theme_${userId}`) != null ||
|
||||||
|
localStorage.getItem(`user_pref_color_scheme_${userId}`) != null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSyncedUserId(userId?: string | null): string | null {
|
||||||
|
const id = userId?.trim() || getActiveUserId()?.trim() || null
|
||||||
|
if (!id) return null
|
||||||
|
|
||||||
|
const activeId = getActiveUserId()?.trim() || null
|
||||||
|
if (!activeId || activeId !== id) return null
|
||||||
|
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAppearancePrefs(userId?: string | null): Promise<AppearancePrefs> {
|
||||||
|
if (!resolveSyncedUserId(userId)) {
|
||||||
|
return { theme: 'auto', colorScheme: 'auto', persisted: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiJson<AppearancePrefs>(API_BASE)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAppearancePrefsToServer(
|
||||||
|
theme: string,
|
||||||
|
colorScheme: string,
|
||||||
|
userId?: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
if (!resolveSyncedUserId(userId)) return
|
||||||
|
|
||||||
|
await apiJson<AppearancePrefs>(API_BASE, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ theme, colorScheme })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge server-stored appearance with local cache (server wins after cache wipe). */
|
||||||
|
export async function syncAppearancePrefs(userId?: string | null): Promise<void> {
|
||||||
|
const id = resolveSyncedUserId(userId)
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const server = await fetchAppearancePrefs(id)
|
||||||
|
|
||||||
|
if (server.persisted) {
|
||||||
|
setThemePreference(id, server.theme)
|
||||||
|
setColorSchemePreference(id, server.colorScheme)
|
||||||
|
} else if (hasLocalAppearancePrefs(id)) {
|
||||||
|
await saveAppearancePrefsToServer(getThemePreference(id), getColorSchemePreference(id), id)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to sync appearance preferences:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyAppearanceChanged()
|
||||||
|
}
|
||||||
@@ -43,6 +43,18 @@ async function fetchVapidPublicKey(): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when crew-change push is enabled and notification permission is granted. */
|
||||||
|
export async function isCollaboratorPushActive(): Promise<boolean> {
|
||||||
|
if (!isPushSupported()) return false
|
||||||
|
if (getNotificationPermission() !== 'granted') return false
|
||||||
|
try {
|
||||||
|
const prefs = await fetchPushPrefs()
|
||||||
|
return prefs.collaboratorChangesEnabled
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchPushPrefs(): Promise<{ collaboratorChangesEnabled: boolean }> {
|
export async function fetchPushPrefs(): Promise<{ collaboratorChangesEnabled: boolean }> {
|
||||||
if (!localStorage.getItem('active_userid')) {
|
if (!localStorage.getItem('active_userid')) {
|
||||||
return { collaboratorChangesEnabled: false }
|
return { collaboratorChangesEnabled: false }
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
forcePwaRecovery,
|
||||||
|
markReloadAttempt,
|
||||||
|
recentlyAttemptedReload,
|
||||||
|
reconcileServiceWorkerOnStartup,
|
||||||
|
reconcileVersionOnStartup
|
||||||
|
} from './pwaStartup.js'
|
||||||
|
|
||||||
|
const STALE_RECOVERY_COUNT_KEY = 'pwa_stale_recovery_count'
|
||||||
|
const STALE_RECOVERY_LAST_KEY = 'pwa_stale_recovery_last_ts'
|
||||||
|
|
||||||
|
describe('pwaStartup reload guards', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks repeated reload attempts within the debounce window', () => {
|
||||||
|
expect(recentlyAttemptedReload(10_000)).toBe(false)
|
||||||
|
markReloadAttempt(10_000)
|
||||||
|
expect(recentlyAttemptedReload(12_000)).toBe(true)
|
||||||
|
expect(recentlyAttemptedReload(15_000)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('forcePwaRecovery stale counter reset', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear()
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears stale recovery counter before hard recovery reload', async () => {
|
||||||
|
vi.stubEnv('DEV', false)
|
||||||
|
sessionStorage.setItem(STALE_RECOVERY_COUNT_KEY, '2')
|
||||||
|
sessionStorage.setItem(STALE_RECOVERY_LAST_KEY, String(Date.now()))
|
||||||
|
|
||||||
|
const reload = vi.fn()
|
||||||
|
vi.stubGlobal('location', { reload })
|
||||||
|
vi.stubGlobal('caches', {
|
||||||
|
keys: vi.fn().mockResolvedValue([]),
|
||||||
|
delete: vi.fn()
|
||||||
|
})
|
||||||
|
Object.defineProperty(navigator, 'serviceWorker', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
getRegistrations: vi.fn().mockResolvedValue([])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await forcePwaRecovery()
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem(STALE_RECOVERY_COUNT_KEY)).toBeNull()
|
||||||
|
expect(sessionStorage.getItem(STALE_RECOVERY_LAST_KEY)).toBeNull()
|
||||||
|
expect(reload).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('reconcileServiceWorkerOnStartup', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear()
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false in dev mode', async () => {
|
||||||
|
vi.stubEnv('DEV', true)
|
||||||
|
await expect(reconcileServiceWorkerOnStartup()).resolves.toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when no waiting worker exists', async () => {
|
||||||
|
vi.stubEnv('DEV', false)
|
||||||
|
Object.defineProperty(navigator, 'serviceWorker', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
controller: {},
|
||||||
|
getRegistration: vi.fn().mockResolvedValue({
|
||||||
|
waiting: null,
|
||||||
|
installing: null,
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
addEventListener: vi.fn()
|
||||||
|
}),
|
||||||
|
addEventListener: vi.fn()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(reconcileServiceWorkerOnStartup()).resolves.toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('reconcileVersionOnStartup', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear()
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns noop in dev mode', async () => {
|
||||||
|
vi.stubEnv('DEV', true)
|
||||||
|
await expect(reconcileVersionOnStartup()).resolves.toBe('noop')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns noop when deployed version matches bundled version', async () => {
|
||||||
|
vi.stubEnv('DEV', false)
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ version: '0.1.0.57' })
|
||||||
|
}))
|
||||||
|
vi.stubGlobal('__APP_VERSION__', '0.1.0.57')
|
||||||
|
|
||||||
|
await expect(reconcileVersionOnStartup()).resolves.toBe('noop')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import { isNewerAppVersion, fetchDeployedVersion, getAppVersion } from './pwaVersion.js'
|
||||||
|
|
||||||
|
const RELOAD_ATTEMPT_KEY = 'pwa_reload_attempt_ts'
|
||||||
|
const COLD_START_UPDATE_KEY = 'pwa_coldstart_update_ts'
|
||||||
|
const HARD_RECOVERY_KEY = 'pwa_hard_recovery_ts'
|
||||||
|
const STALE_RECOVERY_COUNT_KEY = 'pwa_stale_recovery_count'
|
||||||
|
const STALE_RECOVERY_LAST_KEY = 'pwa_stale_recovery_last_ts'
|
||||||
|
const STALE_RECOVERY_WINDOW_MS = 60_000
|
||||||
|
const RELOAD_DEBOUNCE_MS = 4_000
|
||||||
|
const COLD_START_UPDATE_DEBOUNCE_MS = 15_000
|
||||||
|
const HARD_RECOVERY_DEBOUNCE_MS = 30_000
|
||||||
|
|
||||||
|
export function recentlyAttemptedReload(now = Date.now()): boolean {
|
||||||
|
const last = Number(sessionStorage.getItem(RELOAD_ATTEMPT_KEY) || '0')
|
||||||
|
return now - last < RELOAD_DEBOUNCE_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markReloadAttempt(now = Date.now()): void {
|
||||||
|
sessionStorage.setItem(RELOAD_ATTEMPT_KEY, String(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
function recentlyAttemptedColdStartUpdate(now = Date.now()): boolean {
|
||||||
|
const last = Number(sessionStorage.getItem(COLD_START_UPDATE_KEY) || '0')
|
||||||
|
return now - last < COLD_START_UPDATE_DEBOUNCE_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
function markColdStartUpdateAttempt(now = Date.now()): void {
|
||||||
|
sessionStorage.setItem(COLD_START_UPDATE_KEY, String(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
function recentlyAttemptedHardRecovery(now = Date.now()): boolean {
|
||||||
|
const last = Number(sessionStorage.getItem(HARD_RECOVERY_KEY) || '0')
|
||||||
|
return now - last < HARD_RECOVERY_DEBOUNCE_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
function markHardRecoveryAttempt(now = Date.now()): void {
|
||||||
|
sessionStorage.setItem(HARD_RECOVERY_KEY, String(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetStaleRecoveryCount(): void {
|
||||||
|
sessionStorage.removeItem(STALE_RECOVERY_COUNT_KEY)
|
||||||
|
sessionStorage.removeItem(STALE_RECOVERY_LAST_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
function incrementStaleRecoveryCount(now = Date.now()): number {
|
||||||
|
const last = Number(sessionStorage.getItem(STALE_RECOVERY_LAST_KEY) || '0')
|
||||||
|
let current = Number(sessionStorage.getItem(STALE_RECOVERY_COUNT_KEY) || '0')
|
||||||
|
|
||||||
|
if (now - last > STALE_RECOVERY_WINDOW_MS) {
|
||||||
|
current = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = current + 1
|
||||||
|
sessionStorage.setItem(STALE_RECOVERY_COUNT_KEY, String(next))
|
||||||
|
sessionStorage.setItem(STALE_RECOVERY_LAST_KEY, String(now))
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStaleModuleLoadError(error: unknown): boolean {
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: typeof error === 'string'
|
||||||
|
? error
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
message.includes('Failed to fetch dynamically imported module') ||
|
||||||
|
message.includes('Importing a module script failed') ||
|
||||||
|
message.includes('error loading dynamically imported module') ||
|
||||||
|
message.includes('Loading chunk') ||
|
||||||
|
message.includes('ChunkLoadError') ||
|
||||||
|
message.includes('Unable to preload CSS')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearPwaCachesAndWorkers(): Promise<void> {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||||
|
await Promise.all(registrations.map((registration) => registration.unregister()))
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('caches' in window) {
|
||||||
|
const keys = await caches.keys()
|
||||||
|
await Promise.all(keys.map((key) => caches.delete(key)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last-resort recovery when soft reloads cannot escape a stale precache.
|
||||||
|
* Equivalent to manually clearing site data / reinstalling the PWA.
|
||||||
|
*/
|
||||||
|
export async function forcePwaRecovery(): Promise<void> {
|
||||||
|
if (recentlyAttemptedHardRecovery()) return
|
||||||
|
|
||||||
|
markHardRecoveryAttempt()
|
||||||
|
markReloadAttempt()
|
||||||
|
resetStaleRecoveryCount()
|
||||||
|
await clearPwaCachesAndWorkers()
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForWaitingWorker(
|
||||||
|
registration: ServiceWorkerRegistration,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<ServiceWorker | null> {
|
||||||
|
if (registration.waiting) {
|
||||||
|
return registration.waiting
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timeoutId = window.setTimeout(() => resolve(null), timeoutMs)
|
||||||
|
|
||||||
|
const inspectWorker = (worker: ServiceWorker | null) => {
|
||||||
|
if (!worker) return
|
||||||
|
|
||||||
|
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
resolve(worker)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
worker.addEventListener(
|
||||||
|
'statechange',
|
||||||
|
() => {
|
||||||
|
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
resolve(worker)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
inspectWorker(registration.installing)
|
||||||
|
|
||||||
|
registration.addEventListener(
|
||||||
|
'updatefound',
|
||||||
|
() => {
|
||||||
|
inspectWorker(registration.installing)
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function triggerServiceWorkerUpdate(timeoutMs = 5_000): Promise<boolean> {
|
||||||
|
if (import.meta.env.DEV || !('serviceWorker' in navigator)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const registration = await navigator.serviceWorker.getRegistration()
|
||||||
|
if (!registration) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
await registration.update()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const waiting = await waitForWaitingWorker(registration, timeoutMs)
|
||||||
|
return waiting !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activateWaitingWorker(waiting: ServiceWorker): Promise<boolean> {
|
||||||
|
waiting.postMessage({ type: 'SKIP_WAITING' })
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const timeoutId = window.setTimeout(resolve, 4_000)
|
||||||
|
navigator.serviceWorker.addEventListener(
|
||||||
|
'controllerchange',
|
||||||
|
() => {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
resolve()
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After missed deploys, a waiting SW may exist while the page still runs an old bundle.
|
||||||
|
* Apply the waiting worker once on cold start (one controlled reload) instead of hanging.
|
||||||
|
*/
|
||||||
|
export async function reconcileServiceWorkerOnStartup(): Promise<boolean> {
|
||||||
|
if (import.meta.env.DEV || !('serviceWorker' in navigator)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recentlyAttemptedColdStartUpdate()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const registration = await navigator.serviceWorker.getRegistration()
|
||||||
|
let waiting = registration?.waiting ?? null
|
||||||
|
|
||||||
|
if (!waiting && registration) {
|
||||||
|
await registration.update().catch(() => {})
|
||||||
|
waiting = await waitForWaitingWorker(registration, 4_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!waiting || !navigator.serviceWorker.controller) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
markColdStartUpdateAttempt()
|
||||||
|
return activateWaitingWorker(waiting)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare deployed version.json with the bundled app version.
|
||||||
|
* When the server is ahead, try a soft SW takeover before hard recovery.
|
||||||
|
*/
|
||||||
|
export async function reconcileVersionOnStartup(): Promise<'reload' | 'recovered' | 'noop'> {
|
||||||
|
if (import.meta.env.DEV || !navigator.onLine) {
|
||||||
|
return 'noop'
|
||||||
|
}
|
||||||
|
|
||||||
|
const deployedVersion = await fetchDeployedVersion()
|
||||||
|
if (!deployedVersion || !isNewerAppVersion(deployedVersion, getAppVersion())) {
|
||||||
|
return 'noop'
|
||||||
|
}
|
||||||
|
|
||||||
|
const reconciled = await reconcileServiceWorkerOnStartup()
|
||||||
|
if (reconciled) {
|
||||||
|
return 'reload'
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await triggerServiceWorkerUpdate()
|
||||||
|
if (updated) {
|
||||||
|
const registration = await navigator.serviceWorker.getRegistration()
|
||||||
|
const waiting = registration?.waiting
|
||||||
|
if (waiting) {
|
||||||
|
markColdStartUpdateAttempt()
|
||||||
|
await activateWaitingWorker(waiting)
|
||||||
|
return 'reload'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!recentlyAttemptedHardRecovery()) {
|
||||||
|
await forcePwaRecovery()
|
||||||
|
return 'recovered'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'noop'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installStaleAssetRecovery(): void {
|
||||||
|
if (import.meta.env.DEV) return
|
||||||
|
|
||||||
|
const recoverFromStaleAssets = () => {
|
||||||
|
if (recentlyAttemptedReload()) return
|
||||||
|
|
||||||
|
const attempts = incrementStaleRecoveryCount()
|
||||||
|
markReloadAttempt()
|
||||||
|
|
||||||
|
if (attempts >= 2) {
|
||||||
|
void forcePwaRecovery()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
if (!isStaleModuleLoadError(event.reason)) return
|
||||||
|
event.preventDefault()
|
||||||
|
recoverFromStaleAssets()
|
||||||
|
})
|
||||||
|
|
||||||
|
window.addEventListener(
|
||||||
|
'error',
|
||||||
|
(event) => {
|
||||||
|
if (!isStaleModuleLoadError(event.message)) return
|
||||||
|
recoverFromStaleAssets()
|
||||||
|
},
|
||||||
|
true
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
compareAppVersions,
|
||||||
|
isNewerAppVersion,
|
||||||
|
parseAppVersion
|
||||||
|
} from './pwaVersion.js'
|
||||||
|
|
||||||
|
describe('pwaVersion', () => {
|
||||||
|
it('parses semantic build versions', () => {
|
||||||
|
expect(parseAppVersion('v0.1.0.57')).toEqual([0, 1, 0, 57])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('compares build numbers numerically', () => {
|
||||||
|
expect(compareAppVersions('0.1.0.65', '0.1.0.57')).toBeGreaterThan(0)
|
||||||
|
expect(compareAppVersions('0.1.0.57', '0.1.0.65')).toBeLessThan(0)
|
||||||
|
expect(compareAppVersions('0.1.0.57', '0.1.0.57')).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects newer deployed versions', () => {
|
||||||
|
expect(isNewerAppVersion('0.1.0.66', '0.1.0.57')).toBe(true)
|
||||||
|
expect(isNewerAppVersion('0.1.0.57', '0.1.0.57')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
const APP_VERSION =
|
||||||
|
typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0.0-dev'
|
||||||
|
|
||||||
|
export function getAppVersion(): string {
|
||||||
|
return APP_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAppVersion(version: string): number[] {
|
||||||
|
return version
|
||||||
|
.replace(/^v/i, '')
|
||||||
|
.split('.')
|
||||||
|
.map((part) => Number.parseInt(part, 10) || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Positive when `a` is newer than `b`. */
|
||||||
|
export function compareAppVersions(a: string, b: string): number {
|
||||||
|
const partsA = parseAppVersion(a)
|
||||||
|
const partsB = parseAppVersion(b)
|
||||||
|
const length = Math.max(partsA.length, partsB.length)
|
||||||
|
|
||||||
|
for (let index = 0; index < length; index += 1) {
|
||||||
|
const diff = (partsA[index] ?? 0) - (partsB[index] ?? 0)
|
||||||
|
if (diff !== 0) return diff
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNewerAppVersion(serverVersion: string, clientVersion: string): boolean {
|
||||||
|
return compareAppVersions(serverVersion, clientVersion) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDeployedVersion(timeoutMs = 4_000): Promise<string | null> {
|
||||||
|
if (!navigator.onLine) return null
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/version.json?_=${Date.now()}`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
if (!response.ok) return null
|
||||||
|
|
||||||
|
const payload = (await response.json()) as { version?: unknown }
|
||||||
|
return typeof payload.version === 'string' ? payload.version.trim() : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isDeployedVersionNewer(): Promise<boolean> {
|
||||||
|
const deployedVersion = await fetchDeployedVersion()
|
||||||
|
if (!deployedVersion) return false
|
||||||
|
return isNewerAppVersion(deployedVersion, getAppVersion())
|
||||||
|
}
|
||||||
@@ -1,10 +1,23 @@
|
|||||||
/// <reference lib="webworker" />
|
/// <reference lib="webworker" />
|
||||||
|
import { clientsClaim } from 'workbox-core'
|
||||||
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
|
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
|
||||||
|
import { registerRoute } from 'workbox-routing'
|
||||||
|
import { NetworkOnly } from 'workbox-strategies'
|
||||||
|
|
||||||
declare let self: ServiceWorkerGlobalScope
|
declare let self: ServiceWorkerGlobalScope
|
||||||
|
|
||||||
precacheAndRoute(self.__WB_MANIFEST)
|
precacheAndRoute(self.__WB_MANIFEST)
|
||||||
cleanupOutdatedCaches()
|
cleanupOutdatedCaches()
|
||||||
|
clientsClaim()
|
||||||
|
|
||||||
|
// Always fetch the live deploy version, even under an older precache.
|
||||||
|
registerRoute(({ url }) => url.pathname === '/version.json', new NetworkOnly())
|
||||||
|
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
if (event.data?.type === 'SKIP_WAITING') {
|
||||||
|
void self.skipWaiting()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
interface PushPayload {
|
interface PushPayload {
|
||||||
title?: string
|
title?: string
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
carryOverFromPreviousDay,
|
||||||
|
getClosingGreywaterLevel,
|
||||||
|
hasCarryOverFromPreviousDay
|
||||||
|
} from './logEntryTankLevels.js'
|
||||||
|
|
||||||
|
describe('logEntryTankLevels greywater carry-over', () => {
|
||||||
|
it('returns previous greywater level as starting value', () => {
|
||||||
|
const carryOver = carryOverFromPreviousDay({
|
||||||
|
destination: 'Oslo',
|
||||||
|
freshwater: { morning: 100, refilled: 0, evening: 80, consumption: 20 },
|
||||||
|
fuel: { morning: 200, refilled: 0, evening: 150, consumption: 50 },
|
||||||
|
greywater: { level: 42 }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(carryOver.greywaterLevel).toBe(42)
|
||||||
|
expect(carryOver.freshwater.morning).toBe(80)
|
||||||
|
expect(carryOver.fuel.morning).toBe(150)
|
||||||
|
expect(carryOver.departure).toBe('Oslo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults greywater to 0 when previous day has none', () => {
|
||||||
|
expect(carryOverFromPreviousDay(null).greywaterLevel).toBe(0)
|
||||||
|
expect(getClosingGreywaterLevel(undefined)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats greywater level as carry-over candidate', () => {
|
||||||
|
expect(
|
||||||
|
hasCarryOverFromPreviousDay({
|
||||||
|
freshwater: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
|
||||||
|
fuel: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
|
||||||
|
greywaterLevel: 15,
|
||||||
|
departure: ''
|
||||||
|
})
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -48,6 +48,7 @@ export interface LogEntryTankSource {
|
|||||||
export interface CarryOverFromPreviousDay {
|
export interface CarryOverFromPreviousDay {
|
||||||
freshwater: TankLevels
|
freshwater: TankLevels
|
||||||
fuel: TankLevels
|
fuel: TankLevels
|
||||||
|
greywaterLevel: number
|
||||||
departure: string
|
departure: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +61,10 @@ export function formatTankLiters(liters: number): string {
|
|||||||
return Number.isInteger(liters) ? String(liters) : liters.toFixed(1)
|
return Number.isInteger(liters) ? String(liters) : liters.toFixed(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getClosingGreywaterLevel(greywater?: { level?: number } | null): number {
|
||||||
|
return Number(greywater?.level) || 0
|
||||||
|
}
|
||||||
|
|
||||||
export function carryOverTankLevelsFromPreviousDay(previousEntry?: LogEntryTankSource | null): { freshwater: TankLevels; fuel: TankLevels } {
|
export function carryOverTankLevelsFromPreviousDay(previousEntry?: LogEntryTankSource | null): { freshwater: TankLevels; fuel: TankLevels } {
|
||||||
if (!previousEntry) {
|
if (!previousEntry) {
|
||||||
return { freshwater: emptyTankLevels(), fuel: emptyTankLevels() }
|
return { freshwater: emptyTankLevels(), fuel: emptyTankLevels() }
|
||||||
@@ -74,10 +79,16 @@ export function carryOverTankLevelsFromPreviousDay(previousEntry?: LogEntryTankS
|
|||||||
export function carryOverFromPreviousDay(previousEntry?: LogEntryTankSource | null): CarryOverFromPreviousDay {
|
export function carryOverFromPreviousDay(previousEntry?: LogEntryTankSource | null): CarryOverFromPreviousDay {
|
||||||
const { freshwater, fuel } = carryOverTankLevelsFromPreviousDay(previousEntry)
|
const { freshwater, fuel } = carryOverTankLevelsFromPreviousDay(previousEntry)
|
||||||
const departure = previousEntry?.destination?.trim() || ''
|
const departure = previousEntry?.destination?.trim() || ''
|
||||||
|
const greywaterLevel = getClosingGreywaterLevel(previousEntry?.greywater)
|
||||||
|
|
||||||
return { freshwater, fuel, departure }
|
return { freshwater, fuel, greywaterLevel, departure }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasCarryOverFromPreviousDay(carryOver: CarryOverFromPreviousDay): boolean {
|
export function hasCarryOverFromPreviousDay(carryOver: CarryOverFromPreviousDay): boolean {
|
||||||
return carryOver.freshwater.morning > 0 || carryOver.fuel.morning > 0 || carryOver.departure.length > 0
|
return (
|
||||||
|
carryOver.freshwater.morning > 0 ||
|
||||||
|
carryOver.fuel.morning > 0 ||
|
||||||
|
carryOver.greywaterLevel > 0 ||
|
||||||
|
carryOver.departure.length > 0
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-1
@@ -2,9 +2,10 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import { VitePWA } from 'vite-plugin-pwa'
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync, writeFileSync } from 'node:fs'
|
||||||
import { resolve, dirname } from 'node:path'
|
import { resolve, dirname } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import type { Plugin } from 'vite'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
@@ -19,6 +20,19 @@ function readAppVersion(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function versionJsonPlugin(version: string): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'version-json',
|
||||||
|
writeBundle(options) {
|
||||||
|
const outDir = options.dir ?? resolve(__dirname, 'dist')
|
||||||
|
writeFileSync(
|
||||||
|
resolve(outDir, 'version.json'),
|
||||||
|
`${JSON.stringify({ version }, null, 2)}\n`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
@@ -42,6 +56,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
|
versionJsonPlugin(readAppVersion()),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
strategies: 'injectManifest',
|
strategies: 'injectManifest',
|
||||||
srcDir: 'src',
|
srcDir: 'src',
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ model User {
|
|||||||
collaborations Collaboration[]
|
collaborations Collaboration[]
|
||||||
pushSubscriptions PushSubscription[]
|
pushSubscriptions PushSubscription[]
|
||||||
notificationPrefs UserNotificationPrefs?
|
notificationPrefs UserNotificationPrefs?
|
||||||
|
appearancePrefs UserAppearancePrefs?
|
||||||
}
|
}
|
||||||
|
|
||||||
model PushSubscription {
|
model PushSubscription {
|
||||||
@@ -48,6 +49,15 @@ model UserNotificationPrefs {
|
|||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model UserAppearancePrefs {
|
||||||
|
userId String @id
|
||||||
|
theme String @default("auto")
|
||||||
|
colorScheme String @default("auto")
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
model Credential {
|
model Credential {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
|
|||||||
@@ -38,6 +38,17 @@ function normalizeCredentialLabel(label: unknown): string | null {
|
|||||||
return trimmed.slice(0, 64)
|
return trimmed.slice(0, 64)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VALID_THEMES = new Set(['auto', 'ocean', 'material', 'cupertino'])
|
||||||
|
const VALID_COLOR_SCHEMES = new Set(['auto', 'light', 'dark'])
|
||||||
|
|
||||||
|
function parseThemePreference(value: unknown): string | null {
|
||||||
|
return typeof value === 'string' && VALID_THEMES.has(value) ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseColorSchemePreference(value: unknown): string | null {
|
||||||
|
return typeof value === 'string' && VALID_COLOR_SCHEMES.has(value) ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
router.post('/register-options', async (req, res) => {
|
router.post('/register-options', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { username } = req.body
|
const { username } = req.body
|
||||||
@@ -426,6 +437,57 @@ router.post('/rotate-recovery', requireReauth, async (req: any, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.get('/appearance-prefs', requireUser, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const prefs = await prisma.userAppearancePrefs.findUnique({
|
||||||
|
where: { userId: req.userId }
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
theme: prefs?.theme ?? 'auto',
|
||||||
|
colorScheme: prefs?.colorScheme ?? 'auto',
|
||||||
|
persisted: prefs != null
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error reading appearance prefs:', error)
|
||||||
|
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.put('/appearance-prefs', requireUser, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const theme = parseThemePreference(req.body?.theme)
|
||||||
|
const colorScheme = parseColorSchemePreference(req.body?.colorScheme)
|
||||||
|
if (!theme || !colorScheme) {
|
||||||
|
return res.status(400).json({ error: 'Invalid theme or colorScheme' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefs = await prisma.userAppearancePrefs.upsert({
|
||||||
|
where: { userId: req.userId },
|
||||||
|
create: {
|
||||||
|
userId: req.userId,
|
||||||
|
theme,
|
||||||
|
colorScheme,
|
||||||
|
updatedAt: new Date()
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
theme,
|
||||||
|
colorScheme,
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
theme: prefs.theme,
|
||||||
|
colorScheme: prefs.colorScheme,
|
||||||
|
persisted: true
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error updating appearance prefs:', error)
|
||||||
|
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
router.get('/profile', requireUser, async (req: any, res) => {
|
router.get('/profile', requireUser, async (req: any, res) => {
|
||||||
try {
|
try {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
|
|||||||
Reference in New Issue
Block a user