feat: implement Phase 4 (CSV export, share, sync indicators, OS themes) and add dev starter script
This commit is contained in:
@@ -72,8 +72,8 @@ Plans:
|
||||
**Plans**: 2 plans
|
||||
|
||||
Plans:
|
||||
- [ ] 04-01: Create client-side decryption CSV builder and hook it up to standard browser download and Web Share API.
|
||||
- [ ] 04-02: Implement online/offline connection state detectors, sync progress bars, and OS-adaptive UI themes.
|
||||
- [x] 04-01: Create client-side decryption CSV builder and hook it up to standard browser download and Web Share API.
|
||||
- [x] 04-02: Implement online/offline connection state detectors, sync progress bars, and OS-adaptive UI themes.
|
||||
|
||||
## Progress
|
||||
|
||||
@@ -83,6 +83,6 @@ Phases execute in numeric order: 1 → 2 → 3 → 4
|
||||
| Phase | Plans Complete | Status | Completed |
|
||||
|-------|----------------|--------|-----------|
|
||||
| 1. Foundation, Auth & E2E Crypto | 3/3 | Completed | 2026-05-27 |
|
||||
| 2. Sync Protocol & Multi-Logbooks | 0/2 | Not started | - |
|
||||
| 3. Master Data & Log entries | 0/3 | Not started | - |
|
||||
| 4. CSV Export & UI Polish | 0/2 | Not started | - |
|
||||
| 2. Sync Protocol & Multi-Logbooks | 2/2 | Completed | 2026-05-27 |
|
||||
| 3. Master Data & Log entries | 3/3 | Completed | 2026-05-27 |
|
||||
| 4. CSV Export & UI Polish | 2/2 | Completed | 2026-05-28 |
|
||||
|
||||
+8
-8
@@ -9,19 +9,19 @@ See: .planning/PROJECT.md (updated 2026-05-26)
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 3 of 4 (Master Data & Log entries)
|
||||
Plan: 3 of 3 in current phase
|
||||
Phase: 4 of 4 (CSV Export & UI Polish)
|
||||
Plan: 2 of 2 in current phase
|
||||
Status: Completed
|
||||
Last activity: 2026-05-27 — Plan 03-03 completed (Logbook event records, browser Geolocation tracker, and OpenWeatherMap weather API integration complete)
|
||||
Last activity: 2026-05-28 — Phase 4 completed (CSV Export, Web Share API integration, connection/sync indicators, and OS-adaptive UI themes)
|
||||
|
||||
Progress: [████████░░] 80%
|
||||
Progress: [██████████] 100%
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
**Velocity:**
|
||||
- Total plans completed: 8
|
||||
- Total plans completed: 10
|
||||
- Average duration: 15 min
|
||||
- Total execution time: 2.0 hours
|
||||
- Total execution time: 2.5 hours
|
||||
|
||||
**By Phase:**
|
||||
|
||||
@@ -30,10 +30,10 @@ Progress: [████████░░] 80%
|
||||
| 1. Foundation, Auth & E2E Crypto | 3/3 | Completed | - |
|
||||
| 2. Sync Protocol & Multi-Logbooks | 2/2 | Completed | - |
|
||||
| 3. Master Data & Log entries | 3/3 | Completed | - |
|
||||
| 4. CSV Export & UI Polish | 0/2 | - | - |
|
||||
| 4. CSV Export & UI Polish | 2/2 | Completed | - |
|
||||
|
||||
**Recent Trend:**
|
||||
- Last 5 plans: [02-01, 02-02, 03-01, 03-02, 03-03]
|
||||
- Last 5 plans: [03-02, 03-03, 04-01, 04-02]
|
||||
- Trend: Stable
|
||||
|
||||
*Updated after each plan completion*
|
||||
|
||||
@@ -1059,3 +1059,283 @@ body {
|
||||
.text-sm {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ========================================== */
|
||||
/* PHASE 4: CONNECTION & SYNC INDICATORS */
|
||||
/* ========================================== */
|
||||
|
||||
.sync-progress-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #fbbf24, #d97706, #fbbf24);
|
||||
background-size: 200% 100%;
|
||||
animation: sync-slide 1.5s infinite linear;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
@keyframes sync-slide {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: 0 0; }
|
||||
}
|
||||
|
||||
.conn-status.warning {
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
color: #fbbf24;
|
||||
border: 1px solid rgba(251, 191, 36, 0.25);
|
||||
}
|
||||
|
||||
.pulse-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: #fbbf24;
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
animation: pulse-animation 1.5s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes pulse-animation {
|
||||
0% { transform: scale(0.9); opacity: 0.6; }
|
||||
50% { transform: scale(1.2); opacity: 1; }
|
||||
100% { transform: scale(0.9); opacity: 0.6; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hide-mobile {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================== */
|
||||
/* PHASE 4: OS-ADAPTIVE UI THEMES */
|
||||
/* ========================================== */
|
||||
|
||||
/* Body Overrides via :has() */
|
||||
body:has(.theme-material) {
|
||||
background: #121212 !important;
|
||||
}
|
||||
body:has(.theme-cupertino) {
|
||||
background: #000000 !important;
|
||||
}
|
||||
|
||||
/* --- MATERIAL THEME (ANDROID/LINUX) --- */
|
||||
.theme-material {
|
||||
font-family: 'Roboto', 'Noto Sans', system-ui, -apple-system, sans-serif !important;
|
||||
}
|
||||
|
||||
/* Color & Text overrides */
|
||||
.theme-material h1,
|
||||
.theme-material h2,
|
||||
.theme-material h3,
|
||||
.theme-material h4,
|
||||
.theme-material .form-header h2,
|
||||
.theme-material .form-icon,
|
||||
.theme-material .header-logo,
|
||||
.theme-material .card-icon,
|
||||
.theme-material .btn-back,
|
||||
.theme-material .cell-label {
|
||||
color: #00adb5 !important;
|
||||
}
|
||||
|
||||
/* Card Overrides */
|
||||
.theme-material .auth-card,
|
||||
.theme-material .form-card,
|
||||
.theme-material .create-section,
|
||||
.theme-material .app-sidebar,
|
||||
.theme-material .app-content,
|
||||
.theme-material .logbook-card,
|
||||
.theme-material .crew-member-card,
|
||||
.theme-material .member-editor-card {
|
||||
background: #1e1e1e !important;
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
border: 1px solid #2d2d2d !important;
|
||||
border-radius: 4px !important;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3) !important;
|
||||
}
|
||||
|
||||
/* Input Overrides */
|
||||
.theme-material .input-text,
|
||||
.theme-material .input-textarea,
|
||||
.theme-material select.input-text {
|
||||
background: #2a2a2a !important;
|
||||
border: 1px solid #3d3d3d !important;
|
||||
border-radius: 4px !important;
|
||||
color: #f1f5f9 !important;
|
||||
}
|
||||
.theme-material .input-text:focus,
|
||||
.theme-material .input-textarea:focus {
|
||||
border-color: #00adb5 !important;
|
||||
box-shadow: 0 0 0 2px rgba(0, 173, 181, 0.2) !important;
|
||||
}
|
||||
|
||||
/* Button Overrides */
|
||||
.theme-material .btn.primary {
|
||||
background: #00adb5 !important;
|
||||
color: #ffffff !important;
|
||||
border-radius: 4px !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.theme-material .btn.primary:hover {
|
||||
background: #008f95 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
.theme-material .btn.secondary,
|
||||
.theme-material .btn-back {
|
||||
background: #2a2a2a !important;
|
||||
border: 1px solid #3d3d3d !important;
|
||||
color: #f1f5f9 !important;
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
.theme-material .btn.secondary:hover,
|
||||
.theme-material .btn-back:hover {
|
||||
background: #333333 !important;
|
||||
}
|
||||
|
||||
/* Sidebar Overrides */
|
||||
.theme-material .sidebar-btn {
|
||||
border-radius: 0 !important;
|
||||
border-left: 4px solid transparent !important;
|
||||
}
|
||||
.theme-material .sidebar-btn.active {
|
||||
background: rgba(0, 173, 181, 0.08) !important;
|
||||
border-left: 4px solid #00adb5 !important;
|
||||
color: #00adb5 !important;
|
||||
border-top: none !important;
|
||||
border-right: none !important;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
/* Header Overrides */
|
||||
.theme-material .app-header {
|
||||
border-bottom: 1px solid #2d2d2d !important;
|
||||
}
|
||||
|
||||
/* Tables and Grids */
|
||||
.theme-material .events-table th {
|
||||
color: #00adb5 !important;
|
||||
border-bottom: 2px solid #2d2d2d !important;
|
||||
}
|
||||
.theme-material .events-table td {
|
||||
border-bottom: 1px solid #2d2d2d !important;
|
||||
}
|
||||
.theme-material .events-scroll-container {
|
||||
border: 1px solid #2d2d2d !important;
|
||||
}
|
||||
|
||||
/* Progress bar override for Material */
|
||||
.theme-material ~ .sync-progress-bar {
|
||||
background: linear-gradient(90deg, #00adb5, #008f95, #00adb5) !important;
|
||||
}
|
||||
|
||||
|
||||
/* --- CUPERTINO THEME (iOS/macOS) --- */
|
||||
.theme-cupertino {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Helvetica, Arial, sans-serif !important;
|
||||
}
|
||||
|
||||
/* Color & Text overrides */
|
||||
.theme-cupertino h1,
|
||||
.theme-cupertino h2,
|
||||
.theme-cupertino h3,
|
||||
.theme-cupertino h4,
|
||||
.theme-cupertino .form-header h2,
|
||||
.theme-cupertino .form-icon,
|
||||
.theme-cupertino .header-logo,
|
||||
.theme-cupertino .card-icon,
|
||||
.theme-cupertino .btn-back,
|
||||
.theme-cupertino .cell-label {
|
||||
color: #0a84ff !important;
|
||||
}
|
||||
|
||||
/* Card Overrides */
|
||||
.theme-cupertino .auth-card,
|
||||
.theme-cupertino .form-card,
|
||||
.theme-cupertino .create-section,
|
||||
.theme-cupertino .app-sidebar,
|
||||
.theme-cupertino .app-content,
|
||||
.theme-cupertino .logbook-card,
|
||||
.theme-cupertino .crew-member-card,
|
||||
.theme-cupertino .member-editor-card {
|
||||
background: rgba(28, 28, 30, 0.7) !important;
|
||||
backdrop-filter: blur(25px) !important;
|
||||
-webkit-backdrop-filter: blur(25px) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||
border-radius: 12px !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Input Overrides */
|
||||
.theme-cupertino .input-text,
|
||||
.theme-cupertino .input-textarea,
|
||||
.theme-cupertino select.input-text {
|
||||
background: rgba(255, 255, 255, 0.05) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12) !important;
|
||||
border-radius: 8px !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
.theme-cupertino .input-text:focus,
|
||||
.theme-cupertino .input-textarea:focus {
|
||||
border-color: #0a84ff !important;
|
||||
box-shadow: 0 0 0 2px rgba(10, 132, 255, 0.25) !important;
|
||||
}
|
||||
|
||||
/* Button Overrides */
|
||||
.theme-cupertino .btn.primary {
|
||||
background: #0a84ff !important;
|
||||
color: #ffffff !important;
|
||||
border-radius: 9999px !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.theme-cupertino .btn.primary:hover {
|
||||
background: #007aff !important;
|
||||
transform: none !important;
|
||||
}
|
||||
.theme-cupertino .btn.secondary,
|
||||
.theme-cupertino .btn-back {
|
||||
background: rgba(255, 255, 255, 0.08) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12) !important;
|
||||
color: #ffffff !important;
|
||||
border-radius: 9999px !important;
|
||||
}
|
||||
.theme-cupertino .btn.secondary:hover,
|
||||
.theme-cupertino .btn-back:hover {
|
||||
background: rgba(255, 255, 255, 0.12) !important;
|
||||
}
|
||||
|
||||
/* Sidebar Overrides */
|
||||
.theme-cupertino .sidebar-btn {
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
.theme-cupertino .sidebar-btn.active {
|
||||
background: rgba(10, 132, 255, 0.12) !important;
|
||||
color: #0a84ff !important;
|
||||
border: 1px solid rgba(10, 132, 255, 0.2) !important;
|
||||
}
|
||||
|
||||
/* Header Overrides */
|
||||
.theme-cupertino .app-header {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
/* Tables and Grids */
|
||||
.theme-cupertino .events-table th {
|
||||
color: #0a84ff !important;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.15) !important;
|
||||
}
|
||||
.theme-cupertino .events-table td {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||
}
|
||||
.theme-cupertino .events-scroll-container {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||
background: rgba(28, 28, 30, 0.5) !important;
|
||||
}
|
||||
|
||||
/* Progress bar override for Cupertino */
|
||||
.theme-cupertino ~ .sync-progress-bar {
|
||||
background: linear-gradient(90deg, #0a84ff, #007aff, #0a84ff) !important;
|
||||
}
|
||||
|
||||
|
||||
+84
-29
@@ -8,7 +8,9 @@ import DeviationForm from './components/DeviationForm.tsx'
|
||||
import LogEntriesList from './components/LogEntriesList.tsx'
|
||||
import SettingsForm from './components/SettingsForm.tsx'
|
||||
import { getActiveMasterKey, logoutUser } from './services/auth.js'
|
||||
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks } from './services/sync.js'
|
||||
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
|
||||
import { db } from './services/db.js'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { Ship, LogOut, ChevronLeft, Users, Compass, FileText, Settings, Wifi, WifiOff } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -19,6 +21,43 @@ function App() {
|
||||
const [activeLogbookTitle, setActiveLogbookTitle] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<'vessel' | 'crew' | 'deviation' | 'logs' | 'settings'>('logs')
|
||||
const [online, setOnline] = useState(navigator.onLine)
|
||||
const [isSyncing, setIsSyncing] = useState(false)
|
||||
const [appliedTheme, setAppliedTheme] = useState<'ocean' | 'material' | 'cupertino'>('ocean')
|
||||
|
||||
const syncQueueCount = useLiveQuery(
|
||||
() => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(),
|
||||
[activeLogbookId]
|
||||
)
|
||||
|
||||
const updateAppliedTheme = () => {
|
||||
const configTheme = localStorage.getItem('active_theme') || 'auto'
|
||||
if (configTheme === 'auto') {
|
||||
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera
|
||||
if (/iPad|iPhone|iPod|Macintosh/.test(userAgent)) {
|
||||
setAppliedTheme('cupertino')
|
||||
} else if (/Android|Linux/.test(userAgent)) {
|
||||
setAppliedTheme('material')
|
||||
} else {
|
||||
setAppliedTheme('ocean')
|
||||
}
|
||||
} else {
|
||||
setAppliedTheme(configTheme as 'ocean' | 'material' | 'cupertino')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
updateAppliedTheme()
|
||||
window.addEventListener('theme-changed', updateAppliedTheme)
|
||||
return () => {
|
||||
window.removeEventListener('theme-changed', updateAppliedTheme)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeToSyncState((syncing) => {
|
||||
setIsSyncing(syncing)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => {
|
||||
@@ -93,44 +132,59 @@ function App() {
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <AuthOnboarding onAuthenticated={handleAuthenticated} />
|
||||
return (
|
||||
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
|
||||
<AuthOnboarding onAuthenticated={handleAuthenticated} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!activeLogbookId) {
|
||||
return (
|
||||
<LogbookDashboard
|
||||
onSelectLogbook={handleSelectLogbook}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
|
||||
<LogbookDashboard
|
||||
onSelectLogbook={handleSelectLogbook}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<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 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 className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
|
||||
{isSyncing && <div className="sync-progress-bar" />}
|
||||
<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>
|
||||
|
||||
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="header-actions">
|
||||
{syncQueueCount !== undefined && syncQueueCount > 0 && (
|
||||
<div className="conn-status warning" title={`${syncQueueCount} unsynced changes`}>
|
||||
<span className="pulse-dot"></span>
|
||||
<span>{t('sync.status_unsynced')} ({syncQueueCount})</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Active Workspace */}
|
||||
<div className="app-body">
|
||||
@@ -201,6 +255,7 @@ function App() {
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@ import { db } from '../services/db.js'
|
||||
import { getActiveMasterKey } from '../services/auth.js'
|
||||
import { decryptJson, encryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
||||
import LogEntryEditor from './LogEntryEditor.tsx'
|
||||
import { FileText, Plus, Trash2, ChevronRight, Calendar } from 'lucide-react'
|
||||
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react'
|
||||
|
||||
interface LogEntriesListProps {
|
||||
logbookId: string
|
||||
@@ -25,6 +26,7 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
|
||||
const [entries, setEntries] = useState<DecryptedEntryItem[]>([])
|
||||
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -74,6 +76,40 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadCsv = async () => {
|
||||
setExporting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const title = localStorage.getItem('active_logbook_title') || 'Logbook'
|
||||
await downloadCsv(logbookId, title)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to download CSV:', err)
|
||||
setError(err.message || 'Failed to generate CSV export.')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleShareCsv = async () => {
|
||||
setExporting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const title = localStorage.getItem('active_logbook_title') || 'Logbook'
|
||||
await shareCsv(logbookId, title)
|
||||
} catch (err: any) {
|
||||
if (err.message === 'share_unsupported') {
|
||||
const title = localStorage.getItem('active_logbook_title') || 'Logbook'
|
||||
await downloadCsv(logbookId, title)
|
||||
setError(t('logs.share_unsupported'))
|
||||
} else {
|
||||
console.error('Failed to share CSV:', err)
|
||||
setError(err.message || 'Failed to share CSV export.')
|
||||
}
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
@@ -187,10 +223,22 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
|
||||
<Calendar size={24} className="form-icon" />
|
||||
<h2>{t('logs.title')}</h2>
|
||||
</div>
|
||||
<button className="btn primary" onClick={handleCreate} style={{ width: 'auto', padding: '8px 16px' }}>
|
||||
<Plus size={16} />
|
||||
{t('logs.new_entry')}
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<button className="btn secondary" onClick={handleDownloadCsv} disabled={loading || exporting || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.export_csv')}>
|
||||
<Download size={16} />
|
||||
<span className="hide-mobile">{exporting ? t('logs.exporting') : t('logs.export_csv')}</span>
|
||||
</button>
|
||||
|
||||
<button className="btn secondary" onClick={handleShareCsv} disabled={loading || exporting || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.share_csv')}>
|
||||
<Share2 size={16} />
|
||||
<span className="hide-mobile">{t('logs.share_csv')}</span>
|
||||
</button>
|
||||
|
||||
<button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }}>
|
||||
<Plus size={16} />
|
||||
{t('logs.new_entry')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="auth-error mb-4">{error}</div>}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Settings, Save, Check } from 'lucide-react'
|
||||
export default function SettingsForm() {
|
||||
const { t } = useTranslation()
|
||||
const [apiKey, setApiKey] = useState(localStorage.getItem('owm_api_key') || '')
|
||||
const [theme, setTheme] = useState(localStorage.getItem('active_theme') || 'auto')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
@@ -15,6 +16,10 @@ export default function SettingsForm() {
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('owm_api_key', apiKey.trim())
|
||||
localStorage.setItem('active_theme', theme)
|
||||
|
||||
// Notify App of theme change
|
||||
window.dispatchEvent(new Event('theme-changed'))
|
||||
|
||||
setSaving(false)
|
||||
setSuccess(true)
|
||||
@@ -34,6 +39,7 @@ export default function SettingsForm() {
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="vessel-form mt-6">
|
||||
{/* Weather Integration card */}
|
||||
<div className="member-editor-card glass">
|
||||
<h3 style={{ marginTop: 0, marginBottom: '12px', color: '#fbbf24', fontSize: '16px' }}>
|
||||
{t('settings.owm_title')}
|
||||
@@ -58,6 +64,32 @@ export default function SettingsForm() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme customization card */}
|
||||
<div className="member-editor-card glass mt-4">
|
||||
<h3 style={{ marginTop: 0, marginBottom: '12px', color: '#fbbf24', fontSize: '16px' }}>
|
||||
{t('settings.theme_title')}
|
||||
</h3>
|
||||
<p style={{ fontSize: '13.5px', color: '#94a3b8', lineHeight: '145%', margin: '0 0 16px 0' }}>
|
||||
{t('settings.theme_label')}
|
||||
</p>
|
||||
|
||||
<div className="input-group">
|
||||
<select
|
||||
id="app-theme"
|
||||
className="input-text"
|
||||
value={theme}
|
||||
onChange={(e) => setTheme(e.target.value)}
|
||||
disabled={saving}
|
||||
style={{ background: 'rgba(11, 12, 16, 0.85)', color: '#f1f5f9' }}
|
||||
>
|
||||
<option value="auto">{t('settings.theme_auto')}</option>
|
||||
<option value="ocean">{t('settings.theme_ocean')}</option>
|
||||
<option value="material">{t('settings.theme_material')}</option>
|
||||
<option value="cupertino">{t('settings.theme_cupertino')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions mt-4">
|
||||
{success && (
|
||||
<div className="success-toast">
|
||||
|
||||
@@ -85,7 +85,11 @@
|
||||
"event_wind_pressure": "Luftdruck (hPa)",
|
||||
"event_heel": "Krängung (°)",
|
||||
"event_sails": "Segelführung / Motor",
|
||||
"event_distance": "Distanz (sm)"
|
||||
"event_distance": "Distanz (sm)",
|
||||
"export_csv": "CSV herunterladen",
|
||||
"share_csv": "CSV teilen",
|
||||
"exporting": "Exportiere...",
|
||||
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Ihre Logbücher",
|
||||
@@ -144,7 +148,13 @@
|
||||
"no_key": "Bitte hinterlegen Sie Ihren OpenWeatherMap API-Schlüssel in den Einstellungen, um Wetterdaten abzurufen.",
|
||||
"weather_success": "Wetterdaten erfolgreich abgerufen!",
|
||||
"weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfen Sie den API-Schlüssel und die Verbindung.",
|
||||
"gps_error": "Bitte ermitteln Sie zuerst die GPS-Koordinaten."
|
||||
"gps_error": "Bitte ermitteln Sie zuerst die GPS-Koordinaten.",
|
||||
"theme_title": "Design-Anpassung",
|
||||
"theme_label": "Design-Stil der App",
|
||||
"theme_auto": "Automatisch (OS-Erkennung)",
|
||||
"theme_ocean": "Ocean (Glassmorphismus)",
|
||||
"theme_material": "Material (Android)",
|
||||
"theme_cupertino": "Cupertino (iOS)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,11 @@
|
||||
"event_wind_pressure": "Barometer (hPa)",
|
||||
"event_heel": "Heel Angle (°)",
|
||||
"event_sails": "Sails / Motor Status",
|
||||
"event_distance": "Distance (nm)"
|
||||
"event_distance": "Distance (nm)",
|
||||
"export_csv": "Download CSV",
|
||||
"share_csv": "Share CSV",
|
||||
"exporting": "Exporting...",
|
||||
"share_unsupported": "Web sharing is not supported on this device. File downloaded instead."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Your Logbooks",
|
||||
@@ -144,7 +148,13 @@
|
||||
"no_key": "Please set your OpenWeatherMap API Key in settings to enable weather auto-fill.",
|
||||
"weather_success": "Weather details fetched successfully!",
|
||||
"weather_error": "Failed to fetch weather. Check your API key and connection.",
|
||||
"gps_error": "Please fetch GPS coordinates first."
|
||||
"gps_error": "Please fetch GPS coordinates first.",
|
||||
"theme_title": "UI Customization",
|
||||
"theme_label": "Application Style / Theme",
|
||||
"theme_auto": "Auto (OS Detect)",
|
||||
"theme_ocean": "Ocean (Glassmorphism)",
|
||||
"theme_material": "Material (Android)",
|
||||
"theme_cupertino": "Cupertino (iOS)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { decryptJson } from './crypto.js'
|
||||
|
||||
function escapeCsvValue(val: string | number | undefined | null): string {
|
||||
if (val === null || val === undefined) return '';
|
||||
const str = String(val);
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
export async function exportLogbookToCsv(logbookId: string): Promise<string> {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) {
|
||||
throw new Error('Master key not found. User must log in.')
|
||||
}
|
||||
|
||||
// 1. Fetch Yacht details
|
||||
let yachtName = '', homePort = '', owner = '', charter = '', registration = '', callsign = '', atis = '', mmsi = '';
|
||||
const yachtRecord = await db.yachts.get(logbookId);
|
||||
if (yachtRecord) {
|
||||
try {
|
||||
const yacht = await decryptJson(yachtRecord.encryptedData, yachtRecord.iv, yachtRecord.tag, masterKey);
|
||||
yachtName = yacht.name || '';
|
||||
homePort = yacht.port || '';
|
||||
owner = yacht.owner || '';
|
||||
charter = yacht.charter || '';
|
||||
registration = yacht.registration || '';
|
||||
callsign = yacht.callsign || '';
|
||||
atis = yacht.atis || '';
|
||||
mmsi = yacht.mmsi || '';
|
||||
} catch (e) {
|
||||
console.error('Failed to decrypt yacht details for CSV:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch logbook entries
|
||||
const localEntries = await db.entries.where({ logbookId }).toArray();
|
||||
const decryptedEntries = [];
|
||||
for (const entry of localEntries) {
|
||||
try {
|
||||
const dec = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey);
|
||||
if (dec) {
|
||||
decryptedEntries.push(dec);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to decrypt entry for CSV:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort chronological ascending (by date, and then dayOfTravel numerical)
|
||||
decryptedEntries.sort((a, b) => {
|
||||
const timeA = new Date(a.date || '').getTime() || 0;
|
||||
const timeB = new Date(b.date || '').getTime() || 0;
|
||||
if (timeA !== timeB) return timeA - timeB;
|
||||
return Number(a.dayOfTravel || 0) - Number(b.dayOfTravel || 0);
|
||||
});
|
||||
|
||||
// Headers matching the requested event fields & metadata
|
||||
const headers = [
|
||||
'Date', 'Day of Travel', 'Departure Port', 'Destination Port',
|
||||
'Skipper Signature', 'Crew Signature',
|
||||
'Event Time', 'MgK Course', 'RwK Course',
|
||||
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State',
|
||||
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
|
||||
'Latitude', 'Longitude', 'Remarks',
|
||||
'Freshwater Morning (L)', 'Freshwater Refilled (L)', 'Freshwater Evening (L)', 'Freshwater Consumption (L)',
|
||||
'Fuel Morning (L)', 'Fuel Refilled (L)', 'Fuel Evening (L)', 'Fuel Consumption (L)',
|
||||
'Yacht Name', 'Home Port', 'Owner', 'Charter Company', 'Registration', 'Callsign', 'ATIS', 'MMSI'
|
||||
];
|
||||
|
||||
const rows: string[][] = [headers];
|
||||
|
||||
for (const entry of decryptedEntries) {
|
||||
const dateVal = entry.date || '';
|
||||
const travelDay = entry.dayOfTravel || '';
|
||||
const dep = entry.departure || '';
|
||||
const dest = entry.destination || '';
|
||||
const signS = entry.signSkipper || '';
|
||||
const signC = entry.signCrew || '';
|
||||
const fwM = entry.freshwater?.morning ?? '';
|
||||
const fwR = entry.freshwater?.refilled ?? '';
|
||||
const fwE = entry.freshwater?.evening ?? '';
|
||||
const fwCons = entry.freshwater?.consumption ?? '';
|
||||
const fuelM = entry.fuel?.morning ?? '';
|
||||
const fuelR = entry.fuel?.refilled ?? '';
|
||||
const fuelE = entry.fuel?.evening ?? '';
|
||||
const fuelCons = entry.fuel?.consumption ?? '';
|
||||
|
||||
const eventsList = entry.events || [];
|
||||
if (eventsList.length === 0) {
|
||||
// Create one row even if there are no events for the day
|
||||
rows.push([
|
||||
dateVal, travelDay, dep, dest,
|
||||
signS, signC,
|
||||
'', '', '',
|
||||
'', '', '', '',
|
||||
'', '', '', '', '',
|
||||
'', '', '',
|
||||
fwM, fwR, fwE, fwCons,
|
||||
fuelM, fuelR, fuelE, fuelCons,
|
||||
yachtName, homePort, owner, charter, registration, callsign, atis, mmsi
|
||||
].map(escapeCsvValue));
|
||||
} else {
|
||||
// Sort events chronologically by time
|
||||
const sortedEvents = [...eventsList].sort((a, b) => (a.time || '').localeCompare(b.time || ''));
|
||||
for (const ev of sortedEvents) {
|
||||
rows.push([
|
||||
dateVal, travelDay, dep, dest,
|
||||
signS, signC,
|
||||
ev.time || '', ev.mgk || '', ev.rwk || '',
|
||||
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
|
||||
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
|
||||
ev.gpsLat || '', ev.gpsLng || '', ev.remarks || '',
|
||||
fwM, fwR, fwE, fwCons,
|
||||
fuelM, fuelR, fuelE, fuelCons,
|
||||
yachtName, homePort, owner, charter, registration, callsign, atis, mmsi
|
||||
].map(escapeCsvValue));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert array of arrays to CSV string
|
||||
return rows.map(r => r.join(',')).join('\n');
|
||||
}
|
||||
|
||||
export async function downloadCsv(logbookId: string, title: string): Promise<void> {
|
||||
const csvContent = await exportLogbookToCsv(logbookId);
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
// Sanitize filename
|
||||
const filename = `${title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_logbook.csv`;
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
export async function shareCsv(logbookId: string, title: string): Promise<void> {
|
||||
const csvContent = await exportLogbookToCsv(logbookId);
|
||||
const filename = `${title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_logbook.csv`;
|
||||
|
||||
const file = new File([csvContent], filename, { type: 'text/csv' });
|
||||
|
||||
if (navigator.canShare && navigator.canShare({ files: [file] })) {
|
||||
try {
|
||||
await navigator.share({
|
||||
files: [file],
|
||||
title: `Kapteins Daagbox - ${title}`,
|
||||
text: `Logbook export for yacht ${title}`
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e.name !== 'AbortError') {
|
||||
console.error('Sharing failed, falling back to download:', e);
|
||||
await downloadCsv(logbookId, title);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error('share_unsupported');
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,24 @@ import { getActiveMasterKey } from './auth.js'
|
||||
const API_BASE = 'http://localhost:5000/api/sync'
|
||||
const syncingLogbooks = new Set<string>()
|
||||
|
||||
let isSyncing = false
|
||||
const listeners = new Set<(syncing: boolean) => void>()
|
||||
|
||||
export function subscribeToSyncState(listener: (syncing: boolean) => void) {
|
||||
listeners.add(listener)
|
||||
listener(isSyncing)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
function setSyncing(syncing: boolean) {
|
||||
if (isSyncing !== syncing) {
|
||||
isSyncing = syncing
|
||||
listeners.forEach((l) => l(isSyncing))
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to check if a timestamp is newer
|
||||
function isNewer(timeA: string | Date, timeB: string | Date): boolean {
|
||||
return new Date(timeA).getTime() > new Date(timeB).getTime()
|
||||
@@ -184,6 +202,7 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
|
||||
|
||||
if (syncingLogbooks.has(logbookId)) return false
|
||||
syncingLogbooks.add(logbookId)
|
||||
setSyncing(true)
|
||||
|
||||
try {
|
||||
const pushed = await pushChanges(logbookId)
|
||||
@@ -191,6 +210,7 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
|
||||
return pushed && pulled;
|
||||
} finally {
|
||||
syncingLogbooks.delete(logbookId)
|
||||
setSyncing(syncingLogbooks.size > 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +222,7 @@ export async function syncAllLogbooks(): Promise<void> {
|
||||
if (!masterKey) return
|
||||
|
||||
try {
|
||||
setSyncing(true)
|
||||
// 1. Fetch latest logbook lists first (synchronizes db.logbooks index)
|
||||
const logbooks = await db.logbooks.toArray()
|
||||
|
||||
@@ -211,6 +232,8 @@ export async function syncAllLogbooks(): Promise<void> {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error synchronizing all logbooks:', error)
|
||||
} finally {
|
||||
setSyncing(syncingLogbooks.size > 0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Configuration
|
||||
SERVER_PORT=5000
|
||||
CLIENT_PORT=5173
|
||||
|
||||
echo "========================================"
|
||||
echo " Kapteins Daagbox Dev Environment "
|
||||
echo "========================================"
|
||||
echo "Preparing to (re)start services..."
|
||||
|
||||
# Clean up processes running on ports
|
||||
cleanup_port() {
|
||||
local port=$1
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
local pid=$(lsof -t -i:$port)
|
||||
if [ ! -z "$pid" ]; then
|
||||
echo "Port $port is currently in use by PID $pid. Stopping process..."
|
||||
kill -9 $pid 2>/dev/null
|
||||
fi
|
||||
elif command -v fuser >/dev/null 2>&1; then
|
||||
echo "Port $port is currently in use. Stopping process..."
|
||||
fuser -k $port/tcp 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup_port $SERVER_PORT
|
||||
cleanup_port $CLIENT_PORT
|
||||
|
||||
# Clean exit handler
|
||||
cleanup_all() {
|
||||
echo ""
|
||||
echo "Stopping all dev servers..."
|
||||
# Kill all child jobs
|
||||
kill $(jobs -p) 2>/dev/null
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Trap termination signals
|
||||
trap cleanup_all SIGINT SIGTERM EXIT
|
||||
|
||||
# Start backend server
|
||||
echo "Starting backend API server..."
|
||||
cd server
|
||||
npm run dev &
|
||||
cd ..
|
||||
|
||||
# Sleep briefly to let server start up
|
||||
sleep 1.5
|
||||
|
||||
# Start frontend client
|
||||
echo "Starting frontend dev server..."
|
||||
cd client
|
||||
npm run dev &
|
||||
cd ..
|
||||
|
||||
echo "========================================"
|
||||
echo "Dev services are now running:"
|
||||
echo " -> Backend: http://localhost:$SERVER_PORT"
|
||||
echo " -> Frontend: http://localhost:$CLIENT_PORT"
|
||||
echo "========================================"
|
||||
echo "Press Ctrl+C to terminate both servers."
|
||||
echo "========================================"
|
||||
|
||||
# Block to keep parent process alive
|
||||
wait
|
||||
Reference in New Issue
Block a user