feat & docs: implement multi-logbook database cache, API routes, and switcher dashboard
This commit is contained in:
@@ -320,3 +320,466 @@ body {
|
||||
line-height: 150%;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Dashboard Layout and Styles */
|
||||
.dashboard-container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid rgba(212, 175, 55, 0.15);
|
||||
}
|
||||
|
||||
.header-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
color: #fbbf24;
|
||||
filter: drop-shadow(0 0 8px rgba(251, 191, 36, 0.4));
|
||||
}
|
||||
|
||||
.header-brand h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #fef08a 0%, #d97706 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.header-brand .subtitle {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin: 2px 0 0 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.conn-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.conn-status.online {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #4ade80;
|
||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
.conn-status.offline {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #f87171;
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.skipper-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #94a3b8;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: rgba(217, 119, 6, 0.1);
|
||||
border-color: #d97706;
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.btn-icon.logout:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: #ef4444;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.dashboard-main {
|
||||
display: grid;
|
||||
grid-template-columns: 350px 1fr;
|
||||
gap: 32px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dashboard-main {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.create-section {
|
||||
background: rgba(11, 12, 16, 0.6);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(212, 175, 55, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.create-section h2 {
|
||||
font-size: 18px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.dashboard-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.list-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.section-title-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section-title-bar h2 {
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.btn-refresh {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-refresh:hover {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: rotation 1s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes rotation {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(359deg); }
|
||||
}
|
||||
|
||||
.dashboard-status-msg {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.logbooks-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.logbook-card {
|
||||
background: rgba(11, 12, 16, 0.6);
|
||||
backdrop-filter: blur(15px);
|
||||
-webkit-backdrop-filter: blur(15px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.logbook-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(212, 175, 55, 0.4);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
||||
background: rgba(11, 12, 16, 0.75);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
background: rgba(217, 119, 6, 0.1);
|
||||
color: #fbbf24;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid rgba(217, 119, 6, 0.2);
|
||||
}
|
||||
|
||||
.card-info {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-info h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sync-badge {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sync-badge.synced {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.sync-badge.local {
|
||||
background: rgba(234, 179, 8, 0.1);
|
||||
color: #fde047;
|
||||
}
|
||||
|
||||
.date-badge {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease;
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
.logbook-card:hover .btn-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
color: #f43f5e;
|
||||
background: rgba(244, 63, 94, 0.1);
|
||||
}
|
||||
|
||||
/* Active Logbook App Layout */
|
||||
.app-layout {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid rgba(212, 175, 55, 0.15);
|
||||
}
|
||||
|
||||
.app-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #fbbf24;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
background: rgba(217, 119, 6, 0.1);
|
||||
border-color: #d97706;
|
||||
}
|
||||
|
||||
.app-title-area h2 {
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.app-title-area .app-subtitle {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin: 2px 0 0 0;
|
||||
}
|
||||
|
||||
.app-body {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: rgba(11, 12, 16, 0.6);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sidebar-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sidebar-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.sidebar-btn.active {
|
||||
background: rgba(217, 119, 6, 0.1);
|
||||
border: 1px solid rgba(217, 119, 6, 0.2);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
background: rgba(11, 12, 16, 0.6);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(212, 175, 55, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
min-height: 400px;
|
||||
box-sizing: border-box;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.tab-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 300px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.tab-placeholder h3 {
|
||||
font-size: 24px;
|
||||
color: #fbbf24;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tab-placeholder p {
|
||||
max-width: 400px;
|
||||
line-height: 150%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
+170
-26
@@ -1,61 +1,205 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import './App.css'
|
||||
import AuthOnboarding from './components/AuthOnboarding.tsx'
|
||||
import LogbookDashboard from './components/LogbookDashboard.tsx'
|
||||
import { getActiveMasterKey, logoutUser } from './services/auth.js'
|
||||
import { Anchor, LogOut, ShieldCheck, Database } from 'lucide-react'
|
||||
import { Ship, LogOut, ChevronLeft, Users, Compass, FileText, Settings, Wifi, WifiOff } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
function App() {
|
||||
const { t } = useTranslation()
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [username, setUsername] = useState<string | null>(null)
|
||||
const [activeLogbookId, setActiveLogbookId] = useState<string | null>(null)
|
||||
const [activeLogbookTitle, setActiveLogbookTitle] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<'vessel' | 'crew' | 'deviation' | 'logs' | 'settings'>('logs')
|
||||
const [online, setOnline] = useState(navigator.onLine)
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setOnline(true)
|
||||
const handleOffline = () => setOnline(false)
|
||||
window.addEventListener('online', handleOnline)
|
||||
window.addEventListener('offline', handleOffline)
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline)
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const savedUser = localStorage.getItem('active_username')
|
||||
const key = getActiveMasterKey()
|
||||
if (savedUser && key) {
|
||||
setIsAuthenticated(true)
|
||||
setUsername(savedUser)
|
||||
const savedLogbookId = localStorage.getItem('active_logbook_id')
|
||||
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
|
||||
if (savedLogbookId && savedLogbookTitle) {
|
||||
setActiveLogbookId(savedLogbookId)
|
||||
setActiveLogbookTitle(savedLogbookTitle)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleAuthenticated = () => {
|
||||
setIsAuthenticated(true)
|
||||
setUsername(localStorage.getItem('active_username'))
|
||||
const savedLogbookId = localStorage.getItem('active_logbook_id')
|
||||
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
|
||||
if (savedLogbookId && savedLogbookTitle) {
|
||||
setActiveLogbookId(savedLogbookId)
|
||||
setActiveLogbookTitle(savedLogbookTitle)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
logoutUser()
|
||||
setIsAuthenticated(false)
|
||||
setUsername(null)
|
||||
setActiveLogbookId(null)
|
||||
setActiveLogbookTitle(null)
|
||||
localStorage.removeItem('active_logbook_id')
|
||||
localStorage.removeItem('active_logbook_title')
|
||||
}
|
||||
|
||||
const handleSelectLogbook = (id: string, title: string) => {
|
||||
setActiveLogbookId(id)
|
||||
setActiveLogbookTitle(title)
|
||||
localStorage.setItem('active_logbook_id', id)
|
||||
localStorage.setItem('active_logbook_title', title)
|
||||
}
|
||||
|
||||
const handleBackToDashboard = () => {
|
||||
setActiveLogbookId(null)
|
||||
setActiveLogbookTitle(null)
|
||||
localStorage.removeItem('active_logbook_id')
|
||||
localStorage.removeItem('active_logbook_title')
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <AuthOnboarding onAuthenticated={handleAuthenticated} />
|
||||
}
|
||||
|
||||
if (!activeLogbookId) {
|
||||
return (
|
||||
<LogbookDashboard
|
||||
onSelectLogbook={handleSelectLogbook}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard-mock">
|
||||
<div className="auth-brand">
|
||||
<Anchor className="auth-icon accent" size={60} style={{ color: '#fbbf24' }} />
|
||||
<h2>Kapteins Daagbox</h2>
|
||||
<p className="tagline" style={{ color: '#34d399' }}>
|
||||
<ShieldCheck size={16} style={{ display: 'inline', marginRight: 4, verticalAlign: 'text-bottom' }} />
|
||||
Session Decrypted (Zero-Knowledge)
|
||||
</p>
|
||||
</div>
|
||||
<div className="app-layout">
|
||||
{/* Active Logbook Header */}
|
||||
<header className="app-header">
|
||||
<div className="app-header-left">
|
||||
<button className="btn-back" onClick={handleBackToDashboard}>
|
||||
<ChevronLeft size={16} />
|
||||
{t('nav.dashboard')}
|
||||
</button>
|
||||
<div className="app-title-area">
|
||||
<h2>{activeLogbookTitle}</h2>
|
||||
<p className="app-subtitle">{t('app.name')} / {activeLogbookId.substring(0, 8)}...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'left', margin: '30px 0', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 10, padding: 20, background: 'rgba(255,255,255,0.02)' }}>
|
||||
<p style={{ color: '#e2e8f0', margin: '0 0 10px 0' }}><strong>Skipper:</strong> {username}</p>
|
||||
<p style={{ color: '#e2e8f0', margin: '0 0 10px 0' }}><strong>Status:</strong> E2E Secure Connection Active</p>
|
||||
<p style={{ color: '#94a3b8', margin: 0, display: 'flex', alignItems: 'center', gap: 6, fontSize: 13.5 }}>
|
||||
<Database size={15} />
|
||||
Local IndexedDB synced with zero-knowledge PostgreSQL server payload
|
||||
</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<div className={`conn-status ${online ? 'online' : 'offline'}`} title={online ? 'Online' : 'Offline'}>
|
||||
{online ? <Wifi size={18} /> : <WifiOff size={18} />}
|
||||
<span>{online ? 'Online' : t('sync.status_offline')}</span>
|
||||
</div>
|
||||
|
||||
<button className="btn secondary" onClick={handleLogout}>
|
||||
<LogOut size={18} />
|
||||
Abmelden (Logout)
|
||||
</button>
|
||||
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Active Workspace */}
|
||||
<div className="app-body">
|
||||
{/* Navigation Sidebar */}
|
||||
<aside className="app-sidebar">
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('logs')}
|
||||
>
|
||||
<FileText size={18} />
|
||||
{t('nav.logs')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'vessel' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('vessel')}
|
||||
>
|
||||
<Ship size={18} />
|
||||
{t('nav.vessel')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('crew')}
|
||||
>
|
||||
<Users size={18} />
|
||||
{t('nav.crew')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'deviation' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('deviation')}
|
||||
>
|
||||
<Compass size={18} />
|
||||
{t('nav.deviation')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'settings' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('settings')}
|
||||
>
|
||||
<Settings size={18} />
|
||||
{t('nav.settings')}
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
{/* Tab Content Panels (Placeholder until Phase 3) */}
|
||||
<main className="app-content">
|
||||
{activeTab === 'logs' && (
|
||||
<div className="tab-placeholder">
|
||||
<FileText size={48} className="header-logo" />
|
||||
<h3>{t('nav.logs')}</h3>
|
||||
<p>Journal event entries, GPS navigation records, and meteorological reports will be listed and edited here.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'vessel' && (
|
||||
<div className="tab-placeholder">
|
||||
<Ship size={48} className="header-logo" />
|
||||
<h3>{t('nav.vessel')}</h3>
|
||||
<p>Master vessel profile details such as name, home port, call sign, and MMSI registration are managed here.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'crew' && (
|
||||
<div className="tab-placeholder">
|
||||
<Users size={48} className="header-logo" />
|
||||
<h3>{t('nav.crew')}</h3>
|
||||
<p>Skipper, mate, and crew records conforming to marine credentials list are maintained here.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'deviation' && (
|
||||
<div className="tab-placeholder">
|
||||
<Compass size={48} className="header-logo" />
|
||||
<h3>{t('nav.deviation')}</h3>
|
||||
<p>Magnetic compass deviation table calibration grids and calculations are rendered here.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'settings' && (
|
||||
<div className="tab-placeholder">
|
||||
<Settings size={48} className="header-logo" />
|
||||
<h3>{t('nav.settings')}</h3>
|
||||
<p>Logbook sync properties, local cache maintenance, and CSV data tools are configured here.</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { fetchLogbooks, createLogbook, deleteLogbook, type DecryptedLogbook } from '../services/logbook.js'
|
||||
import { logoutUser } from '../services/auth.js'
|
||||
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
|
||||
|
||||
interface LogbookDashboardProps {
|
||||
onSelectLogbook: (id: string, title: string) => void
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookDashboardProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const [logbooks, setLogbooks] = useState<DecryptedLogbook[]>([])
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [online, setOnline] = useState(navigator.onLine)
|
||||
const [username] = useState(localStorage.getItem('active_username') || 'Skipper')
|
||||
|
||||
// Listen to connectivity changes
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setOnline(true)
|
||||
const handleOffline = () => setOnline(false)
|
||||
window.addEventListener('online', handleOnline)
|
||||
window.addEventListener('offline', handleOffline)
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline)
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load logbooks on mount
|
||||
useEffect(() => {
|
||||
loadLogbooks()
|
||||
}, [])
|
||||
|
||||
const loadLogbooks = async (isRefresh = false) => {
|
||||
if (isRefresh) setRefreshing(true)
|
||||
else setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await fetchLogbooks()
|
||||
setLogbooks(data)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load logbooks')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newTitle.trim()) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const created = await createLogbook(newTitle.trim())
|
||||
setLogbooks((prev) => [created, ...prev])
|
||||
setNewTitle('')
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to create logbook')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation() // Prevent selecting the logbook when clicking delete
|
||||
|
||||
if (window.confirm(t('dashboard.delete_confirm'))) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await deleteLogbook(id)
|
||||
setLogbooks((prev) => prev.filter((lb) => lb.id !== id))
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to delete logbook')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
logoutUser()
|
||||
onLogout()
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
|
||||
i18n.changeLanguage(nextLang)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard-container">
|
||||
{/* Premium Dashboard Header */}
|
||||
<header className="dashboard-header">
|
||||
<div className="header-brand">
|
||||
<Ship className="header-logo" size={32} />
|
||||
<div>
|
||||
<h1>{t('app.name')}</h1>
|
||||
<p className="subtitle">{t('app.tagline')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="header-actions">
|
||||
{/* Connection Indicator */}
|
||||
<div className={`conn-status ${online ? 'online' : 'offline'}`} title={online ? 'Online' : 'Offline'}>
|
||||
{online ? <Wifi size={18} /> : <WifiOff size={18} />}
|
||||
<span>{online ? 'Online' : t('sync.status_offline')}</span>
|
||||
</div>
|
||||
|
||||
{/* Skipper profile */}
|
||||
<div className="skipper-badge">
|
||||
<User size={16} />
|
||||
<span>{username}</span>
|
||||
</div>
|
||||
|
||||
{/* Lang toggle */}
|
||||
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
||||
<Languages size={18} />
|
||||
</button>
|
||||
|
||||
{/* Logout */}
|
||||
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Dashboard Layout */}
|
||||
<main className="dashboard-main">
|
||||
{/* Left Side: Create form */}
|
||||
<section className="create-section glass">
|
||||
<h2>{t('dashboard.create_btn')}</h2>
|
||||
<form onSubmit={handleCreate} className="dashboard-form">
|
||||
<div className="input-group">
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
placeholder={t('dashboard.new_logbook_placeholder')}
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn primary" disabled={loading || !newTitle.trim()}>
|
||||
<Plus size={18} />
|
||||
{t('dashboard.create_btn')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && <div className="auth-error mt-4">{error}</div>}
|
||||
</section>
|
||||
|
||||
{/* Right Side: Logbooks list */}
|
||||
<section className="list-section">
|
||||
<div className="section-title-bar">
|
||||
<h2>{t('dashboard.title')}</h2>
|
||||
<button className="btn-refresh" onClick={() => loadLogbooks(true)} disabled={loading || refreshing}>
|
||||
<RefreshCw size={16} className={refreshing ? 'spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && !refreshing ? (
|
||||
<div className="dashboard-status-msg">{t('dashboard.loading')}</div>
|
||||
) : logbooks.length === 0 ? (
|
||||
<div className="dashboard-status-msg glass">{t('dashboard.no_logbooks')}</div>
|
||||
) : (
|
||||
<div className="logbooks-grid">
|
||||
{logbooks.map((lb) => (
|
||||
<div key={lb.id} className="logbook-card glass" onClick={() => onSelectLogbook(lb.id, lb.title)}>
|
||||
<div className="card-icon">
|
||||
<BookOpen size={24} />
|
||||
</div>
|
||||
|
||||
<div className="card-info">
|
||||
<h3>{lb.title}</h3>
|
||||
<div className="card-meta">
|
||||
<span className={`sync-badge ${lb.isSynced ? 'synced' : 'local'}`}>
|
||||
{lb.isSynced ? t('dashboard.status_synced') : t('dashboard.status_local')}
|
||||
</span>
|
||||
<span className="date-badge">
|
||||
{new Date(lb.updatedAt).toLocaleDateString(i18n.language, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="btn-delete" onClick={(e) => handleDelete(lb.id, e)} title="Delete Logbook">
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -44,6 +44,18 @@
|
||||
"coordinates": "Koordinaten",
|
||||
"weather": "Wetterbedingungen",
|
||||
"save": "Eintrag speichern"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Ihre Logbücher",
|
||||
"subtitle": "Wählen Sie ein Logbuch aus oder erstellen Sie ein neues, um Ihre Reisen zu verwalten.",
|
||||
"create_btn": "Logbuch erstellen",
|
||||
"new_logbook_placeholder": "Name des Logbuchs oder der Yacht",
|
||||
"logout": "Abmelden",
|
||||
"delete_confirm": "Sind Sie sicher, dass Sie dieses Logbuch unwiderruflich löschen möchten? Alle lokalen Daten und Server-Backups werden vernichtet.",
|
||||
"no_logbooks": "Keine Logbücher gefunden. Erstellen Sie Ihr erstes Logbuch, um zu beginnen!",
|
||||
"loading": "Logbücher werden geladen...",
|
||||
"status_synced": "Synchronisiert",
|
||||
"status_local": "Nur lokaler Cache"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,18 @@
|
||||
"coordinates": "Coordinates",
|
||||
"weather": "Weather Conditions",
|
||||
"save": "Save Entry"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Your Logbooks",
|
||||
"subtitle": "Select a logbook or create a new one to manage your journeys.",
|
||||
"create_btn": "Create Logbook",
|
||||
"new_logbook_placeholder": "Logbook or Yacht Name",
|
||||
"logout": "Logout",
|
||||
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local cache and server backups will be destroyed.",
|
||||
"no_logbooks": "No logbooks found. Create your first logbook to begin!",
|
||||
"loading": "Loading logbooks...",
|
||||
"status_synced": "Synced",
|
||||
"status_local": "Local Cache Only"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ export async function registerUser(username: string): Promise<RegistrationResult
|
||||
if (result.verified) {
|
||||
activeMasterKey = masterKey
|
||||
localStorage.setItem('active_username', username)
|
||||
localStorage.setItem('active_userid', result.userId)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -116,6 +117,7 @@ export interface LoginResult {
|
||||
encryptedMasterKeyRec: string
|
||||
encryptedMasterKeyRecIv: string
|
||||
encryptedMasterKeyRecTag: string
|
||||
userId: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +183,7 @@ export async function loginUser(username: string): Promise<LoginResult> {
|
||||
)
|
||||
activeMasterKey = decryptedMaster
|
||||
localStorage.setItem('active_username', username)
|
||||
localStorage.setItem('active_userid', result.userId)
|
||||
return { verified: true, prfSuccess: true }
|
||||
} catch (e) {
|
||||
console.warn('PRF decryption failed, falling back to recovery phrase:', e)
|
||||
@@ -194,7 +197,8 @@ export async function loginUser(username: string): Promise<LoginResult> {
|
||||
encryptedPayloads: {
|
||||
encryptedMasterKeyRec: result.encryptedMasterKeyRec,
|
||||
encryptedMasterKeyRecIv: result.encryptedMasterKeyRecIv,
|
||||
encryptedMasterKeyRecTag: result.encryptedMasterKeyRecTag
|
||||
encryptedMasterKeyRecTag: result.encryptedMasterKeyRecTag,
|
||||
userId: result.userId
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,6 +211,7 @@ export async function completeLoginWithRecovery(
|
||||
encryptedMasterKeyRec: string
|
||||
encryptedMasterKeyRecIv: string
|
||||
encryptedMasterKeyRecTag: string
|
||||
userId: string
|
||||
}
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
@@ -219,6 +224,7 @@ export async function completeLoginWithRecovery(
|
||||
)
|
||||
activeMasterKey = decryptedMaster
|
||||
localStorage.setItem('active_username', username)
|
||||
localStorage.setItem('active_userid', encryptedPayloads.userId)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to decrypt master key with recovery phrase:', error)
|
||||
@@ -229,4 +235,5 @@ export async function completeLoginWithRecovery(
|
||||
export function logoutUser() {
|
||||
activeMasterKey = null
|
||||
localStorage.removeItem('active_username')
|
||||
localStorage.removeItem('active_userid')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import Dexie, { type Table } from 'dexie'
|
||||
|
||||
export interface LocalLogbook {
|
||||
id: string
|
||||
encryptedTitle: string
|
||||
updatedAt: string
|
||||
isSynced: number // 1 = yes, 0 = pending local modifications
|
||||
}
|
||||
|
||||
export interface LocalYacht {
|
||||
logbookId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface LocalCrew {
|
||||
payloadId: string
|
||||
logbookId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface LocalDeviation {
|
||||
logbookId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface LocalEntry {
|
||||
payloadId: string
|
||||
logbookId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface SyncQueueItem {
|
||||
id?: number
|
||||
action: 'create' | 'update' | 'delete'
|
||||
type: 'yacht' | 'crew' | 'deviation' | 'entry' | 'logbook'
|
||||
payloadId: string // payloadId or logbookId depending on the type
|
||||
logbookId: string
|
||||
data: string // JSON representation of the local record
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
class DaagboxDatabase extends Dexie {
|
||||
logbooks!: Table<LocalLogbook>
|
||||
yachts!: Table<LocalYacht>
|
||||
crews!: Table<LocalCrew>
|
||||
deviations!: Table<LocalDeviation>
|
||||
entries!: Table<LocalEntry>
|
||||
syncQueue!: Table<SyncQueueItem>
|
||||
|
||||
constructor() {
|
||||
super('DaagboxDatabase')
|
||||
this.version(1).stores({
|
||||
logbooks: 'id, encryptedTitle, updatedAt, isSynced',
|
||||
yachts: 'logbookId, updatedAt',
|
||||
crews: 'payloadId, logbookId, updatedAt',
|
||||
deviations: 'logbookId, updatedAt',
|
||||
entries: 'payloadId, logbookId, updatedAt',
|
||||
syncQueue: '++id, action, type, payloadId, logbookId'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const db = new DaagboxDatabase()
|
||||
@@ -0,0 +1,215 @@
|
||||
import { db, type LocalLogbook } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { encryptJson, decryptJson } from './crypto.js'
|
||||
|
||||
const API_BASE = 'http://localhost:5000/api/logbooks'
|
||||
|
||||
export interface DecryptedLogbook {
|
||||
id: string
|
||||
title: string
|
||||
updatedAt: string
|
||||
isSynced: boolean
|
||||
}
|
||||
|
||||
// Helper to decrypt a logbook's title using the active master key
|
||||
export async function decryptLogbookTitle(encryptedTitle: string): Promise<string> {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) {
|
||||
throw new Error('Master key not found. User must log in.')
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(encryptedTitle)
|
||||
const decrypted = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, masterKey)
|
||||
return decrypted
|
||||
} catch (error) {
|
||||
console.error('Failed to decrypt logbook title:', error)
|
||||
return '[Decryption Failed]'
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch logbooks from the server (if online) and update local cache, falling back to cache if offline
|
||||
export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) {
|
||||
throw new Error('User not authenticated')
|
||||
}
|
||||
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) {
|
||||
throw new Error('Master key not found. User must log in.')
|
||||
}
|
||||
|
||||
if (navigator.onLine) {
|
||||
try {
|
||||
const response = await fetch(API_BASE, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-Id': userId
|
||||
}
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const serverLogbooks = await response.json()
|
||||
|
||||
// Update Dexie database cache
|
||||
const localLogbooks: LocalLogbook[] = serverLogbooks.map((lb: any) => ({
|
||||
id: lb.id,
|
||||
encryptedTitle: lb.encryptedTitle,
|
||||
updatedAt: lb.updatedAt || new Date().toISOString(),
|
||||
isSynced: 1
|
||||
}))
|
||||
|
||||
// Clear existing cache for this user and insert new ones
|
||||
// Note: Currently Dexie schema doesn't store userId on logbook table, but we can bulkPut.
|
||||
await db.logbooks.bulkPut(localLogbooks)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Network request failed. Reading logbooks from offline cache:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve all from Dexie cache
|
||||
const cachedLogbooks = await db.logbooks.toArray()
|
||||
|
||||
// Decrypt titles
|
||||
const decrypted: DecryptedLogbook[] = []
|
||||
for (const lb of cachedLogbooks) {
|
||||
const title = await decryptLogbookTitle(lb.encryptedTitle)
|
||||
decrypted.push({
|
||||
id: lb.id,
|
||||
title,
|
||||
updatedAt: lb.updatedAt,
|
||||
isSynced: lb.isSynced === 1
|
||||
})
|
||||
}
|
||||
|
||||
return decrypted
|
||||
}
|
||||
|
||||
// Create a new logbook. Encrypts the title and registers locally + on server
|
||||
export async function createLogbook(title: string): Promise<DecryptedLogbook> {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) {
|
||||
throw new Error('User not authenticated')
|
||||
}
|
||||
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) {
|
||||
throw new Error('Master key not found. User must log in.')
|
||||
}
|
||||
|
||||
// 1. E2E Encrypt title
|
||||
const encrypted = await encryptJson(title, masterKey)
|
||||
const encryptedTitleStr = JSON.stringify(encrypted)
|
||||
const localId = window.crypto.randomUUID()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
if (navigator.onLine) {
|
||||
try {
|
||||
const response = await fetch(API_BASE, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-Id': userId
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: localId,
|
||||
encryptedTitle: encryptedTitleStr
|
||||
})
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const serverLb = await response.json()
|
||||
await db.logbooks.put({
|
||||
id: serverLb.id,
|
||||
encryptedTitle: serverLb.encryptedTitle,
|
||||
updatedAt: serverLb.updatedAt,
|
||||
isSynced: 1
|
||||
})
|
||||
|
||||
return {
|
||||
id: serverLb.id,
|
||||
title,
|
||||
updatedAt: serverLb.updatedAt,
|
||||
isSynced: true
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to save logbook to server, saving locally instead:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// If offline or request failed, store locally as unsynced and add to queue
|
||||
await db.logbooks.put({
|
||||
id: localId,
|
||||
encryptedTitle: encryptedTitleStr,
|
||||
updatedAt: now,
|
||||
isSynced: 0
|
||||
})
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'create',
|
||||
type: 'logbook',
|
||||
payloadId: localId,
|
||||
logbookId: localId,
|
||||
data: JSON.stringify({ encryptedTitle: encryptedTitleStr }),
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
return {
|
||||
id: localId,
|
||||
title,
|
||||
updatedAt: now,
|
||||
isSynced: false
|
||||
}
|
||||
}
|
||||
|
||||
// Delete a logbook and all associated payloads locally and on server
|
||||
export async function deleteLogbook(id: string): Promise<void> {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) {
|
||||
throw new Error('User not authenticated')
|
||||
}
|
||||
|
||||
if (navigator.onLine) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-User-Id': userId
|
||||
}
|
||||
})
|
||||
if (!response.ok) {
|
||||
console.warn('Server deletion failed or was rejected')
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Server delete request failed, queuing locally:', error)
|
||||
await db.syncQueue.put({
|
||||
action: 'delete',
|
||||
type: 'logbook',
|
||||
payloadId: id,
|
||||
logbookId: id,
|
||||
data: '',
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
} else {
|
||||
await db.syncQueue.put({
|
||||
action: 'delete',
|
||||
type: 'logbook',
|
||||
payloadId: id,
|
||||
logbookId: id,
|
||||
data: '',
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
// Perform local cascading cleanup
|
||||
await db.logbooks.delete(id)
|
||||
await db.yachts.where({ logbookId: id }).delete()
|
||||
await db.crews.where({ logbookId: id }).delete()
|
||||
await db.deviations.where({ logbookId: id }).delete()
|
||||
await db.entries.where({ logbookId: id }).delete()
|
||||
}
|
||||
Reference in New Issue
Block a user