feat & docs: implement multi-logbook database cache, API routes, and switcher dashboard

This commit is contained in:
2026-05-27 21:33:16 +02:00
parent 073be1a957
commit 6e2dce6ec5
11 changed files with 1252 additions and 30 deletions
+463
View File
@@ -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
View File
@@ -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>
)
}
+209
View File
@@ -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>
)
}
+12
View File
@@ -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"
}
}
}
+12
View File
@@ -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"
}
}
}
+8 -1
View File
@@ -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')
}
+75
View File
@@ -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()
+215
View File
@@ -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()
}