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**: 2 plans
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
- [ ] 04-01: Create client-side decryption CSV builder and hook it up to standard browser download and Web Share API.
|
- [x] 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-02: Implement online/offline connection state detectors, sync progress bars, and OS-adaptive UI themes.
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
@@ -83,6 +83,6 @@ Phases execute in numeric order: 1 → 2 → 3 → 4
|
|||||||
| Phase | Plans Complete | Status | Completed |
|
| Phase | Plans Complete | Status | Completed |
|
||||||
|-------|----------------|--------|-----------|
|
|-------|----------------|--------|-----------|
|
||||||
| 1. Foundation, Auth & E2E Crypto | 3/3 | Completed | 2026-05-27 |
|
| 1. Foundation, Auth & E2E Crypto | 3/3 | Completed | 2026-05-27 |
|
||||||
| 2. Sync Protocol & Multi-Logbooks | 0/2 | Not started | - |
|
| 2. Sync Protocol & Multi-Logbooks | 2/2 | Completed | 2026-05-27 |
|
||||||
| 3. Master Data & Log entries | 0/3 | Not started | - |
|
| 3. Master Data & Log entries | 3/3 | Completed | 2026-05-27 |
|
||||||
| 4. CSV Export & UI Polish | 0/2 | Not started | - |
|
| 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
|
## Current Position
|
||||||
|
|
||||||
Phase: 3 of 4 (Master Data & Log entries)
|
Phase: 4 of 4 (CSV Export & UI Polish)
|
||||||
Plan: 3 of 3 in current phase
|
Plan: 2 of 2 in current phase
|
||||||
Status: Completed
|
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
|
## Performance Metrics
|
||||||
|
|
||||||
**Velocity:**
|
**Velocity:**
|
||||||
- Total plans completed: 8
|
- Total plans completed: 10
|
||||||
- Average duration: 15 min
|
- Average duration: 15 min
|
||||||
- Total execution time: 2.0 hours
|
- Total execution time: 2.5 hours
|
||||||
|
|
||||||
**By Phase:**
|
**By Phase:**
|
||||||
|
|
||||||
@@ -30,10 +30,10 @@ Progress: [████████░░] 80%
|
|||||||
| 1. Foundation, Auth & E2E Crypto | 3/3 | Completed | - |
|
| 1. Foundation, Auth & E2E Crypto | 3/3 | Completed | - |
|
||||||
| 2. Sync Protocol & Multi-Logbooks | 2/2 | Completed | - |
|
| 2. Sync Protocol & Multi-Logbooks | 2/2 | Completed | - |
|
||||||
| 3. Master Data & Log entries | 3/3 | 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:**
|
**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
|
- Trend: Stable
|
||||||
|
|
||||||
*Updated after each plan completion*
|
*Updated after each plan completion*
|
||||||
|
|||||||
@@ -1059,3 +1059,283 @@ body {
|
|||||||
.text-sm {
|
.text-sm {
|
||||||
font-size: 12px;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+57
-2
@@ -8,7 +8,9 @@ import DeviationForm from './components/DeviationForm.tsx'
|
|||||||
import LogEntriesList from './components/LogEntriesList.tsx'
|
import LogEntriesList from './components/LogEntriesList.tsx'
|
||||||
import SettingsForm from './components/SettingsForm.tsx'
|
import SettingsForm from './components/SettingsForm.tsx'
|
||||||
import { getActiveMasterKey, logoutUser } from './services/auth.js'
|
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 { Ship, LogOut, ChevronLeft, Users, Compass, FileText, Settings, Wifi, WifiOff } from 'lucide-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
@@ -19,6 +21,43 @@ function App() {
|
|||||||
const [activeLogbookTitle, setActiveLogbookTitle] = useState<string | null>(null)
|
const [activeLogbookTitle, setActiveLogbookTitle] = useState<string | null>(null)
|
||||||
const [activeTab, setActiveTab] = useState<'vessel' | 'crew' | 'deviation' | 'logs' | 'settings'>('logs')
|
const [activeTab, setActiveTab] = useState<'vessel' | 'crew' | 'deviation' | 'logs' | 'settings'>('logs')
|
||||||
const [online, setOnline] = useState(navigator.onLine)
|
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(() => {
|
useEffect(() => {
|
||||||
const handleOnline = () => {
|
const handleOnline = () => {
|
||||||
@@ -93,19 +132,27 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return <AuthOnboarding onAuthenticated={handleAuthenticated} />
|
return (
|
||||||
|
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
|
||||||
|
<AuthOnboarding onAuthenticated={handleAuthenticated} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!activeLogbookId) {
|
if (!activeLogbookId) {
|
||||||
return (
|
return (
|
||||||
|
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
|
||||||
<LogbookDashboard
|
<LogbookDashboard
|
||||||
onSelectLogbook={handleSelectLogbook}
|
onSelectLogbook={handleSelectLogbook}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
|
||||||
|
{isSyncing && <div className="sync-progress-bar" />}
|
||||||
<div className="app-layout">
|
<div className="app-layout">
|
||||||
{/* Active Logbook Header */}
|
{/* Active Logbook Header */}
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
@@ -121,6 +168,13 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="header-actions">
|
<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'}>
|
<div className={`conn-status ${online ? 'online' : 'offline'}`} title={online ? 'Online' : 'Offline'}>
|
||||||
{online ? <Wifi size={18} /> : <WifiOff size={18} />}
|
{online ? <Wifi size={18} /> : <WifiOff size={18} />}
|
||||||
<span>{online ? 'Online' : t('sync.status_offline')}</span>
|
<span>{online ? 'Online' : t('sync.status_offline')}</span>
|
||||||
@@ -201,6 +255,7 @@ function App() {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import { db } from '../services/db.js'
|
|||||||
import { getActiveMasterKey } from '../services/auth.js'
|
import { getActiveMasterKey } from '../services/auth.js'
|
||||||
import { decryptJson, encryptJson } from '../services/crypto.js'
|
import { decryptJson, encryptJson } from '../services/crypto.js'
|
||||||
import { syncLogbook } from '../services/sync.js'
|
import { syncLogbook } from '../services/sync.js'
|
||||||
|
import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
||||||
import LogEntryEditor from './LogEntryEditor.tsx'
|
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 {
|
interface LogEntriesListProps {
|
||||||
logbookId: string
|
logbookId: string
|
||||||
@@ -25,6 +26,7 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
|
|||||||
const [entries, setEntries] = useState<DecryptedEntryItem[]>([])
|
const [entries, setEntries] = useState<DecryptedEntryItem[]>([])
|
||||||
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null)
|
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [exporting, setExporting] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
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 () => {
|
const handleCreate = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -187,11 +223,23 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
|
|||||||
<Calendar size={24} className="form-icon" />
|
<Calendar size={24} className="form-icon" />
|
||||||
<h2>{t('logs.title')}</h2>
|
<h2>{t('logs.title')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn primary" onClick={handleCreate} style={{ width: 'auto', padding: '8px 16px' }}>
|
<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} />
|
<Plus size={16} />
|
||||||
{t('logs.new_entry')}
|
{t('logs.new_entry')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <div className="auth-error mb-4">{error}</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() {
|
export default function SettingsForm() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [apiKey, setApiKey] = useState(localStorage.getItem('owm_api_key') || '')
|
const [apiKey, setApiKey] = useState(localStorage.getItem('owm_api_key') || '')
|
||||||
|
const [theme, setTheme] = useState(localStorage.getItem('active_theme') || 'auto')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [success, setSuccess] = useState(false)
|
const [success, setSuccess] = useState(false)
|
||||||
|
|
||||||
@@ -15,6 +16,10 @@ export default function SettingsForm() {
|
|||||||
|
|
||||||
// Save to localStorage
|
// Save to localStorage
|
||||||
localStorage.setItem('owm_api_key', apiKey.trim())
|
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)
|
setSaving(false)
|
||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
@@ -34,6 +39,7 @@ export default function SettingsForm() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="vessel-form mt-6">
|
<form onSubmit={handleSubmit} className="vessel-form mt-6">
|
||||||
|
{/* Weather Integration card */}
|
||||||
<div className="member-editor-card glass">
|
<div className="member-editor-card glass">
|
||||||
<h3 style={{ marginTop: 0, marginBottom: '12px', color: '#fbbf24', fontSize: '16px' }}>
|
<h3 style={{ marginTop: 0, marginBottom: '12px', color: '#fbbf24', fontSize: '16px' }}>
|
||||||
{t('settings.owm_title')}
|
{t('settings.owm_title')}
|
||||||
@@ -58,6 +64,32 @@ export default function SettingsForm() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="form-actions mt-4">
|
||||||
{success && (
|
{success && (
|
||||||
<div className="success-toast">
|
<div className="success-toast">
|
||||||
|
|||||||
@@ -85,7 +85,11 @@
|
|||||||
"event_wind_pressure": "Luftdruck (hPa)",
|
"event_wind_pressure": "Luftdruck (hPa)",
|
||||||
"event_heel": "Krängung (°)",
|
"event_heel": "Krängung (°)",
|
||||||
"event_sails": "Segelführung / Motor",
|
"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": {
|
"dashboard": {
|
||||||
"title": "Ihre Logbücher",
|
"title": "Ihre Logbücher",
|
||||||
@@ -144,7 +148,13 @@
|
|||||||
"no_key": "Bitte hinterlegen Sie Ihren OpenWeatherMap API-Schlüssel in den Einstellungen, um Wetterdaten abzurufen.",
|
"no_key": "Bitte hinterlegen Sie Ihren OpenWeatherMap API-Schlüssel in den Einstellungen, um Wetterdaten abzurufen.",
|
||||||
"weather_success": "Wetterdaten erfolgreich abgerufen!",
|
"weather_success": "Wetterdaten erfolgreich abgerufen!",
|
||||||
"weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfen Sie den API-Schlüssel und die Verbindung.",
|
"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_wind_pressure": "Barometer (hPa)",
|
||||||
"event_heel": "Heel Angle (°)",
|
"event_heel": "Heel Angle (°)",
|
||||||
"event_sails": "Sails / Motor Status",
|
"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": {
|
"dashboard": {
|
||||||
"title": "Your Logbooks",
|
"title": "Your Logbooks",
|
||||||
@@ -144,7 +148,13 @@
|
|||||||
"no_key": "Please set your OpenWeatherMap API Key in settings to enable weather auto-fill.",
|
"no_key": "Please set your OpenWeatherMap API Key in settings to enable weather auto-fill.",
|
||||||
"weather_success": "Weather details fetched successfully!",
|
"weather_success": "Weather details fetched successfully!",
|
||||||
"weather_error": "Failed to fetch weather. Check your API key and connection.",
|
"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 API_BASE = 'http://localhost:5000/api/sync'
|
||||||
const syncingLogbooks = new Set<string>()
|
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
|
// Helper to check if a timestamp is newer
|
||||||
function isNewer(timeA: string | Date, timeB: string | Date): boolean {
|
function isNewer(timeA: string | Date, timeB: string | Date): boolean {
|
||||||
return new Date(timeA).getTime() > new Date(timeB).getTime()
|
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
|
if (syncingLogbooks.has(logbookId)) return false
|
||||||
syncingLogbooks.add(logbookId)
|
syncingLogbooks.add(logbookId)
|
||||||
|
setSyncing(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pushed = await pushChanges(logbookId)
|
const pushed = await pushChanges(logbookId)
|
||||||
@@ -191,6 +210,7 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
|
|||||||
return pushed && pulled;
|
return pushed && pulled;
|
||||||
} finally {
|
} finally {
|
||||||
syncingLogbooks.delete(logbookId)
|
syncingLogbooks.delete(logbookId)
|
||||||
|
setSyncing(syncingLogbooks.size > 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +222,7 @@ export async function syncAllLogbooks(): Promise<void> {
|
|||||||
if (!masterKey) return
|
if (!masterKey) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
setSyncing(true)
|
||||||
// 1. Fetch latest logbook lists first (synchronizes db.logbooks index)
|
// 1. Fetch latest logbook lists first (synchronizes db.logbooks index)
|
||||||
const logbooks = await db.logbooks.toArray()
|
const logbooks = await db.logbooks.toArray()
|
||||||
|
|
||||||
@@ -211,6 +232,8 @@ export async function syncAllLogbooks(): Promise<void> {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error synchronizing all logbooks:', 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