Compare commits

...

248 Commits

Author SHA1 Message Date
elpatron 2453134c51 chore: release v0.1.0.92 2026-06-02 19:28:24 +02:00
elpatron 671cb2dd9a fix: resolve push notification issues on iPad and Android by preloading VAPID keys and ready service worker to preserve user gesture context and by forcing clean re-subscription 2026-06-02 19:28:03 +02:00
elpatron 1d511e0f8c chore: release v0.1.0.91 2026-06-02 19:18:28 +02:00
elpatron 18a68367bc fix: resolve PWA freeze caused by infinite microtask loop in sync.ts and hung fetches without timeout 2026-06-02 19:17:36 +02:00
elpatron 90518372d8 chore: release v0.1.0.90 2026-06-02 15:48:31 +02:00
elpatron 9d22cb61c7 fix: prevent UI freeze after saving signed log entries
Cache plaintext list metadata on entry save so the journal list avoids
full decrypt per row, and batch sync pull writes with main-thread yields.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 15:47:18 +02:00
elpatron bb501ba644 chore: release v0.1.0.89 2026-06-01 22:44:01 +02:00
elpatron f51f088f1e chore: release v0.1.0.88 2026-06-01 22:32:53 +02:00
elpatron 3d2918e0fe feat: logbook filter by crew/vessel and save-on-leave dialog
Extend dashboard search with ship name and crew name parts from local data.
When leaving a dirty travel day, offer save, discard, or stay instead of only leave/cancel.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 22:30:41 +02:00
elpatron c5a9b39057 chore: release v0.1.0.87 2026-06-01 22:14:57 +02:00
elpatron 2c8a858c89 fix(pwa): keep bootstrap watchdog active on startup errors
Prevents failed app bootstrap from being marked as successful so the boot watchdog can continue recovery attempts instead of suppressing them.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 22:14:07 +02:00
elpatron ee94a5be10 chore: release v0.1.0.86 2026-06-01 22:05:32 +02:00
elpatron 08798dc9b2 fix(ui): Wetter-Slider mit Tank-Slider-Styling vereinheitlichen
Die Slider-Styles galten nur unter .tank-liter-input; MetricRangeInput nutzt dieselbe Klasse, lag aber in einem anderen Container.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 22:04:39 +02:00
elpatron ddeb69437a fix(pwa): harden startup recovery and track watchdog events
Adds a production boot watchdog to self-heal white/black-screen startup stalls without clearing IndexedDB data offline, reducing failure loops after frequent deploys. Also records watchdog recovery paths in Plausible and documents the new events/properties for monitoring stability.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 22:03:42 +02:00
elpatron cdcef2e106 feat(logs): Sichtweite und kompakte Wetter-Slider im Ereignisprotokoll
Ergänzt visibility in Editor und Live-Log inkl. OWM-Übernahme, CSV-Export
und touch-taugliche Slider für Luftdruck, Seegang, Sichtweite und Krängung.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 21:50:05 +02:00
elpatron 847c73fda9 fix(dev): Prisma db push beim Start und sichere Vessel-Pool-Sync-Abfragen
start-dev.sh synchronisiert Schema vor dem Backend; Sync/Collaboration
liefern bei fehlenden Tabellen null statt 500.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 21:33:41 +02:00
elpatron ec11dd8d2b feat(vessel): Schiffsflotte im Profil und Logbuch-Auswahl
Benutzerweiter Vessel-Pool (E2E, Sync, Migration von Legacy-Yachts) mit
LogbookVesselSelection und LogbookVesselPicker. Profil mit Accordion
(Flotte & Crew); Demo und Onboarding-Tour inkl. profile_vessel_pool.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 21:25:08 +02:00
elpatron 182ea497d8 chore: release v0.1.0.85 2026-06-01 19:43:24 +02:00
elpatron 837bcfe287 fix(ui): Disclaimer-Modal Scroll und Schließen-Button
Ein Scrollbereich im Hinweis-Dialog, Body-Lock beim Öffnen, X oben rechts an der Karte.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:42:56 +02:00
elpatron d261a1e7ca i18n: Mannschaft und Äquivalente zu Crew umbenennen
Einheitliche Crew-Terminologie in allen App-Sprachen (de, en, nb, sv, da).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:38:46 +02:00
elpatron 2ebc3e8a44 chore: release v0.1.0.84 2026-06-01 19:34:10 +02:00
elpatron 047a5b1bdb fix(crew): keep first legacy skipper when several are present
Prefer canonical skipper id and stop overwriting activeSkipperId during
legacy crew migration and read-only share conversion.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:34:01 +02:00
elpatron 7a7e9d5d28 chore: release v0.1.0.83 2026-06-01 19:31:20 +02:00
elpatron 39cbe707c7 fix(server): Docker build when prisma postinstall runs
Copy Prisma schema before npm ci in the builder image and skip
postinstall in the production stage since the client is copied from builder.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:30:53 +02:00
elpatron bb6e7f5c32 chore: release v0.1.0.82 2026-06-01 19:29:51 +02:00
elpatron ca0daa8f2a fix(logs): repair journal entry cards and avoid duplicate days
Match dashboard card DOM so entry tiles get correct height, remove nested
form-card chrome, and open today’s entry instead of creating a second one.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:27:09 +02:00
elpatron 2304f95ac1 fix(live-log): prevent freeze without GPS and prompt for day-start position
Harden geolocation with watchdog timeouts and permission checks so
desktop browsers without GPS no longer hang Live-Log. Show a hint to
log a position when none exists for the day.

Return 503 when crew-pool Prisma models are missing instead of crashing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:20:34 +02:00
elpatron 98c0ed81d4 Raise Stammcrew pool limit from 5 to 12 crew members.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:07:37 +02:00
elpatron 3504ec97cc Add account-level crew pool with per-logbook and per-day selection.
Move skipper and crew master data to the user profile pool, replace the logbook crew tab with selection from that pool, inherit crew on new travel days, and sync via new PersonPayload and LogbookCrewSelection models. Includes migration from legacy crew records, tour/demo updates, and i18n.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:05:50 +02:00
elpatron 4c6c2779f2 chore: release v0.1.0.81 2026-06-01 18:35:35 +02:00
elpatron b6c4e9e7d9 fix(dashboard): allow spaces when renaming logbook title
Move the title input out of the card open button so Space no longer
activates navigation; use an overlay button for opening the logbook.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 18:35:20 +02:00
elpatron 04c6be2b5b chore: release v0.1.0.80 2026-06-01 15:30:57 +02:00
elpatron 9089d017b6 feat(ux): Sprint 3 mobile nav, sync conflicts, and resilience
Improve mobile bottom navigation, accessible dialogs and cards, explicit
sync conflict resolution, i18n error messages, encrypted draft autosave,
and persistent storage hints for offline data safety.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:30:08 +02:00
elpatron f8dc6ace3c chore: release v0.1.0.79 2026-06-01 15:20:55 +02:00
elpatron 18f14d7e0b chore(deploy): run predeploy-check.sh from update-prod.sh
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:18:58 +02:00
elpatron 0edf4a789c feat(quality): Sprint 2 pre-deploy gates and server smoke tests
Extract Express app factory for testability, add Vitest/Supertest API
smoke tests, root npm run check script, and deployment docs. Fix
express-rate-limit IPv6 keyGenerator for feedback limiter.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:17:46 +02:00
elpatron 4ef56aeb8f fix(ops): force-recreate backend after postgres password rotation
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:10:50 +02:00
elpatron 3263fbcec3 chore: restore merge skill accidentally removed
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:09:24 +02:00
elpatron b9ce853059 feat(ops): script to rotate PostgreSQL password safely
Add rotate-postgres-password.sh with optional app role, document the
procedure, and stop defaulting production POSTGRES_PASSWORD to postgres.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:09:15 +02:00
elpatron 3d8a505bd9 fix(nginx): security headers on index.html and PWA asset routes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:04:27 +02:00
elpatron e138752dd3 feat(security): Sprint 1 hardening for production behind NPM
Add trust proxy, WebAuthn challenge TTL, stricter public collaboration
rate limits, generic 500 responses, Docker POSTGRES_PASSWORD from env,
nginx security headers/CSP, and deployment documentation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:02:15 +02:00
elpatron b9c908169b chore: release v0.1.0.78 2026-06-01 13:44:36 +02:00
elpatron e6bde5c525 fix: shorten travel day badge to "Reisetag x"
Use travel_day_number i18n key in journal cards and live log subtitle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 13:41:27 +02:00
elpatron eab7b86c0b chore: release v0.1.0.77 2026-06-01 13:28:24 +02:00
elpatron b86789ae4c fix: show profile button while a logbook is open
Extract ProfileHeaderButton and open UserProfilePage from any screen
without leaving the active logbook.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 13:28:03 +02:00
elpatron 2a8ec2fccf chore: release v0.1.0.76 2026-06-01 11:43:33 +02:00
elpatron 60a8533a44 feat: add Plausible events for live log photos and OWM usage
Track Live Log Photo Uploaded and centralize OWM Weather Fetched with
source props for live log and entry editor call sites.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 11:34:21 +02:00
elpatron c86ac4273c chore: release v0.1.0.75 2026-06-01 10:59:31 +02:00
elpatron 73467f2263 fix: live journal camera save on Android
Use native camera picker on mobile, add preview-and-save step, and
harden canvas capture with toDataURL fallback when toBlob fails.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 10:45:54 +02:00
elpatron e068f083c1 style: add blank line before LiveLogViewProps interface
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 10:01:34 +02:00
elpatron f083294db5 chore: release v0.1.0.74 2026-06-01 09:58:44 +02:00
elpatron 8fc15081e2 Show QR codes for invite and public share links.
Generate scannable QR codes in settings next to collaboration links so crew can open invites on mobile without copying long URLs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 09:58:16 +02:00
elpatron efa0fcf934 Add live journal camera photos and harden OWM button.
Capture photos via getUserMedia in live log, share encrypted save logic with the editor, and disable weather fetch while other quick actions run.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 09:47:56 +02:00
elpatron c1ecdcad9c Fix live journal hang on empty new logbooks.
Fast-path today's entry creation, add init timeout, defer auto-position GPS, and migrate logbook keys when the server returns a different id.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 09:39:51 +02:00
elpatron d6c7952af8 Fix live journal freeze during OpenWeatherMap fetch.
Batch weather events in one persist cycle, avoid global busy state while loading, and add a 20s API timeout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 09:19:03 +02:00
elpatron 3d02f841a0 Add live journal OWM weather and manual GPS fix dialog.
Fetch OpenWeatherMap in live log when a GPS fix is under six hours old, open a fix modal with manual coordinates when geolocation fails, and only show the undo bar after a successful save.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 09:03:37 +02:00
elpatron 0caaf681d8 Fix live journal freeze and passkey login on localhost.
Harden live log init with safe per-entry decrypt, stable loading state, and no parallel list scan in live mode. Improve multi-sail picker UX, stop WebAuthn retry after user cancel, redirect 127.0.0.1 to localhost, and tolerate missing appearance prefs table.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 08:49:45 +02:00
elpatron 43dc994c4f Update beta flyer with NMEA, live log, and route stats features.
Add three feature bullets in all locales, reduce feature list font to 8.5pt for single-page layout, and regenerate PDF/PNG exports.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:52:11 +02:00
elpatron d94502097e chore: release v0.1.0.73 2026-05-31 21:46:44 +02:00
elpatron a36ca2facb Add Plausible analytics for live journal and NMEA upload.
Track Live Log Opened/Event Logged with action types, NMEA Uploaded on parse success, and align NMEA Imported properties with docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:43:30 +02:00
elpatron b7a1085d52 chore: release v0.1.0.72 2026-05-31 21:39:22 +02:00
elpatron 3925c6f822 Add cloc code statistics report for the project.
Documents line counts by language, area, and largest source files for onboarding and size tracking.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:39:00 +02:00
elpatron 0b2c1c22c6 Guard optional event fields before calling trim in live-log paths.
Legacy decrypted events may omit mgk or wind fields; optional chaining prevents runtime crashes in course prefill and stats aggregation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:29:42 +02:00
elpatron aa03573e1f Fix live-log dial modals overlapping journal text.
Portal overlays to document.body and use opaque modal panels so fixed positioning works outside form-card and journal entries stay readable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:28:02 +02:00
elpatron a0b8664e23 Use course dials for live-log wind direction and course entry.
Reuses CourseDialInput from the classic journal editor in the live modals, prefilled from the most recent wind or course values.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:20:19 +02:00
elpatron 74282f50d0 Add SOG and STW live-log actions and capitalize motor labels.
SOG prefills from GPS speed when available; STW is entered manually. Motor journal entries now read “Motor Start” / “Motor Stop”.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:17:51 +02:00
elpatron 5b47415d55 Extend live journal with weather, tanks, undo, and event series stats.
Adds weather and course quick actions, diesel/water refills, five-second undo, foreground auto-position every three hours, and chronological pressure/wind/motor series in the stats tab.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:11:52 +02:00
elpatron 039e4e2736 Add live journal mode for one-tap event logging during travel.
Introduces a parallel Live view alongside the existing travel-day list so skippers can log motor, sail, and position events instantly without navigating the full editor.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:09:02 +02:00
elpatron 35bfbc1043 Update .gitignore to exclude userfeedback directory. 2026-05-31 21:00:43 +02:00
elpatron 6c866dbad5 Add NMEA journal import with wizard and CRC-based duplicate detection.
Enables importing .nmea logs into travel-day events with interval/change modes, optional GPS track, local encrypted archive, and a test fixture for the Kieler Förde route.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 20:41:42 +02:00
elpatron bb667afec8 Document NMEA import research for future backlog evaluation.
Captures PWA constraints, file-import scope, and GPX comparison for a later feature decision.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 17:20:39 +02:00
elpatron beee33f842 Update plausible-events.md to specify that recommended goal chains are for business use only. 2026-05-31 16:41:16 +02:00
elpatron 77a7072b77 chore: release v0.1.0.71 2026-05-31 16:38:32 +02:00
elpatron bd1edd89f3 Track language selection with Plausible Language Changed event.
Centralize UI language switches in cycleAppLanguage and document the event in plausible-events.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 16:38:10 +02:00
elpatron ffe6b19818 chore: release v0.1.0.70 2026-05-31 16:33:35 +02:00
elpatron eb1f87f57e Add translation error category to feedback form.
Lets users report i18n issues via the feedback dropdown in all supported locales.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 16:33:28 +02:00
elpatron 13cb03646b chore: release v0.1.0.69 2026-05-31 16:06:36 +02:00
elpatron 1bc0d7fb2a Fix Scandinavian flyer layout by excluding CSS from DeepL translation.
Translate only visible body/title text, preserve HTML structure, and regenerate all flyer PDF/PNG exports.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 16:04:19 +02:00
elpatron 5f3d76b30f Generate beta flyer PDF and PNG for all locales.
Extend generate-beta-flyer.mjs with --all/--lang support and add da, sv, nb assets alongside refreshed German exports.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 16:00:09 +02:00
elpatron b48545e943 Add root package.json for translate scripts from repo root.
Avoids the client/client path when --prefix client is used from inside client/.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 15:55:25 +02:00
elpatron 3749f87c1d Add Scandinavian i18n (da/sv/nb) via DeepL pipeline.
Integrate new locale bundles, language cycling in the UI, SEO hreflang tags, and localized beta flyer HTML variants with scripts for batch translation and key validation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 15:53:43 +02:00
elpatron 2e656dc6b2 feat(marketing): PNG-Export für Beta-Flyer ergänzen
Erweitert den bestehenden Flyer-Generator um eine PNG-Ausgabe aus der HTML-Vorlage inklusive eigenem npm-Skript und erzeugter PNG-Datei für den direkten Einsatz.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 15:19:34 +02:00
elpatron 484ed66b7b chore: release v0.1.0.68 2026-05-31 15:03:26 +02:00
elpatron 49d77f08a2 fix(pwa): Recovery-Render-Race beim Reload vermeiden
Stoppt den Bootstrap nach einem erfolgreichen Hard-Recovery sofort, damit vor dem asynchronen Reload kein React-Render mehr gestartet wird.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 15:03:00 +02:00
elpatron 951b5b3f1c fix(pwa): Startup-Hänger nach Inaktivität stabilisieren
Verhindert Blank-Screens und Reload-Schleifen beim Wiederöffnen der PWA, indem Recovery nur bei bestätigter SW-Übernahme greift und Navigationen bevorzugt frisch aus dem Netzwerk geladen werden.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 15:00:51 +02:00
elpatron abb708c3d0 fix(dashboard): Sortierbuttons in der Mobile-Ansicht besser ausrichten
Die Sortiersteuerung nutzt auf schmalen Viewports eine horizontale Zeile mit gleichmäßig verteilten Touch-Zielen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:55:42 +02:00
elpatron cc87b0f8e6 chore: release v0.1.0.67 2026-05-31 14:46:45 +02:00
elpatron 58984594b0 fix(pwa): Pending-Refresh bei Suppression nicht sofort verwerfen
Suppression wird vor dem Flush geprüft, damit gepufferte
needRefresh-Werte erhalten bleiben, bis die Unterdrückung endet.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:46:02 +02:00
elpatron 61675e1085 fix(pwa): Ausstehendes needRefresh nicht mehr während des Renders setzen
Frühe Service-Worker-Callbacks puffern den Refresh-Status; der Flush
erfolgt jetzt im useEffect statt in der Render-Phase.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:44:12 +02:00
elpatron 2082218f78 chore: release v0.1.0.66 2026-05-31 14:43:25 +02:00
elpatron 5882edcbdf fix(dashboard): Sortierbuttons als kompakte Icon-Buttons ohne Text
Beschriftungen entfernt, Buttons auf 36px verkleinert; aria-label und
title bleiben für Tooltip und Barrierefreiheit erhalten.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:41:46 +02:00
elpatron b7a47a1d90 fix(ui): Vite-Template-CSS entfernen und App-Layout wieder zentrieren
index.css auf App-Shell reduziert, App.css zentral in main.tsx geladen
und #root zentriert Dashboard/Logbuch-Ansichten nach dem Login.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:40:03 +02:00
elpatron 48c408302f fix(pwa): Hintergrund-SW-Updates während Update-Suppression unterbinden
Das periodische Intervall nutzt checkForUpdate() und respektiert damit
„Später“ wie Fokus- und Online-Checks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:28:01 +02:00
elpatron 2b5c5d4a36 feat(dashboard): Filter und Sortierung für die Logbuchliste
Hilft bei vielen Logbüchern: Suche nach Name/Jahr/Datum sowie Sortierung nach Name oder Datum in beide Richtungen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:27:41 +02:00
elpatron 7cf04b3357 fix(pwa): Recovery-Zähler, Update-Suppression und Timer-Leaks beheben
Stale-Recovery zählt nur aufeinanderfolgende Fehler und wird nach Hard Recovery zurückgesetzt; Update-Checks respektieren „Später“, und PWA-Refresh-State sowie Recovery-Timer werden zuverlässig gesetzt bzw. beim Unmount aufgeräumt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:24:25 +02:00
elpatron bbd4281dcb fix(pwa): Updates zuverlässiger erkennen und veraltete Instanzen automatisch reparieren
Unabhängige version.json-Prüfung, häufigere Update-Checks und Hard Recovery
beheben hängende Android-PWAs ohne manuelles Cache-Löschen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:20:54 +02:00
elpatron d2833f7664 chore: release v0.1.0.65 2026-05-31 14:14:25 +02:00
elpatron 2a14080b5b fix(appearance): Theme-Sync an aufgelöste User-ID und Session koppeln
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:13:09 +02:00
elpatron 2457fa41e3 fix(profile): Profil-Statistiken auf Mobilgeräten platzsparender gestalten
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:11:57 +02:00
elpatron 87b0fa7bde chore: release v0.1.0.64 2026-05-31 14:03:27 +02:00
elpatron d90f292a21 fix(appearance): Theme-Einstellungen serverseitig speichern und beim Login wiederherstellen
Nach PWA-Cache-Löschung gingen Theme und Farbschema verloren, weil sie nur in localStorage lagen. Die Präferenzen werden jetzt synchronisiert und nach dem Login erneut angewendet.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:03:17 +02:00
elpatron 9e42f828a0 chore: release v0.1.0.63 2026-05-31 14:00:00 +02:00
elpatron 4197e77b1e feat(auth): Passwortmanager für Wiederherstellungsschlüssel aktivieren
Das Eingabefeld nutzt jetzt Passwort-Semantik und Autocomplete-Attribute, damit OS-Passwortmanager gespeicherte Schlüssel vorschlagen können.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:59:50 +02:00
elpatron 1373c11de8 fix(pwa): Kaltstart nach verpassten Updates stabilisieren
Service Worker übernimmt Updates zuverlässig (SKIP_WAITING, clientsClaim),
wartende Versionen werden beim Start angewendet und veraltete Chunks führen
nicht mehr zum Hängenbleiben.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:58:35 +02:00
elpatron 0bae3b29dc feat: Grauwasserstand beim neuen Reisetag vom Vortag übernehmen
Übernimmt den Grauwasser-Füllstand analog zu Frischwasser und Kraftstoff
beim Anlegen eines Reisetags und zeigt ihn im Übernahme-Dialog an.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:54:57 +02:00
elpatron 73e86d28b3 chore: release v0.1.0.62 2026-05-31 13:47:49 +02:00
elpatron ad4721e694 fix: Fehler-Alert nach Push-Aktivierung korrekt awaiten
Stellt sicher, dass die Fehlermeldung angezeigt wird, bevor
promptPushAfterInviteCreated zurückkehrt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:47:36 +02:00
elpatron 8037b3b63e chore: release v0.1.0.61 2026-05-31 13:45:05 +02:00
elpatron c4cd566da0 fix: Tank-Nachfüll-Clamping und Deaktivierung bei vollem Tank
Korrigiert fehlende useEffect-Dependencies beim Auto-Clamping und
deaktiviert Refill-Eingaben, wenn der Morgenstand die Tankkapazität erreicht.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:44:49 +02:00
elpatron 3a267905b0 feat: Push-Hinweis nach Erstellen eines Crew-Einladungslinks
Owner sieht einen Dialog zur Aktivierung von Crew-Push-Benachrichtigungen, sofern diese noch nicht aktiv sind.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:43:52 +02:00
elpatron c856c2e903 chore: release v0.1.0.60 2026-05-31 13:41:29 +02:00
elpatron b3256d1685 fix: Tank-Slider für Touch auf Mobilgeräten vergrößern
Größerer Thumb und sichtbarere Spur mit touch-action, damit Füllstände am Handy leichter einstellbar sind.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:40:59 +02:00
elpatron 23fc940324 feat: dynamische Slider-Obergrenzen für Frischwasser und Treibstoff
Nachgefüllt ist auf Restkapazität nach Morgen begrenzt, Stand abends auf Morgen plus Nachgefüllt; Werte werden bei Änderungen automatisch gekürzt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:39:46 +02:00
elpatron 25e1bdded3 feat: Tankkapazitäten, Grauwasser und Slider im Journal
Schiffsdaten speichern optionale Tankvolumina; Reisetage erfassen Grauwasser-Füllstand und nutzen Slider bei bekannter Kapazität, inkl. Tooltips und CSV/PDF-Export.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:33:40 +02:00
elpatron 6a61c9e06c chore: release v0.1.0.59 2026-05-31 13:25:11 +02:00
elpatron d3683ad6aa fix: DemoViewer an erweiterte TourNavigation-Schnittstelle anpassen
Ergänzt No-Op-Stubs für setLogbookActive und setProfileOpen, damit der
Production-Build nach der Profil-Tour-Erweiterung wieder durchläuft.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:25:04 +02:00
elpatron ef5891ba3f chore: release v0.1.0.58 2026-05-31 13:22:59 +02:00
elpatron d25095bab3 fix: Fehlalarm bei sauberem Working Tree in update-prod.sh vermeiden
Die Clean-Tree-Prüfung verlässt sich nur noch auf git status --porcelain,
da git diff-index nach git reset fälschlich Änderungen melden kann.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:22:52 +02:00
elpatron 0d16782001 fix: Onboarding-Tour bei gelöschtem Demo-Logbuch und GPS-Schritt stabilisieren
Bereinigt veraltete Demo-Referenzen, löst gültiges Logbuch und ersten Eintrag zur Laufzeit auf und scrollt den GPS-Track-Schritt automatisch ins Viewport.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:15:53 +02:00
elpatron b7e2d470a9 fix: Tour-Tooltip auf feste Breite begrenzen
Entfernt left+right-Stretching in CSS, positioniert das Tooltip horizontal
am Spotlight und misst Ziele nach Navigation mit verzögerten Retries.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:08:31 +02:00
elpatron 520ba766a3 feat: Onboarding-Tour um Benutzerprofil erweitern
Profil-Schritte auf dem Dashboard und in den Einstellungen, entry_open
highlightet nur die Karte ohne Editor, Finish verweist auf Benutzerprofil.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:04:02 +02:00
elpatron c215cd8b15 docs: Beta-Flyer-Formulierung für Skipper/Crew-Fotos präzisieren
Feature-Text nennt Avatarbilder statt Foto-Anhänge; PDF entsprechend aktualisiert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:57:23 +02:00
elpatron 27c780d2b8 fix: Passkey-Signatur beim Speichern der Logbuchseite erhalten
Leeres Event-Formular (nur Uhrzeit) galt fälschlich als Änderung und
invalidierte frische Signaturen. Speichern-Button und Hash-Sperre folgen
nun echten Entwürfen und synchronisiertem Seiteninhalt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:57:02 +02:00
elpatron aa52948ddc chore: release v0.1.0.57 2026-05-31 12:45:44 +02:00
elpatron 49b4e7b9c3 fix: Code- und Profil-Kontrast an App-Theme binden
Benutzer-ID und Passkey-IDs nutzen jetzt Theme-Token statt System-
prefers-color-scheme, damit Monospace-Text in allen Schemes lesbar bleibt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:43:27 +02:00
elpatron 2d64987ada chore: release v0.1.0.56 2026-05-31 12:38:09 +02:00
elpatron 87973eaa4a fix: Light-Theme-Hintergrund auf PWA/Android reparieren
Der hardcodierte Inline-Style auf body überschrieb --app-body-bg und ließ
hellen Modus mit dunklem Seitenhintergrund erscheinen. Theme-Bootstrap und
dynamisches theme-color ergänzen alle Scheme/Theme-Kombinationen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:38:01 +02:00
elpatron 93e26b7807 chore: release v0.1.0.55 2026-05-31 12:26:55 +02:00
elpatron 814eeadd1f fix: Sync-Indikator Listener-Cleanup und CSS-Zustände
useSyncIndicator gibt die Unsubscribe-Funktion von subscribeToSyncState
zurück. conn-status-Klassen berücksichtigen jetzt auch den aktiven
Sync-Lauf (syncing) statt nur die Queue-Länge.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:26:33 +02:00
elpatron d9cbcd8e43 chore: release v0.1.0.54 2026-05-31 12:24:01 +02:00
elpatron 282e7ba8ba fix: Sync-Icon nur während aktiver Synchronisation animieren
Die Drehung hing an der Queue-Länge statt am laufenden Sync. Veraltete
Queue-Einträge werden nach Pull bereinigt; parallele syncAll-Läufe
werden im Sync-State korrekt gezählt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:23:50 +02:00
elpatron b86e5a15d6 chore: release v0.1.0.53 2026-05-31 12:16:02 +02:00
elpatron eac86ec655 README: Neue Features und klare Trennung Profil vs. Logbuch.
Dokumentiert Kompass-Dial, Benutzerprofil, Feedback/Ntfy, Demo-URL, Tests und aktualisierte Env-Variablen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:15:46 +02:00
elpatron a6331bea1a OWM-API-Schlüssel explizit über aktive User-ID laden.
Wetter-Abruf nutzt getOwmApiKeyForActiveUser(), damit namespaced Keys nicht am fehlenden active_userid vorbeilaufen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:14:43 +02:00
elpatron ae89b131a1 chore: release v0.1.0.52 2026-05-31 12:12:15 +02:00
elpatron 3fdea31c4a Update beta flyer styles: Adjust feature gap to 1.8mm and line height to 1.28 for improved layout consistency. Update PDF file to reflect changes. 2026-05-31 12:09:34 +02:00
elpatron 04d114c315 Marketing-Flyer: Themes mit Hell/Dunkel-Varianten erwähnen.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:07:37 +02:00
elpatron 3fa66f044c Korrigiere Label-Ausrichtung im Backup-Panel.
Labels waren durch text-align:center auf #root zentriert, Inputs linksbündig. Formularfelder nutzen nun block-Labels und konsistenten Abstand.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:06:18 +02:00
elpatron a84c611402 Verschiebe benutzerbezogene Einstellungen ins Benutzerprofil.
Theme, Farbschema, OWM-Schlüssel, Push, PWA und App-Tour liegen nun im Profil mit pro-User-localStorage. Der Logbuch-Tab fokussiert Teilen, Backup und Crew.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:01:08 +02:00
elpatron f12b9b2a1a chore: release v0.1.0.51 2026-05-31 11:52:20 +02:00
elpatron 34914b4f19 fix(ui): Segel-Picker-Layout und Formular-Reset korrigieren
Flex-Layout für Pills und Toggle wiederherstellen und den Einklapp-Zustand beim Leeren des Ereignisformulars zurücksetzen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:52:10 +02:00
elpatron d9fa8c0edf chore: release v0.1.0.50 2026-05-31 11:50:39 +02:00
elpatron adf02acd45 fix(ui): Segel-Picker auf Mobile weiter verdichten
Einklappbare Badge-Liste bei vielen Segeln, kompaktere Pills und aktive Auswahl bleibt oben sichtbar.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:50:23 +02:00
elpatron 3992db9d61 fix(ui): Besegelungs-Badges auf Mobile platzsparender anordnen
Die Segel-Pills nutzen die volle Formularbreite und wrappen kompakt, statt in der halben Grid-Spalte untereinander zu stapeln.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:48:30 +02:00
elpatron 51f6a1b291 chore: release v0.1.0.49 2026-05-31 11:42:37 +02:00
elpatron 0b07d8b3d3 feat(auth): Hilfe-Button auf Login öffnet Hinweise-Modal
Ersetzt den toten #help-Link durch einen Button, der dasselbe
Hinweise- und Haftungsausschluss-Modal wie in der App anzeigt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:42:28 +02:00
elpatron a07e033e62 copy(i18n): Login-Tagline persönlicher formulieren
„Dein sicheres …“ statt „Sicheres …“ auf der Anmeldeseite.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:40:06 +02:00
elpatron bbe63dfb47 chore: release v0.1.0.48 2026-05-31 11:38:03 +02:00
elpatron 57f63ad486 fix(auth): Session-Restore erst mit vollständiger lokaler Session
Stellt hasUnlockedLocalSession für UI-Wiederherstellung und
enforceUnlockedSession wieder her; persistSessionUserId setzt userId
nur bei Angabe in der Server-Antwort.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:37:50 +02:00
elpatron 728c40f936 chore: release v0.1.0.47 2026-05-31 11:35:51 +02:00
elpatron 72cbad8d5e fix(auth): Session-Wiederherstellung nicht an active_userid koppeln
Trennt hasUnlockedLocalCrypto (Master-Key + Username) von
hasUnlockedLocalSession (+ userId für API), damit ein gültiges
Server-Cookie ohne userId in der Antwort keinen fälschlichen Logout auslöst.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:35:17 +02:00
elpatron 15f2172a38 fix: ungenutzten getActiveMasterKey-Import entfernen
Behebt TS6133 und schlägt fehlenden Docker-Frontend-Build wieder frei.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:34:01 +02:00
elpatron e2e038f2d6 chore: release v0.1.0.46 2026-05-31 11:30:47 +02:00
elpatron 634eb622fd fix(pwa): weiße Seite nach Android-Neustart ohne Master-Key vermeiden
Erzwingt Login wenn nur die HTTP-Session übrig ist, begrenzt SW-Reloads,
fängt Bootstrap-/Render-Fehler ab und stabilisiert den PWA-Kaltstart.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:30:37 +02:00
elpatron 04b822b263 fix(logs): Wind- und Kurs-Dial auf gleiche Größe bringen
Der Wind-Dial nutzte size="sm" (220px), der Kurs-Dial md (260px).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:26:56 +02:00
elpatron ee60d5fda3 fix(dev): veralteten PWA-Cache bereinigen, damit i18n-Labels laden
Stale Service-Worker-Precache konnte Vite-Module und Locale-Bundles
überlagern, sodass Kompass-Dial-Texte als Roh-i18n-Keys erschienen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:25:10 +02:00
elpatron 3a7d244433 fix(logs): Kompass-Dial-Locales und UI-Labels vervollständigen
Ergänzt fehlende i18n-Keys (Himmelsrichtungen, Platzhalter, Schrittweite) und zeigt Validierungsfehler im Kurs-Dial.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:12:43 +02:00
elpatron 9e03fcda0a feat(logs): Kompass-Dial für Kurs- und Windeingabe
Ersetzt Textfelder für MgK, rwK und Wind durch einen mobilen Kompass-Ring, normalisiert Kurswinkel beim Speichern und führt Vitest mit Regressionstests für html lang ein.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:08:36 +02:00
elpatron 34c7d2d65c fix(logs): 24h-Uhrzeit per Dropdown und konsistentes html lang
Ersetzt natives type=time (AM/PM je nach System) durch Stunde/Minute-Auswahl, wandelt 12h-Werte beim Laden um und stellt html lang auf de/en zurück.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:01:03 +02:00
elpatron 658bc6c0c9 feat(logs): Ereignis-Uhrzeit vorbelegen und 24h-Format vereinheitlichen
Neue Ereignisse starten mit der aktuellen Uhrzeit; Datums-/Zeitanzeigen und Zeit-Picker nutzen durchgängig das 24-Stunden-Format.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 10:57:47 +02:00
elpatron dee2f7b95b chore: release v0.1.0.45 2026-05-31 10:50:13 +02:00
elpatron 4eaf5d7f30 fix(dashboard): Löschbutton und Badge auf Logbuch-Karten trennen
Aktions-Spalte im Flex-Layout statt absoluter Positionierung, mit responsivem Stacking auf schmalen Viewports.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 10:49:55 +02:00
elpatron 257bca14d1 feat(dashboard): Logbuch-Titel per Inline-Bearbeitung umbenennen
Ersetzt Umbenennen-Button und Modal durch Klick auf den Kartentitel.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 10:48:22 +02:00
elpatron 917fb92d85 feat: add logbook title editing with E2E encryption and sync support 2026-05-31 10:45:36 +02:00
elpatron b48b31580d chore: release v0.1.0.44 2026-05-31 10:08:13 +02:00
elpatron 7f0223c636 fix(profile): Abbrechen-Text im Recovery-Rotations-Dialog
Verwendet recovery_rotate_confirm_no statt remove_passkey_confirm_no.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 10:01:30 +02:00
elpatron 68af8c6361 fix(profile): Reauth für Passkey-Umbenennung und Geräte-Dialog
PATCH /credentials verlangt requireReauth wie add/delete; Client ruft
reauthWithPasskey vor rename auf. Abbrechen-Text beim Gerät vergessen korrigiert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 09:59:44 +02:00
elpatron ad7e036ab7 feat(profile): Wiederherstellungsschlüssel rotieren
Neuer Recovery-Code über Profilseite mit Passkey-Reauth, Anzeige der
12 Wörter und API-Endpoint rotate-recovery; Plausible-Event dokumentiert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 09:58:21 +02:00
elpatron 12c02f6392 fix(profile): eigene Fehlermeldung beim Passkey-Umbenennen
Verwendet profile.passkey_rename_failed statt add_passkey_failed,
damit Fehler beim Umbenennen nicht fälschlich als Hinzufügen angezeigt werden.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 09:50:16 +02:00
elpatron 3698c6fbca feat(analytics): Plausible-Events für Profilseite
Trackt Profilaufruf, Passkey-/PIN-Aktionen und Gerät vergessen;
Dokumentation in docs/plausible-events.md ergänzt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 09:47:12 +02:00
elpatron d4538ec06e feat(profile): Passkey-Labels, Sicherheits-Checkliste und Geräte-Block
Erweitert die Profilseite um benennbare Passkeys, Sicherheitsübersicht,
Gerät/Sync-Status, Backup-Hinweis in der Gefahrenzone und Dialog beim
Löschen des letzten Passkeys.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 09:43:28 +02:00
elpatron 86cb4d92ec fix(profile): Logbuch-KPI und Statistik-Fallback robuster laden
Profil bleibt bei fehlenden Client-Stats sichtbar; logbookCount nutzt lokale logbooks.length mit Server-Fallback statt totem ?? in ungerenderter Sektion.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 09:30:58 +02:00
elpatron b72b20b66c fix(dashboard): Profil-Button an btn-icon-Stil angleichen
Nutzt dieselbe 36px-Höhe, Farben und Hover wie die übrigen Header-Buttons; auf Mobile nur Icon in Kreisform.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 09:27:21 +02:00
elpatron 6ad75ff947 fix(auth): Add-credential-Challenges pro Versuch und single-use
Speichert Challenges nach challenge statt userId für parallele Flows und invalidiert sie vor der Verifikation, damit fehlgeschlagene Versuche keine Leaks hinterlassen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 09:25:02 +02:00
elpatron 75eba362d6 fix(profile): Linksbündiges Layout der Profilseite
Überschreibt die zentrierte #root-Textausrichtung für Identität, PIN-Formular und Header, damit Labels und Werte konsistent ausgerichtet sind.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 09:24:20 +02:00
elpatron afc5a1e200 feat(profile): Benutzerprofilseite mit Passkeys, PIN und Statistiken
Zentralisiert Account-Verwaltung vom Dashboard aus: Identität, Passkey-CRUD, lokaler PIN und KPIs; Kontolöschung wandert ausschließlich in die Profilseite.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 09:19:56 +02:00
elpatron 79a54fdfc2 chore: release v0.1.0.43 2026-05-30 20:56:13 +02:00
elpatron e73c078463 fix(seo): replaceState nur bei abweichendem lng und Manifest auf Deutsch
Vermeidet unnötige History-Änderungen beim Seitenaufbau und stellt die
PWA-Beschreibung konsistent zu lang: 'de' bereit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 20:55:14 +02:00
elpatron 2eb6551200 chore: release v0.1.0.42 2026-05-30 20:53:29 +02:00
elpatron 9baaccf239 feat(settings): Warnhinweis zum privaten Teilen des Logbuch-Links
Nutzer sollen den Share-Link nur privat teilen, nicht in sozialen Medien.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 20:52:57 +02:00
elpatron df53420f3b feat(seo): Zweisprachige Meta-Tags und hreflang für DE/EN
SEO-Texte in i18n, dynamische Meta-Updates beim Sprachwechsel, hreflang-Links und ?lng-Parameter; PWA-Manifest zweisprachig.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 20:52:39 +02:00
elpatron 5271ed90c1 fix(marketing): Union-Jack-ClipPath im Beta-Flyer wiederherstellen
Der vorherige Fix hatte den SVG-Pfad durch fragmentierte Subpaths ersetzt; korrekter Pfad und PDF neu generiert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 20:48:20 +02:00
elpatron a8ba998444 fix(marketing): Ungültigen Union-Jack-ClipPath im Beta-Flyer korrigieren
Der SVG-Pfad für die roten Diagonalen war syntaktisch fehlerhaft; PDF neu generiert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 20:44:40 +02:00
elpatron 67d169080e docs(marketing): Landesflaggen für DE/EN im Beta-Flyer
Deutsch- und Englisch-Hinweis mit Inline-SVG-Flaggen und aktualisiertem PDF.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 20:39:44 +02:00
elpatron c67c1425df chore: release v0.1.0.41 2026-05-30 19:32:37 +02:00
elpatron d231a7fb40 feat(logs): Maschinenstunden pro Reisetag und Verbrauch pro Stunde
Maschinenstunden sind im Journal erfassbar; der Kraftstoffverbrauch pro Maschinenstunde wird aus Tagesverbrauch und Maschinenstunden berechnet und in Journal sowie Statistik als Read-only angezeigt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 19:29:38 +02:00
elpatron 4acb9b1290 fix(logs): Crew-Unterschrift mit Benutzerzuordnung und Owner-Crew-Signatur
Klassische Crew-Signaturen speichern Unterzeichner und Datum; Export und UI zeigen die Zuordnung. Eigner ohne WRITE-Collaborators dürfen wieder als Crew per Passkey signieren.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 19:24:46 +02:00
elpatron 4484724d38 fix(logs): Skipper- und Crew-Unterschrift rollenbasiert trennen
Jede Rolle darf nur das eigene Signaturfeld bearbeiten; Passkey-Freigabe auf dem Server entsprechend eingeschränkt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 19:21:51 +02:00
elpatron 5ea5111ec3 fix(auth): Schiffsdaten und Skipper-Profil nur für Logbuch-Eigner
Eingeladene Crew (WRITE) sieht Schiffsdaten und Skipper-Profil schreibgeschützt; Server-Sync lehnt entsprechende Änderungen ab.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 19:17:45 +02:00
elpatron 7ab0ec6061 fix(logs): Ereignis-Bearbeitung sichern und Warnung bei ungespeicherten Änderungen
Normalisiert partielle Logbuch-Events beim Speichern (z. B. Besegelung) und warnt beim Verlassen von Editor, Tabs und Browser.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 19:15:49 +02:00
elpatron 258fee31ab fix(logs): Ereignisprotokoll chronologisch nach Uhrzeit sortieren
Einträge werden beim Laden, Speichern und Export älteste-oben angezeigt (sortLogEventsByTime).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 19:05:46 +02:00
elpatron 2e83f1c6bb fix(logs): Galerie-Upload für Foto-Anhänge auf Mobilgeräten ermöglichen
Entfernt capture="environment", damit Nutzer neben der Kamera auch Bilder aus der Gerätegalerie wählen können.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 18:59:48 +02:00
elpatron fcb76d1305 docs(marketing): Update beta flyer layout and content
Modified the screenshot layout to a three-column grid with adjusted gaps, reduced screenshot height for better fit, and refined feature descriptions for clarity. Added a new screenshot for vessel data. Updated the corresponding PDF to reflect these changes.
2026-05-30 18:22:09 +02:00
elpatron 7d96bbcfd8 docs(marketing): Beta-Flyer mit App-Screenshots und größerer Typografie
Zwei Screenshots nebeneinander, Schriftgrößen für bessere Seitennutzung auf A4.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 17:22:52 +02:00
elpatron a586fcbfba fix(ui): autocomplete und Formulare für Passwort-Felder
PIN, Backup-Export/Import und API-Key entsprechen Chrome-DOM-Empfehlungen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 16:36:38 +02:00
elpatron 0ed9ac6941 chore: release v0.1.0.40 2026-05-30 16:31:10 +02:00
elpatron b4fff04ee1 docs(marketing): Beta-Flyer-PDF neu generieren
Aktualisierte PDF-Version aus dem überarbeiteten HTML-Flyer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 16:30:55 +02:00
elpatron 7e01106801 fix(ui): Mobile-Layout für Tour, Header, Toolbars und Dialoge
Onboarding-Tooltip bleibt im Viewport; PWA-Banner während Tour aus.
Kopfzeilen, Listen-Toolbars, Link-Zeilen und Modals für iPhone optimiert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 16:30:33 +02:00
elpatron caf6e395cd docs(marketing): Revise beta flyer feature descriptions for clarity and detail
Updated the feature descriptions to enhance clarity, including separating passwordless login and end-to-end encryption, and specifying GPS track upload with map representation. Added new feature for photo attachments for skipper and crew. Updated the corresponding PDF to reflect these changes.
2026-05-30 15:17:43 +02:00
elpatron a67575f4d2 chore: release v0.1.0.39 2026-05-30 15:10:26 +02:00
elpatron c2d620025e feat(ui): Beta-Badge in Login-, Dashboard- und Logbuch-Titelzeile
Wiederverwendbare BetaBadge-Komponente mit i18n-Tooltip.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 15:10:11 +02:00
elpatron 1524321afd docs(marketing): Update beta flyer feature description for passwordless login
Revised the feature description to specify "Passwortlose Passkey-Anmeldung" instead of "Passkey-Anmeldung" for clarity. Updated the corresponding PDF to reflect this change.
2026-05-30 14:58:40 +02:00
elpatron ab8a188fa0 chore: release v0.1.0.38 2026-05-30 14:49:49 +02:00
elpatron bb98af040e feat(analytics): Plausible-Events für öffentliche Logbuch-Freigabe
Trackt Aktivierung des Freigabelinks und erfolgreiches Öffnen unter /share.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 14:49:19 +02:00
elpatron 333c36db21 docs(marketing): Correct typo in beta flyer feature description
Updated the description in the beta flyer to correct the phrase "Crew und Schiffsdaten" to "Crew- und Schiffsdaten" for grammatical accuracy.
2026-05-30 14:36:31 +02:00
elpatron 3bd1970c59 docs(marketing): Update GPS feature description in beta flyer
Revised the GPS feature description from "GPS-Tracks" to "GPS-Track Upload" for improved clarity. Updated the corresponding PDF to reflect this change.
2026-05-30 14:35:58 +02:00
elpatron 75c1369c75 docs(marketing): Split PDF & CSV export and encryption features for clarity
Updated the beta flyer to separate the PDF & CSV export feature from the encrypted backup and recovery feature, enhancing clarity in the feature list.
2026-05-30 14:35:07 +02:00
elpatron 9ce1e384b7 docs(marketing): Update beta flyer feature list for improved detail
Enhanced the feature description to include 'Crew' in the nautical logbook format. Updated the PDF to reflect these changes.
2026-05-30 14:32:28 +02:00
elpatron 3eee42a30c docs(marketing): Beta-Flyer Feature-Liste erweitern
Neue Punkte für Teilen, mehrere Logbücher, Sprachen und Kiel-Herkunft;
PDF neu erzeugt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 14:31:52 +02:00
elpatron 90ffff0da6 fix(beta-flyer): update feature descriptions for clarity and accuracy
Revised the descriptions of offline capabilities and encryption methods in the beta flyer to enhance clarity. The PWA is now described as functioning on any smartphone and tablet, and the encryption method is specified as end-to-end.
2026-05-30 14:23:58 +02:00
elpatron 5c815caf8a chore: release v0.1.0.37 2026-05-30 14:21:30 +02:00
elpatron c3836eb07d fix(invitation): Fehlertexte bei Sprachwechsel und Beta-Flyer-Logo
Speichert Einladungsfehler als i18n-Keys statt übersetzter Strings.
Beta-Flyer nutzt das Steuerrad-Logo (logo.png), PDF neu erzeugt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 14:21:03 +02:00
elpatron caf7d81ac9 i18n: Du-Ansprache und Einladungstexte in Locales auslagern
Deutsche UI-Texte und Beta-Flyer auf informelles Deutsch umstellen;
hardcodierte Strings aus InvitationAcceptance in de/en.json verschieben.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 14:18:47 +02:00
elpatron 8bcfb97e98 chore: release v0.1.0.36 2026-05-30 14:11:20 +02:00
elpatron b9ccb0dfb6 fix(client): Read-only-UI nur bei bestätigter READ-Rolle
Während des Ladens geteilter Logbücher wird Schreibzugriff nicht mehr
fälschlich gesperrt; der Zugriffs-Effect setzt bei fehlendem Record kein OWNER mehr.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 14:09:54 +02:00
elpatron d98e2e8dc0 feat(feedback): Rate-Limit und Spam-Erkennung für Feedback-Formular
Schützt den Feedback-Endpunkt vor Missbrauch durch pro-Nutzer-Limits, Honeypot, Zeitprüfung und einfache Inhaltsheuristiken.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 14:09:43 +02:00
elpatron f5f12f50f5 fix(sync): READ-Zugriff darf Sync-Queue nicht als erfolgreich leeren
Bei READ-only oder unbekannter Rolle gibt pushChanges false zurück, solange
noch Einträge in der Queue sind, damit lokale Änderungen nicht verloren gehen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 14:04:43 +02:00
elpatron 1437b75c2f feat(client): Onboarding-Tour um Statistik und Feedback erweitern
Neue Tour-Schritte für Statistik-Dashboard und Feedback-Formular, Hinweis
zum Löschen der Demo-Einträge und Landung auf Statistik nach Abschluss.
Rollenauflösung bei geteilten Logbüchern fail-closed bis die Rolle bekannt ist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 14:03:46 +02:00
elpatron 7d75e74679 fix: CORS-Origins, Sync-Body-Limit und geteilte Logbuch-Rolle
Erlaubt mehrere/normalisierte CORS-Origins mit Dev-Fallbacks für Session-Cookies,
stellt express.json wieder auf 50mb für große Sync-Payloads und setzt die
Zugriffsrolle beim Wechsel in geteilte Logbücher ohne Cache korrekt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 13:59:15 +02:00
elpatron 0276d8445e fix(client): Vite 6 statt Vite 8 wegen fehlender Rolldown-Bindings
Vite 8 benötigt native @rolldown-Bindings, die npm oft nicht installiert.
Downgrade auf Vite 6 mit plugin-react 4 behebt den Dev-Server-Absturz;
start-dev.sh prüft client/node_modules vor dem Start.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 13:54:30 +02:00
elpatron dea33e3f00 feat(security): Session-Cookies statt X-User-Id und API-Härtung
Ersetzt die spoofbare X-User-Id-Auth durch signierte HttpOnly-Sessions nach
WebAuthn, erzwingt WRITE-only Sync, speichert den Master-Key nur im RAM und
ergänzt CORS, Rate-Limits, Helmet sowie Passkey-Reauth für sensible Aktionen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 13:47:24 +02:00
elpatron 4f3f530f1f chore: release v0.1.0.35 2026-05-30 13:29:55 +02:00
elpatron 858d5d1d25 feat(feedback): optionales E-Mail-Kontaktfeld im Formular
Nutzer können optional eine E-Mail hinterlassen; Validierung client-/serverseitig, Weitergabe in Ntfy-Benachrichtigungen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 13:24:43 +02:00
elpatron c914156d70 fix(feedback): Erfolgsstatus inline anzeigen und Modal auto-schließen
Erfolgsmeldung erscheint im Formular statt hinter dem Modal; Schließen-Button oben rechts; Fehler inline.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 13:23:03 +02:00
elpatron 8bf89ed898 chore: release v0.1.0.34 2026-05-30 13:17:53 +02:00
elpatron adf8ee9929 fix(feedback): Ntfy in Docker, ASCII-Titel und Skipper-Badge
NTFY_* an den Backend-Container durchreichen; En-Dash im Ntfy-Header durch ASCII-Strich ersetzen (ByteString-Fehler). Skipper-Badge klar als Account-Anzeige kennzeichnen; start-dev.sh prüft npm vor dem Start.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 13:16:59 +02:00
elpatron 1055a12dad docs(marketing): Beta-Flyer (DIN A4) mit Playwright-Generierung
Skript und npm-Script zum Erzeugen des druckbaren Beta-Flyers als PDF.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 12:58:28 +02:00
elpatron f1f90da069 feat(feedback): Feedback-Formular mit Ntfy-Versand
Nutzer können Feedback aus dem Header senden; der Server leitet Nachrichten über Ntfy weiter (NTFY_* in .env).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 12:58:25 +02:00
elpatron 4541c81d3b chore: release v0.1.0.33 2026-05-30 12:39:29 +02:00
elpatron 03bb55f9a1 feat(weather): OWM-Fallback über Server-.env wenn kein User-Key
Wetter-Proxy auf /api/weather/current nutzt optionalen Nutzer-Key aus
den Einstellungen, sonst OpenWeatherMapAPIKey aus der Umgebung.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 12:37:58 +02:00
elpatron 69d5203305 chore: release v0.1.0.32 2026-05-30 12:20:05 +02:00
elpatron e8f9381c5f fix(docker): VAPID-Umgebungsvariablen an Backend durchreichen
Web Push benötigt VAPID_* aus der Host-.env im Backend-Container.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 12:19:50 +02:00
elpatron 442ddccceb feat(analytics): Plausible-Event für Footer-Link-Klick
Trackt „Footer Link Clicked“ beim Klick auf den Autoren-Link im App-Footer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:54:30 +02:00
elpatron f47413999c docs(seo): kostenlos und werbefrei in Meta-Tags ergänzen
Title, Description, Keywords, OG/Twitter sowie README und PWA-Manifest.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:50:32 +02:00
elpatron f23f0db70b chore: release v0.1.0.31 2026-05-30 11:46:26 +02:00
elpatron ece0abccbf docs: README um Web-Push-Dokumentation ergänzen
Beschreibt Opt-in, VAPID, iOS-PWA, Projektstruktur und Deployment-Hinweise.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:46:19 +02:00
elpatron 92e9020212 feat: Unterstützung für benutzerdefinierte Service Worker und Opt-in für Web Push-Benachrichtigungen
Ermöglicht es Logbuch-Eignern, benutzerdefinierte Service Worker zu verwenden und Web Push-Benachrichtigungen für Änderungen von Collaborators zu aktivieren, mit einem Opt-in in den Einstellungen.
2026-05-30 11:40:57 +02:00
elpatron 2428313a22 feat: Web Push für Logbuch-Eigner bei Crew-Sync
Benachrichtigt Owner optional per VAPID/Web Push, wenn Collaborators
Änderungen synchronisieren — ohne Klartext-Inhalte, mit Opt-in in den
Einstellungen, Custom Service Worker und Deep-Link zum Logbuch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:36:03 +02:00
elpatron 0e61bc5dad chore: release v0.1.0.30 2026-05-30 11:14:21 +02:00
elpatron 585ef788df fix: Rückgabetyp von fingerprintSignature für Passkey-Signaturen korrigieren
Behebt den TypeScript-Buildfehler, da Passkey-Signaturen Objekte und keine Strings sind.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:14:15 +02:00
elpatron 9aabb2729d chore: release v0.1.0.29 2026-05-30 11:12:37 +02:00
elpatron ebe4199b8b fix: Footer-Copyright und Signatur-Fingerprint vereinheitlichen
Footer zeigt KnorrLabs/Markus F.J. Busche mit Mailto nur am Namen. Signatur-Normalisierung ist für Persistenz und isDirty-Check konsistent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:12:26 +02:00
elpatron 10f01f1ffc chore: release v0.1.0.28 2026-05-30 11:08:18 +02:00
elpatron 29765d172e feat: Ereignisse sofort speichern und Save-Button bei Änderungen aktivieren
Ereignisprotokoll-Einträge werden direkt persistiert, ohne vorher die Logbuchseite zu speichern. Der Speichern-Button ist nur aktiv, wenn noch ungespeicherte Änderungen vorliegen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:07:54 +02:00
elpatron 5f9e83dbdd chore: release v0.1.0.27 2026-05-30 10:56:53 +02:00
elpatron aa2b35ddac feat: Ereignisprotokoll bearbeiten und Skipper-Signatur invalidieren
Bestehende Ereigniszeilen lassen sich nachträglich ändern; beim Speichern
oder Löschen wird nur die Skipper-Unterschrift entfernt, die Crew-Signatur bleibt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 10:56:45 +02:00
elpatron b5bc80594c chore: release v0.1.0.26 2026-05-30 10:41:35 +02:00
elpatron b88ce17e1d fix: Prevent signature alert loop when adding log events.
Stabilize dialog callbacks and dedupe signature-invalidation alerts so the UI no longer freezes after adding an event to a signed travel day.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 10:40:31 +02:00
248 changed files with 35390 additions and 2697 deletions
+182
View File
@@ -0,0 +1,182 @@
---
name: merge
description: >-
Merge Git branches safely — fetch latest, merge or rebase onto master, resolve
conflicts intelligently, and verify the result. Use when the user asks to merge
branches, sync with master, resolve merge conflicts, or bring a feature branch
up to date.
---
# Git Merge
Führe Branch-Merges sicher und nachvollziehbar aus. Für PR-Review, CI und
Comment-Triage siehe den **babysit**-Skill — dieser Skill deckt die Git-Merge-
Operation selbst ab.
## Projekt-Kontext
- **Basis-Branch:** `master` (nicht `main`)
- **Monorepo:** `client/` (React PWA) und `server/` (Express API) — Konflikte
können in beiden liegen
## Sicherheitsregeln (immer einhalten)
- **Niemals** `git config` ändern
- **Niemals** `--no-verify`, `--no-gpg-sign` o.ä. ohne explizite Anfrage
- **Niemals** `push --force` auf `master` — bei Bedarf warnen und abbrechen
- **Niemals** destruktive Befehle (`reset --hard`, `clean -fd`) ohne explizite Anfrage
- **Niemals** interaktive Git-Befehle (`-i`-Flags) — nicht unterstützt
- **Kein Commit** ohne explizite Anfrage des Users
- **Kein Push** ohne explizite Anfrage des Users
## Workflow
### 1. Ausgangslage klären
Parallel ausführen:
```bash
git status
git branch -vv
git log --oneline -5
```
Ermittle:
- Aktueller Branch
- Ziel-Branch (Standard: `master`)
- Ob uncommittete Änderungen vorliegen
- Ob der Branch einen Remote-Tracking-Branch hat
**Bei uncommitteten Änderungen:** Stashen (`git stash push -m "pre-merge"`) nur
mit Zustimmung oder wenn der User es verlangt hat. Sonst stoppen und melden.
### 2. Merge-Strategie wählen
| Situation | Empfehlung |
|-----------|------------|
| Feature-Branch aktuell halten | `git merge origin/master` (Merge-Commit) |
| Linearer Verlauf gewünscht | `git rebase origin/master` (nur wenn User Rebase verlangt) |
| Zwei Feature-Branches zusammenführen | `git merge <branch>` auf Ziel-Branch |
**Standard:** Merge (nicht Rebase), es sei denn der User verlangt Rebase.
### 3. Remote aktualisieren
```bash
git fetch origin
```
Vor dem Merge prüfen, wie weit der Branch hinter `origin/master` liegt:
```bash
git log --oneline HEAD..origin/master
git log --oneline origin/master..HEAD
```
### 4. Merge ausführen
**Feature-Branch mit master synchronisieren** (häuigster Fall):
```bash
git checkout <feature-branch>
git merge origin/master
```
**Branch in master mergen** (nur wenn User das ausdrücklich will — normalerweise
passiert das via PR):
```bash
git checkout master
git pull origin master
git merge <feature-branch>
```
Merge-Commit-Nachricht kurz und sachlich halten, z.B.:
`Merge branch 'master' into feature/push-notifications-owner`
### 5. Konflikte lösen
Konfliktdateien finden:
```bash
git diff --name-only --diff-filter=U
```
**Pro Konfliktdatei:**
1. Datei lesen und beide Seiten verstehen (HEAD = eigener Branch, incoming = gemergter Branch)
2. Intent beider Änderungen erhalten — nicht blind eine Seite wählen
3. Konfliktmarker entfernen (`<<<<<<<`, `=======`, `>>>>>>>`)
4. Bei widersprüchlicher Intent: Merge abbrechen und User fragen
```bash
git merge --abort # oder: git rebase --abort
```
**Typische Konflikt-Muster in diesem Projekt:**
| Bereich | Hinweis |
|---------|---------|
| `package-lock.json` | Nach manueller Lösung `npm install` im betroffenen Paket (`client/` oder `server/`) ausführen |
| i18n (`client/src/i18n/`) | Beide Sprachkeys (DE + EN) behalten, keine Keys verlieren |
| Prisma/Schema | Migrationen beider Seiten zusammenführen, nicht überschreiben |
| Verschlüsselung/Auth | Vorsichtig — keine Sicherheitslogik stillschweigend vereinfachen |
Nach jeder gelösten Datei:
```bash
git add <file>
```
Merge abschließen (nur wenn User Commit verlangt hat):
```bash
git commit -m "$(cat <<'EOF'
Merge branch 'master' into <feature-branch>
EOF
)"
```
### 6. Verifizieren
Nach erfolgreichem Merge:
```bash
git status
git log --oneline -5
```
Relevante Checks je nach betroffenen Bereichen:
```bash
# Client
cd client && npm run build
# Server
cd server && npm run build
```
Bei Lockfile-Konflikten oder Dependency-Änderungen: Build in beiden Paketen prüfen.
### 7. Abschluss
- Ergebnis dem User mitteilen: welche Branches, wie viele Konflikte, was gelöst wurde
- Bei `git stash`: erinnern, Stash wieder anzuwenden (`git stash pop`)
- Push nur auf explizite Anfrage: `git push origin <branch>`
## Wann abbrechen und fragen
- Widersprüchliche fachliche Intent (z.B. beide Seiten ändern dieselbe Logik unterschiedlich)
- Konflikte in Krypto-, Auth- oder Sync-Kernlogik ohne klares „richtig“
- Merge würde `.env`, Credentials oder Secrets einschließen
- User wollte nur Status prüfen, nicht tatsächlich mergen
## Abgrenzung zu anderen Skills
| Skill | Wann |
|-------|------|
| **merge** (dieser) | Git merge/rebase, Konflikte, Branch sync |
| **babysit** | PR merge-ready: Comments, CI, PR-Konflikte im PR-Kontext |
| **creating-pull-requests** | PR erstellen und pushen |
+39 -3
View File
@@ -1,7 +1,43 @@
OpenWeatherMapAPIKey=<owm_api_key>
# DeepL API (for scripts/translate-locales.mjs and scripts/translate-flyer.mjs)
# Free plan keys use api-free.deepl.com automatically (suffix :fx)
DeepLAPIKey=
# Passkey configuration (WebAuthn Relying Party ID and Origin)
# For local dev: localhost and http://localhost
# For production: e.g. kapteins-daagbok.eu and https://kapteins-daagbok.eu
# For local dev: use localhost (NOT 127.0.0.1 — browsers reject IP addresses for Passkeys)
# Production (kapteins-daagbok.eu):
# RP_ID=kapteins-daagbok.eu
# ORIGIN=https://kapteins-daagbok.eu
RP_ID=localhost
ORIGIN=http://localhost
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
ORIGIN=http://localhost:5173
# Behind Nginx Proxy Manager — see docs/deployment/npm-security.md
# TRUST_PROXY=172.16.10.10
# TRUST_PROXY=1
# Docker Compose database (required for production deploy)
# Generate: openssl rand -hex 24
# Rotate on running server: ./scripts/rotate-postgres-password.sh (see docs/deployment/postgres-password.md)
# POSTGRES_USER=postgres
# POSTGRES_PASSWORD=
# POSTGRES_DB=daagbox
# Optional: comma-separated CORS origins (defaults to ORIGIN; 127.0.0.1 may be allowed for CORS but not for login)
# CORS_ORIGINS=http://localhost:5173
# API session signing (min. 32 chars; required in production)
# Generate: openssl rand -base64 48
SESSION_SECRET=
# Web Push (VAPID) — generate with: npx web-push generate-vapid-keys
# Public key may also be set on the client as VITE_VAPID_PUBLIC_KEY
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
# Feedback via Ntfy (https://ntfy.sh or self-hosted)
# NTFY_TOPIC: topic name only (not the full URL)
NTFY_SERVER=https://ntfy.sh
NTFY_TOPIC=kapteins-daagbok-feedback
NTFY_TOKEN=tk_example_ntfy_access_token
+2
View File
@@ -11,3 +11,5 @@ server/dist/
.env.local
.env.*.local
*.log
userfeedback/
@@ -168,7 +168,7 @@ Beim Laden eines Eintrags: `computedHash !== sig.entryHash` → UI-Warnung.
Neuer Router: `server/src/routes/sign.ts` → Mount unter `/api/sign`
Auth wie bestehend: Header `X-User-Id` (siehe `sync.ts`).
Auth wie bestehend: HttpOnly-Session-Cookie `daagbok_session` nach WebAuthn (`server/src/middleware/auth.ts`, Client `apiFetch` mit `credentials: 'include'`).
### 4.1 `POST /api/sign/options`
@@ -472,7 +472,7 @@ test('isSignatureValidForEntry')
| WebAuthn Login | `client/src/services/auth.ts`, `server/src/routes/auth.ts` |
| Collaborators | `server/src/routes/collaboration.ts`, `SettingsForm.tsx` |
| E2E-Einträge | `EntryPayload` in `server/prisma/schema.prisma` |
| Auth-Header | `X-User-Id` in `server/src/routes/sync.ts` |
| API-Auth | Session-Cookie via `requireUser` in `server/src/middleware/auth.ts` |
---
+122 -16
View File
@@ -1,8 +1,8 @@
# Kapteins Daagbok
Digitales Yacht-Logbuch als Progressive Web App (PWA) — offline-fähig, End-to-End-verschlüsselt und mit Passkey-Anmeldung.
Digitales Yacht-Logbuch als Progressive Web App (PWA) — **kostenlos**, **werbefrei**, offline-fähig, End-to-End-verschlüsselt und mit Passkey-Anmeldung.
**Live:** [kapteins-daagbok.eu](https://kapteins-daagbok.eu)
**Live:** [kapteins-daagbok.eu](https://kapteins-daagbok.eu) · **Demo:** [kapteins-daagbok.eu/demo](https://kapteins-daagbok.eu/demo)
## Überblick
@@ -15,18 +15,29 @@ Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API).
- **Passkey-Authentifizierung** (WebAuthn) mit optionaler Recovery-Phrase und lokalem PIN-Fallback
- **Mehrere Logbücher** pro Benutzerkonto — eigene Logbücher und per Einladung geteilte Logbücher (Crew-Zugang) klar getrennt
- **Reisetage** mit Hafen, Wetter, Tankständen, Ereignissen und Tagesnummer
- **Kompass-Dial** für MgK- und RwK-Kurse — Ring-Eingabe, Gradfeld, Schrittweite 1°/5°/10° (maritime Orientierung: 0° = Nord)
- **GPS-Tracks** (GPX/KML/GeoJSON-Upload, Karte, Statistiken)
- **Foto-Anhänge** pro Reisetag
- **Passkey-Signaturen** für Skipper und Crew (hybride elektronische Signatur)
- **Schiffsdaten** und **Crew-Profile** (Skipper + Mitglieder)
- **Statistik-Dashboard** — Strecken, Verbrauch, Segel/Motor, Hafenkette (pro Logbuch oder accountweit)
- **Benutzerprofil** — kontoweite Einstellungen: Darstellung (Theme, Hell/Dunkel), OpenWeatherMap-API-Key, Web Push, PWA-Installation, Onboarding-Tour, Passkey-Verwaltung, Account-Statistik
- **Kollaboration** — Crew per Einladungslink einladen (Schreib- oder Lesezugriff)
- **Push-Benachrichtigungen** (optional) — Logbuch-Eigner per Web Push informieren, wenn Crew Änderungen synchronisiert (ohne Klartext-Inhalte; Opt-in im Benutzerprofil)
- **Read-only-Freigabe** — öffentlicher Lese-Link für Dritte
- **Export** — PDF pro Reisetag, CSV-Download/-Teilen
- **Backup & Wiederherstellung** — vollständiges verschlüsseltes Logbuch-Backup (Einträge, Fotos, GPS, Crew, Schiff) als `.daagbok.json`; Restore auf gleichem oder neuem Account
- **Feedback** — Bug-, Feature- und allgemeine Rückmeldungen aus der App (serverseitig via [Ntfy](https://ntfy.sh) oder self-hosted)
- **PWA** — installierbar auf iOS/Android, Offline-Modus, Update-Hinweise
- **Mehrsprachig** — Deutsch und Englisch
- **Demo-Logbuch & Onboarding-Tour** für neue Nutzer
- **Demo-Logbuch & Onboarding-Tour** für neue Nutzer (auch unter `/demo` ohne Anmeldung)
### Benutzerprofil vs. Logbuch-Einstellungen
| Bereich | Inhalt |
|---------|--------|
| **Benutzerprofil** | Theme, Farbschema, Wetter-API-Key, Push, PWA, Tour, Passkeys, Account löschen |
| **Logbuch-Einstellungen** | Crew-Einladungen, öffentliche Freigabe, Backup & Wiederherstellung (nur Eigner) |
## Architektur
@@ -44,22 +55,36 @@ Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API).
| Lokaler Speicher | Dexie.js (IndexedDB), Hintergrund-Sync |
| Backend | Node.js, Express, Prisma |
| Datenbank | PostgreSQL 16 |
| Auth | WebAuthn (Passkeys) via `@simplewebauthn` |
| Auth | WebAuthn (Passkeys) + signiertes HttpOnly-Session-Cookie (`daagbok_session`) |
| Krypto | Web Crypto API (AES-GCM), BIP39 Recovery |
| Push (optional) | Web Push (VAPID), Custom Service Worker (`injectManifest`) |
| Feedback (optional) | Ntfy (HTTP Publish) |
### Rollen & Zugriff
| Rolle | Bedeutung |
|-------|-----------|
| **Owner** | Logbuch angelegt; voller Zugriff, Einladungen, Backup, Löschen |
| **Owner** | Logbuch angelegt; voller Zugriff, Einladungen, Backup, Löschen; optional Push bei Crew-Änderungen |
| **Collaborator (WRITE)** | Per Einladung; Einträge bearbeiten und als Crew signieren |
| **Collaborator (READ)** | Nur Lesen (z. B. öffentlicher Share-Link) |
Skipper- und Crew-Profile im Logbuch sind **Inhaltsdaten** (verschlüsselt), nicht an den Account gebunden. Ein Account kann gleichzeitig Owner eines eigenen und Collaborator in fremden Logbüchern sein.
### Authentifizierung & Session
| Schicht | Verhalten |
|---------|-----------|
| **Login** | WebAuthn (`/api/auth/login-verify`) — danach HttpOnly-Cookie, 7 Tage gültig |
| **API-Aufrufe** | Cookie `credentials: 'include'` (Client: `apiFetch`) — kein `X-User-Id` |
| **Master-Key** | Nur im RAM; nach Reload Entsperren per Passkey oder lokalem PIN |
| **Step-up** | Konto löschen, PRF-Enrollment: frische Passkey-Bestätigung (`/api/auth/reauth-*`) |
| **Sync WRITE** | Server lehnt Schreib-Sync für Collaborator mit `READ` ab |
Öffentliche Routen (ohne Session): Registrierung/Login-Optionen, Einladungsdetails, Read-only-Share (`share-pull`), Health-Check, VAPID-Public-Key.
## Backup & Wiederherstellung
Nur der **Logbuch-Eigner** kann unter **Einstellungen → Backup & Wiederherstellung** ein vollständiges Backup erstellen:
Nur der **Logbuch-Eigner** kann unter **Logbuch-Einstellungen → Backup & Wiederherstellung** ein vollständiges Backup erstellen:
1. Backup-Passphrase wählen (min. 8 Zeichen, getrennt von der Datei aufbewahren)
2. Download als `.daagbok.json` — enthält alle verschlüsselten Payloads inkl. **Fotos** und GPS-Tracks
@@ -67,20 +92,55 @@ Nur der **Logbuch-Eigner** kann unter **Einstellungen → Backup & Wiederherstel
Vor dem Löschen eines Logbuchs weist die App auf diese Funktion hin. Crew-Einladungen und Passkey-Signaturen werden nicht mitübertragen — Inhalte bleiben lesbar, Signaturen auf neuem Account ggf. nicht mehr verifizierbar.
## Push-Benachrichtigungen (optional)
Logbuch-**Eigner** können im **Benutzerprofil** Web Push aktivieren. Sobald ein eingeladenes Crewmitglied mit Schreibrechten (`WRITE`) Änderungen synchronisiert, erhält der Owner eine **generische** Benachrichtigung — der Server kennt keine Logbuch-Inhalte (Zero-Knowledge).
| Aspekt | Verhalten |
|--------|-----------|
| Auslöser | Erfolgreicher Sync-Push durch Collaborator (`create`/`update`) |
| Aggregation | Mehrere Änderungen in einem Sync → eine Benachrichtigung pro Logbuch |
| Drosselung | Max. eine Push-Nachricht pro Logbuch alle 3 Minuten |
| Klick | Öffnet die App auf dem betroffenen Logbuch |
**Voraussetzungen:**
- HTTPS (Produktion)
- VAPID-Schlüssel auf dem Server (`VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT`)
- Browser-Berechtigung „Benachrichtigungen“; auf **iOS** installierte PWA ab iOS 16.4+
Schlüssel erzeugen: `npx web-push generate-vapid-keys` (im `server/`-Verzeichnis oder global).
Ausführlicher Implementierungs- und Testplan: [docs/push-notifications-plan.md](docs/push-notifications-plan.md).
## Feedback (optional)
Eingeloggte Nutzer können über das Feedback-Formular in der App Rückmeldungen senden. Der Server leitet sie an einen **Ntfy**-Topic weiter (kein Klartext-Logbuch auf dem Server).
| Variable | Bedeutung |
|----------|-----------|
| `NTFY_SERVER` | Basis-URL (Standard: `https://ntfy.sh`) |
| `NTFY_TOPIC` | Topic-Name (ohne URL) |
| `NTFY_TOKEN` | Optional: Access-Token für geschützte Topics |
Ohne `NTFY_TOPIC` antwortet die API mit „nicht konfiguriert“. Rate-Limiting und einfacher Spam-Schutz sind serverseitig aktiv.
## Projektstruktur
```
kapteins-daagbok/
├── client/ # React-PWA (Frontend)
│ ├── src/
│ │ ├── components/ # UI-Komponenten
│ │ ├── services/ # Auth, Sync, Krypto, Backup, Analytics, …
│ │ ├── components/ # UI (u. a. CourseDialInput, UserProfilePage, FeedbackModal)
│ │ ├── services/ # Auth, Sync, Krypto, Backup, Push, Analytics, …
│ │ ├── sw.ts # Service Worker (Precache + Web Push)
│ │ └── i18n/ # DE/EN-Übersetzungen
│ └── Dockerfile # Nginx-Produktions-Image
├── server/ # Express-API + Prisma
│ ├── src/routes/ # auth, logbooks, sync, collaboration, sign
│ ├── src/routes/ # auth, logbooks, sync, collaboration, sign, push, feedback, weather
│ ├── src/services/ # z. B. pushNotify, ntfyNotify
│ └── prisma/ # Datenbankschema
├── docs/ # Projektdokumentation (z. B. Plausible Events)
├── docs/ # Projektdokumentation
├── scripts/ # Dev- und Deploy-Skripte
├── docker-compose.yml # Produktions-Stack (DB + Backend + Frontend)
└── VERSION # App-Version (Build & Footer)
@@ -91,7 +151,9 @@ kapteins-daagbok/
- **Node.js** 20+
- **npm**
- **Docker** (für PostgreSQL in der Entwicklung oder den vollständigen Stack)
- Optional: OpenWeatherMap-API-Key (Wetter-Abruf in den Einstellungen)
- Optional: eigener OpenWeatherMap-API-Key im **Benutzerprofil** (sonst serverseitiger Key aus `.env`)
- Optional: VAPID-Schlüssel für Web Push (siehe Abschnitt Push-Benachrichtigungen)
- Optional: Ntfy-Topic für Feedback (siehe Abschnitt Feedback)
## Lokale Entwicklung
@@ -108,16 +170,34 @@ cd client && npm ci && cd ..
cp .env.example .env
```
Für lokale Passkeys: `RP_ID=localhost`, `ORIGIN=http://localhost:5173` (bzw. die tatsächliche Frontend-URL).
Kopiere `.env.example` nach `.env` und passe mindestens an:
Im `server/`-Verzeichnis eine `.env` mit `DATABASE_URL` anlegen, z. B.:
| Variable | Dev (Vite) | Produktion |
|----------|------------|------------|
| `RP_ID` | `localhost` | `kapteins-daagbok.eu` |
| `ORIGIN` | `http://localhost:5173` | `https://kapteins-daagbok.eu` |
| `SESSION_SECRET` | empfohlen (≥ 32 Zeichen) | **Pflicht** |
`ORIGIN` muss **exakt** der Frontend-URL entsprechen (CORS + Session-Cookie). Das Backend lädt `.env` aus dem Projektroot und optional `server/.env`.
```
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/daagbox?schema=public"
OpenWeatherMapAPIKey= # Fallback für Wetter-Abruf, wenn Nutzer keinen eigenen Key hat
RP_ID=localhost
ORIGIN=http://localhost:5173
SESSION_SECRET= # openssl rand -base64 48 (in Prod Pflicht)
# Optional — Web Push (npx web-push generate-vapid-keys)
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
# Optional — Feedback via Ntfy
NTFY_SERVER=https://ntfy.sh
NTFY_TOPIC=
NTFY_TOKEN=
```
`./scripts/start-dev.sh` prüft `ORIGIN` und `SESSION_SECRET` beim Start und gibt Hinweise aus.
### 3. Datenbank & Schema
Das Dev-Skript startet PostgreSQL in Docker (`postgres-daagbox`). Schema anwenden:
@@ -137,6 +217,21 @@ cd server && npx prisma db push && cd ..
| Frontend (Vite) | http://localhost:5173 |
| Backend API | http://localhost:5000 |
| Health Check | http://localhost:5000/api/health |
| Public Demo | http://localhost:5173/demo |
### 5. Qualität & Tests
Vor jedem Deploy auf [kapteins-daagbok.eu](https://kapteins-daagbok.eu/) (kein externes CI):
```bash
npm run check
# oder: ./scripts/predeploy-check.sh
```
Einzeln: `npm test` (Client + Server) · `npm run build` · optional `npm run lint` (Client, noch nicht in `check`)
- **Client:** Vitest für Utils, i18n, Services
- **Server:** Smoke-Tests (`/api/health`, Auth-Guards) mit Supertest — siehe `server/src/api.smoke.test.ts`
## Docker (produktionsnah)
@@ -146,13 +241,14 @@ Gesamten Stack lokal bauen und starten:
./scripts/start-dev-docker.sh
```
Frontend: http://localhost · API: http://localhost/api/health
Frontend: http://localhost · API: http://localhost/api/health · Demo: http://localhost/demo
Umgebungsvariablen für Passkeys in `.env` setzen (`RP_ID`, `ORIGIN`).
Umgebungsvariablen in `.env` setzen — mindestens `RP_ID`, `ORIGIN` (z. B. `http://localhost`), `SESSION_SECRET` und für Docker Compose `POSTGRES_PASSWORD`. Für Push die VAPID-Variablen an den Backend-Container durchreichen (`docker-compose.yml``backend.environment`). Für Feedback `NTFY_*` setzen.
## Deployment
Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen):
Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen). Führt vor dem SSH-Deploy automatisch [`predeploy-check.sh`](scripts/predeploy-check.sh) aus (`npm run check`):
```bash
./scripts/update-prod.sh
@@ -160,11 +256,21 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen):
Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
Auf dem Server müssen `.env` u. a. `POSTGRES_PASSWORD`, `RP_ID`, `ORIGIN` (`https://kapteins-daagbok.eu`), `SESSION_SECRET` (≥ 32 Zeichen), `TRUST_PROXY` (NPM, z. B. `172.16.10.10` oder `1`) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deployment/npm-security.md).
## Dokumentation
| Dokument | Inhalt |
|----------|--------|
| [docs/deployment/npm-security.md](docs/deployment/npm-security.md) | NPM, TLS, `trust proxy`, Security-Header |
| [docs/deployment/predeploy.md](docs/deployment/predeploy.md) | Pre-Deploy-Checks ohne CI |
| [docs/deployment/postgres-password.md](docs/deployment/postgres-password.md) | PostgreSQL-Passwort rotieren / App-Rolle |
| [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics |
| [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan |
| [docs/plan-compass-course-dial.md](docs/plan-compass-course-dial.md) | Kompass-Dial: UX- und Implementierungsplan |
| [docs/marketing/kapteins-daagbok-beta-flyer.pdf](docs/marketing/kapteins-daagbok-beta-flyer.pdf) | Beta-Flyer (DIN A4) zum Ausdrucken — Quelle: `docs/marketing/beta-flyer.html`, neu erzeugen: `cd client && npm run generate:flyer` |
| [.planning/PROJECT.md](.planning/PROJECT.md) | Produktvision und Anforderungen (GSD) |
## Analytics
+1 -1
View File
@@ -1 +1 @@
0.1.0.26
0.1.0.93
+16 -8
View File
@@ -4,34 +4,42 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Digitales Yacht-Logbuch mit End-to-End-Verschlüsselung und Passkey-Anmeldung. Reisetage, GPS-Tracks, Crew und Schiffsdaten sicher dokumentieren auch offline als PWA." />
<meta name="keywords" content="Yacht-Logbuch, Schiffstagebuch, Bordlogbuch, Segeln, Passkey, E2E-Verschlüsselung, GPS-Track, maritimes Logbuch" />
<meta name="description" content="Kostenloses, werbefreies digitales Yacht-Logbuch mit End-to-End-Verschlüsselung und Passkey-Anmeldung. Reisetage, GPS-Tracks, Crew und Schiffsdaten sicher dokumentieren auch offline als PWA." />
<meta name="keywords" content="Yacht-Logbuch, Schiffstagebuch, Bordlogbuch, Segeln, Passkey, E2E-Verschlüsselung, GPS-Track, maritimes Logbuch, kostenlos, werbefrei, gratis, ohne Werbung, yacht logbook, sailing log, ad-free" />
<meta name="author" content="Markus F.J. Busche" />
<meta name="robots" content="index, follow" />
<meta name="application-name" content="Kapteins Daagbok" />
<link rel="canonical" href="https://kapteins-daagbok.eu/" />
<link rel="alternate" hreflang="de" href="https://kapteins-daagbok.eu/?lng=de" />
<link rel="alternate" hreflang="en" href="https://kapteins-daagbok.eu/?lng=en" />
<link rel="alternate" hreflang="da" href="https://kapteins-daagbok.eu/?lng=da" />
<link rel="alternate" hreflang="sv" href="https://kapteins-daagbok.eu/?lng=sv" />
<link rel="alternate" hreflang="nb" href="https://kapteins-daagbok.eu/?lng=nb" />
<link rel="alternate" hreflang="x-default" href="https://kapteins-daagbok.eu/" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Daagbok" />
<meta name="theme-color" content="#1e293b" />
<meta name="theme-color" content="#0b0c10" />
<script src="/appearance-bootstrap.js"></script>
<script src="/bootstrap-watchdog.js"></script>
<link rel="apple-touch-icon" href="/logo.png" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Kapteins Daagbok" />
<meta property="og:title" content="Kapteins Daagbok Digitales Yacht-Logbuch" />
<meta property="og:description" content="Sicheres, E2E-verschlüsseltes Logbuch für Skipper: Reisetage, GPS-Tracks, Crew- und Schiffsdaten mit Passkey-Anmeldung und Offline-PWA." />
<meta property="og:title" content="Kapteins Daagbok Kostenloses digitales Yacht-Logbuch" />
<meta property="og:description" content="Kostenlos und werbefrei: sicheres, E2E-verschlüsseltes Logbuch für Skipper. Reisetage, GPS-Tracks, Crew- und Schiffsdaten Passkey-Anmeldung und Offline-PWA." />
<meta property="og:url" content="https://kapteins-daagbok.eu/" />
<meta property="og:image" content="https://kapteins-daagbok.eu/logo.png" />
<meta property="og:image:alt" content="Kapteins Daagbok Logo" />
<meta property="og:locale" content="de_DE" />
<meta property="og:locale:alternate" content="en_US" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Kapteins Daagbok Digitales Yacht-Logbuch" />
<meta name="twitter:description" content="Sicheres, E2E-verschlüsseltes Logbuch für Skipper: Reisetage, GPS-Tracks, Crew- und Schiffsdaten mit Passkey-Anmeldung und Offline-PWA." />
<meta name="twitter:title" content="Kapteins Daagbok Kostenloses digitales Yacht-Logbuch" />
<meta name="twitter:description" content="Kostenlos und werbefrei: sicheres, E2E-verschlüsseltes Logbuch für Skipper. Reisetage, GPS-Tracks, Crew- und Schiffsdaten Passkey-Anmeldung und Offline-PWA." />
<meta name="twitter:image" content="https://kapteins-daagbok.eu/logo.png" />
<meta name="twitter:image:alt" content="Kapteins Daagbok Logo" />
<script defer data-domain="kapteins-daagbok.eu" src="https://plausible.elpatron.me/js/script.tagged-events.js"></script>
<title>Kapteins Daagbok Digitales Yacht-Logbuch</title>
<title>Kapteins Daagbok Kostenloses digitales Yacht-Logbuch (werbefrei)</title>
</head>
<body>
<div id="root"></div>
+20 -3
View File
@@ -3,15 +3,32 @@ server {
server_name localhost;
client_max_body_size 50M;
# Security headers (TLS/HSTS at NPM — see docs/deployment/npm-security.md)
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
# Service worker and app shell must revalidate so PWA updates are detected
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest)$ {
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location = /index.html {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, must-revalidate";
add_header Cache-Control "no-cache, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location / {
+1461 -665
View File
File diff suppressed because it is too large Load Diff
+20 -7
View File
@@ -7,7 +7,15 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"test": "vitest run",
"preview": "vite preview",
"generate:flyer": "node ../scripts/generate-beta-flyer.mjs",
"generate:flyer:png": "node ../scripts/generate-beta-flyer.mjs --png",
"generate:flyer:all": "node ../scripts/generate-beta-flyer.mjs --all",
"generate:flyer:setup": "playwright install chromium",
"translate:locales": "node ../scripts/translate-locales.mjs",
"translate:flyer": "node ../scripts/translate-flyer.mjs",
"validate:i18n": "node ../scripts/validate-i18n-keys.mjs"
},
"dependencies": {
"@simplewebauthn/browser": "^13.3.0",
@@ -21,25 +29,30 @@
"lucide-react": "^1.16.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.8"
"react-i18next": "^17.0.8",
"qrcode": "^1.5.4"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.12.3",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitejs/plugin-react": "^4.7.0",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"happy-dom": "^20.9.0",
"playwright": "^1.51.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",
"vite-plugin-pwa": "^1.3.0"
"vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.1",
"vitest": "^3.2.4"
},
"optionalDependencies": {
"@rolldown/binding-linux-x64-gnu": "^1.0.2"
"engines": {
"node": ">=20.0.0"
}
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Applies saved appearance classes before CSS/JS bundle loads (prevents wrong flash on PWA).
* Logic mirrors client/src/services/appearance.ts + userPreferences.ts.
*/
(function () {
try {
var uid = localStorage.getItem('active_userid')
var theme = 'auto'
var scheme = 'auto'
if (uid) {
theme =
localStorage.getItem('user_pref_theme_' + uid) ||
localStorage.getItem('active_theme') ||
'auto'
scheme =
localStorage.getItem('user_pref_color_scheme_' + uid) ||
localStorage.getItem('active_color_scheme') ||
'auto'
} else {
theme = localStorage.getItem('active_theme') || 'auto'
scheme = localStorage.getItem('active_color_scheme') || 'auto'
}
var resolvedTheme = theme
if (resolvedTheme !== 'ocean' && resolvedTheme !== 'material' && resolvedTheme !== 'cupertino') {
var ua = navigator.userAgent || navigator.vendor || ''
if (/iPad|iPhone|iPod|Macintosh/.test(ua)) resolvedTheme = 'cupertino'
else if (/Android|Linux/.test(ua)) resolvedTheme = 'material'
else resolvedTheme = 'ocean'
}
var resolvedScheme = scheme
if (resolvedScheme !== 'light' && resolvedScheme !== 'dark') {
resolvedScheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
var root = document.documentElement
root.classList.add('theme-' + resolvedTheme, 'scheme-' + resolvedScheme)
root.style.colorScheme = resolvedScheme
} catch (_) {
/* ignore storage / matchMedia errors */
}
})()
+221
View File
@@ -0,0 +1,221 @@
/**
* Boot watchdog for production PWAs.
* Recovers from white/black screens when stale HTML points to missing JS chunks.
* Does not clear caches automatically while offline to protect unsynced data.
*/
(function () {
if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') {
return
}
var BOOT_TIMEOUT_MS = 12000
var ATTEMPT_WINDOW_MS = 120000
var ATTEMPT_COUNT_KEY = 'pwa_boot_watchdog_attempt_count'
var ATTEMPT_LAST_KEY = 'pwa_boot_watchdog_attempt_last_ts'
var PENDING_EVENTS_KEY = 'pwa_boot_pending_events'
var MAX_PENDING_EVENTS = 12
function enqueueEvent(name, props) {
try {
var current = JSON.parse(sessionStorage.getItem(PENDING_EVENTS_KEY) || '[]')
if (!Array.isArray(current)) current = []
current.push({ name: name, props: props, ts: Date.now() })
if (current.length > MAX_PENDING_EVENTS) {
current = current.slice(current.length - MAX_PENDING_EVENTS)
}
sessionStorage.setItem(PENDING_EVENTS_KEY, JSON.stringify(current))
} catch (_) {
/* ignore analytics queue errors */
}
}
function emit(name, props) {
if (typeof window.plausible === 'function') {
if (props && Object.keys(props).length > 0) {
window.plausible(name, { props: props })
} else {
window.plausible(name)
}
return
}
enqueueEvent(name, props)
}
function hasBootstrapped() {
return window.__KDB_APP_BOOTSTRAPPED === true
}
function resetAttempts() {
try {
sessionStorage.removeItem(ATTEMPT_COUNT_KEY)
sessionStorage.removeItem(ATTEMPT_LAST_KEY)
} catch (_) {
/* ignore storage errors */
}
}
function nextAttempt() {
try {
var now = Date.now()
var last = Number(sessionStorage.getItem(ATTEMPT_LAST_KEY) || '0')
var count = Number(sessionStorage.getItem(ATTEMPT_COUNT_KEY) || '0')
if (now - last > ATTEMPT_WINDOW_MS) {
count = 0
}
count += 1
sessionStorage.setItem(ATTEMPT_COUNT_KEY, String(count))
sessionStorage.setItem(ATTEMPT_LAST_KEY, String(now))
return count
} catch (_) {
return 1
}
}
function createRecoveryUrl(reason) {
try {
var url = new URL(location.href)
url.searchParams.set('boot_recover', reason)
url.searchParams.set('_', String(Date.now()))
return url.toString()
} catch (_) {
return location.href
}
}
async function clearServiceWorkerCaches() {
if ('serviceWorker' in navigator) {
try {
var registrations = await navigator.serviceWorker.getRegistrations()
await Promise.all(
registrations.map(function (registration) {
return registration.unregister()
})
)
} catch (_) {
/* ignore SW cleanup errors */
}
}
if ('caches' in window) {
try {
var keys = await caches.keys()
await Promise.all(
keys.map(function (key) {
return caches.delete(key)
})
)
} catch (_) {
/* ignore cache cleanup errors */
}
}
}
function renderFallback(isOffline) {
var root = document.getElementById('root')
if (!root) return
root.innerHTML =
'<div class="auth-screen">' +
'<div class="auth-card glass" role="alert" style="max-width:460px">' +
'<h2 style="margin-top:0">Kapteins Daagbok</h2>' +
'<p style="color:var(--app-text-muted);line-height:1.5;margin-bottom:8px">' +
(isOffline
? 'Die App konnte offline nicht sauber starten. Deine lokalen, nicht synchronisierten Daten bleiben erhalten.'
: 'Die App konnte nicht sauber starten. Deine lokalen, nicht synchronisierten Daten bleiben erhalten.') +
'</p>' +
'<p style="color:var(--app-text-muted);line-height:1.5;margin-top:0">' +
(isOffline
? 'Bitte neu laden. Wenn wieder Netz verfügbar ist, kann die App-Engine automatisch repariert werden.'
: 'Du kannst jetzt eine App-Reparatur ausfuehren, ohne IndexedDB-Logbuchdaten zu loeschen.') +
'</p>' +
'<button type="button" class="btn primary" id="boot-reload-btn" style="width:100%">' +
'Neu laden' +
'</button>' +
(!isOffline
? '<button type="button" class="btn secondary" id="boot-repair-btn" style="width:100%;margin-top:12px">' +
'App-Reparatur (Cache + Service Worker)' +
'</button>'
: '') +
'</div>' +
'</div>'
var reloadBtn = document.getElementById('boot-reload-btn')
if (reloadBtn) {
reloadBtn.addEventListener('click', function () {
location.replace(createRecoveryUrl('retry'))
})
}
var repairBtn = document.getElementById('boot-repair-btn')
if (repairBtn) {
repairBtn.addEventListener('click', function () {
emit('PWA Boot Watchdog Manual Repair', {
attempt: Number(sessionStorage.getItem(ATTEMPT_COUNT_KEY) || '0'),
online: navigator.onLine
})
Promise.resolve()
.then(clearServiceWorkerCaches)
.finally(function () {
resetAttempts()
location.replace(createRecoveryUrl('manual-hard-recovery'))
})
})
}
}
function runWatchdog() {
window.setTimeout(function () {
if (hasBootstrapped()) {
resetAttempts()
return
}
var attempt = nextAttempt()
var online = navigator.onLine
if (attempt === 1) {
emit('PWA Boot Watchdog Soft', {
attempt: attempt,
online: online,
reason: online ? 'soft-reload' : 'offline-retry'
})
Promise.resolve()
.then(function () {
if ('serviceWorker' in navigator && navigator.serviceWorker.getRegistration) {
return navigator.serviceWorker.getRegistration().then(function (registration) {
if (registration) {
return registration.update().catch(function () {})
}
})
}
})
.finally(function () {
location.replace(createRecoveryUrl(online ? 'soft-reload' : 'offline-retry'))
})
return
}
if (attempt === 2 && online) {
emit('PWA Boot Watchdog Hard', {
attempt: attempt,
online: online,
reason: 'hard-recovery'
})
Promise.resolve()
.then(clearServiceWorkerCaches)
.finally(function () {
location.replace(createRecoveryUrl('hard-recovery'))
})
return
}
emit('PWA Boot Watchdog Fallback', {
attempt: attempt,
online: online,
reason: online ? 'retries-exhausted' : 'offline-retries-exhausted'
})
renderFallback(!online)
}, BOOT_TIMEOUT_MS)
}
runWatchdog()
})()
+2493 -21
View File
File diff suppressed because it is too large Load Diff
+431 -70
View File
@@ -1,10 +1,14 @@
import { useState, useEffect, useCallback } from 'react'
import './App.css'
import { useState, useEffect, useCallback, useRef } from 'react'
import { DialogProvider } from './components/ModalDialog.tsx'
import AuthOnboarding from './components/AuthOnboarding.tsx'
import UserProfilePage from './components/UserProfilePage.tsx'
import LogbookDashboard from './components/LogbookDashboard.tsx'
import VesselForm from './components/VesselForm.tsx'
import CrewForm from './components/CrewForm.tsx'
import LogbookVesselPicker from './components/LogbookVesselPicker.tsx'
import LogbookCrewPicker from './components/LogbookCrewPicker.tsx'
import { migrateLegacyCrewToPoolIfNeeded } from './services/crewMigration.js'
import { migrateLegacyYachtsToPoolIfNeeded } from './services/vesselMigration.js'
import { syncVesselPool } from './services/vesselPoolSync.js'
import { syncPersonPool } from './services/personPoolSync.js'
// Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert (Komponente bleibt erhalten)
// import DeviationForm from './components/DeviationForm.tsx'
import LogEntriesList from './components/LogEntriesList.tsx'
@@ -13,7 +17,14 @@ import SettingsForm from './components/SettingsForm.tsx'
import InvitationAcceptance from './components/InvitationAcceptance.tsx'
import AppTourOverlay from './components/AppTourOverlay.tsx'
import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx'
import { getActiveMasterKey, logoutUser } from './services/auth.js'
import { UnsavedChangesProvider, useUnsavedChangesContext } from './context/UnsavedChangesContext.tsx'
import {
logoutUser,
checkServerSession,
hasUnlockedLocalSession,
persistSessionUserId
} from './services/auth.js'
import AppErrorBoundary from './components/AppErrorBoundary.tsx'
import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
import {
applyAppearanceToDocument,
@@ -21,6 +32,7 @@ import {
resolveColorScheme,
subscribeToSystemColorScheme
} from './services/appearance.js'
import { syncAppearancePrefs } from './services/appearancePrefs.js'
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
import DemoViewer from './components/DemoViewer.tsx'
@@ -28,30 +40,50 @@ import PwaInstallPrompt from './components/PwaInstallPrompt.tsx'
import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx'
import AppFooter from './components/AppFooter.tsx'
import LogbookRoleBadge from './components/LogbookRoleBadge.tsx'
import BetaBadge from './components/BetaBadge.tsx'
import { db } from './services/db.js'
import { getLogbookAccess } from './services/logbookAccess.js'
import type { LogbookAccessRole } from './services/logbook.js'
import { useLiveQuery } from 'dexie-react-hooks'
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react'
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
import ProfileHeaderButton from './components/ProfileHeaderButton.tsx'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage } from './utils/i18nLanguages.js'
import {
getStoredDemoFirstEntryId,
resolveTourLogbookContext,
seedDemoLogbookIfNeeded
} from './services/demoLogbook.js'
import { fetchLogbooks, parseCollaborationRole } from './services/logbook.js'
import { ensurePushSubscriptionIfEnabled } from './services/pushNotifications.js'
import SyncConflictBanner from './components/SyncConflictBanner.tsx'
import { requestPersistentStorage } from './utils/storagePersist.js'
const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
function App() {
const { t, i18n } = useTranslation()
const { registerNavigation, requestStartAfterLogin } = useAppTour()
const { confirmLeave } = useUnsavedChangesContext()
const { registerNavigation, registerDemoTourContext, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [activeLogbookId, setActiveLogbookId] = useState<string | null>(null)
const [activeLogbookTitle, setActiveLogbookTitle] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<AppTab>('logs')
const [tourSelectedEntryId, setTourSelectedEntryId] = useState<string | null>(null)
const [tourFeedbackOpen, setTourFeedbackOpen] = useState(false)
const [demoHighlightEntryId, setDemoHighlightEntryId] = useState<string | null>(null)
const [online, setOnline] = useState(navigator.onLine)
const [isSyncing, setIsSyncing] = useState(false)
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
const [showUserProfile, setShowUserProfile] = useState(false)
const [storagePersistHint, setStoragePersistHint] = useState(false)
const tourLogbookRef = useRef<{ id: string; title: string } | null>(null)
const activeLogbookRef = useRef<{ id: string | null; title: string | null }>({
id: activeLogbookId,
title: activeLogbookTitle
})
activeLogbookRef.current = { id: activeLogbookId, title: activeLogbookTitle }
// Viewer mode for read-only shared links
const [isViewerMode, setIsViewerMode] = useState(false)
@@ -71,7 +103,7 @@ function App() {
[activeLogbookId]
)
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole>('OWNER')
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>('OWNER')
useEffect(() => {
if (!activeLogbookId) {
@@ -79,19 +111,34 @@ function App() {
return
}
if (activeLogbookRecord?.isShared !== 1) {
if (!activeLogbookRecord) {
setActiveAccessRole(null)
return
}
if (activeLogbookRecord.isShared !== 1) {
setActiveAccessRole('OWNER')
return
}
const cachedRole = activeLogbookRecord.collaborationRole
if (cachedRole) {
setActiveAccessRole(cachedRole)
}
setActiveAccessRole(
cachedRole ? parseCollaborationRole(cachedRole, `logbook ${activeLogbookId}`) : null
)
getLogbookAccess(activeLogbookId).then((access) => {
if (access) setActiveAccessRole(access.role)
})
let cancelled = false
getLogbookAccess(activeLogbookId)
.then((access) => {
if (cancelled || !access) return
setActiveAccessRole(access.role)
})
.catch((err) => {
console.warn('Failed to resolve logbook access role:', err)
})
return () => {
cancelled = true
}
}, [activeLogbookId, activeLogbookRecord])
useEffect(() => {
@@ -113,6 +160,15 @@ function App() {
})
}, [])
useEffect(() => {
if (!isAuthenticated) return
const userId = localStorage.getItem('active_userid')
if (!userId) return
void syncAppearancePrefs(userId)
void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool())
void migrateLegacyYachtsToPoolIfNeeded().then(() => syncVesselPool())
}, [isAuthenticated])
useEffect(() => {
const handleOnline = () => {
setOnline(true)
@@ -169,19 +225,101 @@ function App() {
setIsAcceptingInvite(false)
const savedUser = localStorage.getItem('active_username')
const key = getActiveMasterKey()
if (savedUser && key) {
setIsAuthenticated(true)
const savedLogbookId = localStorage.getItem('active_logbook_id')
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
if (savedLogbookId && savedLogbookTitle) {
setActiveLogbookId(savedLogbookId)
setActiveLogbookTitle(savedLogbookTitle)
}
const openLogbookId = params.get('logbook')
if (openLogbookId) {
sessionStorage.setItem(PENDING_PUSH_LOGBOOK_KEY, openLogbookId)
const cleanUrl = new URL(window.location.href)
cleanUrl.searchParams.delete('logbook')
window.history.replaceState(
{},
document.title,
`${cleanUrl.pathname}${cleanUrl.search}${cleanUrl.hash}`
)
}
}, [])
const clearAuthenticatedAppState = useCallback(() => {
setIsAuthenticated(false)
setActiveLogbookId(null)
setActiveLogbookTitle(null)
setShowUserProfile(false)
setTourSelectedEntryId(null)
setDemoHighlightEntryId(null)
}, [])
/** After PWA/bfcache resume, React state may still say "logged in" while the master key is gone. */
const enforceUnlockedSession = useCallback(() => {
if (isViewerMode || isDemoMode || isAcceptingInvite) return
// Require full local session (incl. userId) so API calls are not left headless.
if (isAuthenticated && !hasUnlockedLocalSession()) {
clearAuthenticatedAppState()
}
}, [
isAuthenticated,
isViewerMode,
isDemoMode,
isAcceptingInvite,
clearAuthenticatedAppState
])
useEffect(() => {
enforceUnlockedSession()
}, [enforceUnlockedSession])
useEffect(() => {
const onPageShow = (event: PageTransitionEvent) => {
if (event.persisted) {
enforceUnlockedSession()
}
}
const onVisibility = () => {
if (document.visibilityState === 'visible') {
enforceUnlockedSession()
}
}
window.addEventListener('pageshow', onPageShow)
document.addEventListener('visibilitychange', onVisibility)
return () => {
window.removeEventListener('pageshow', onPageShow)
document.removeEventListener('visibilitychange', onVisibility)
}
}, [enforceUnlockedSession])
useEffect(() => {
let cancelled = false
;(async () => {
try {
const session = await checkServerSession()
if (cancelled) return
if (session.authenticated) {
persistSessionUserId(session.userId)
}
// Cookie alone is insufficient — need in-memory master key, username, and userId for API.
if (session.authenticated && hasUnlockedLocalSession()) {
setIsAuthenticated(true)
const savedLogbookId = localStorage.getItem('active_logbook_id')
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
if (savedLogbookId && savedLogbookTitle) {
setActiveLogbookId(savedLogbookId)
setActiveLogbookTitle(savedLogbookTitle)
}
}
// authenticated + crypto but no userId: stay on login (enforceUnlockedSession guards active UI)
} catch (err) {
if (!cancelled) {
console.warn('Session restore failed:', err)
}
}
})()
return () => {
cancelled = true
}
}, [clearAuthenticatedAppState])
useEffect(() => {
syncRouteFromLocation()
window.addEventListener('popstate', syncRouteFromLocation)
@@ -195,31 +333,123 @@ function App() {
setIsAcceptingInvite(false)
}, [])
useEffect(() => {
registerNavigation({
setActiveTab,
setSelectedEntryId: setTourSelectedEntryId
})
}, [registerNavigation])
useEffect(() => {
if (isAuthenticated && activeLogbookId) {
setDemoHighlightEntryId(getStoredDemoFirstEntryId())
}
}, [isAuthenticated, activeLogbookId])
const selectLogbook = (id: string, title: string) => {
const selectLogbook = useCallback((id: string, title: string) => {
setActiveLogbookId(id)
setActiveLogbookTitle(title)
setActiveTab('logs')
setTourSelectedEntryId(null)
localStorage.setItem('active_logbook_id', id)
localStorage.setItem('active_logbook_title', title)
}
}, [])
const ensureTourLogbookOpen = useCallback(async () => {
const ctx = await resolveTourLogbookContext(tourLogbookRef.current?.id)
if (!ctx) return
if (activeLogbookRef.current.id !== ctx.logbookId) {
selectLogbook(ctx.logbookId, ctx.title)
}
if (ctx.firstEntryId) {
setDemoHighlightEntryId(ctx.firstEntryId)
registerDemoTourContext({ firstEntryId: ctx.firstEntryId })
}
}, [registerDemoTourContext, selectLogbook])
useEffect(() => {
registerNavigation({
setActiveTab,
setSelectedEntryId: setTourSelectedEntryId,
setFeedbackOpen: setTourFeedbackOpen,
setProfileOpen: setShowUserProfile,
ensureLogbookForTour: ensureTourLogbookOpen,
setLogbookActive: (active) => {
if (active) {
void ensureTourLogbookOpen()
return
}
const { id, title } = activeLogbookRef.current
if (id && title) {
tourLogbookRef.current = { id, title }
}
setActiveLogbookId(null)
setActiveLogbookTitle(null)
setTourSelectedEntryId(null)
localStorage.removeItem('active_logbook_id')
localStorage.removeItem('active_logbook_title')
}
})
}, [ensureTourLogbookOpen, registerNavigation])
useEffect(() => {
if (!isAuthenticated || !activeLogbookId) return
void (async () => {
const ctx = await resolveTourLogbookContext()
if (!ctx || ctx.logbookId !== activeLogbookId) return
if (ctx.firstEntryId) {
setDemoHighlightEntryId(ctx.firstEntryId)
registerDemoTourContext({ firstEntryId: ctx.firstEntryId })
}
})()
}, [isAuthenticated, activeLogbookId, registerDemoTourContext])
const openLogbookById = useCallback(
async (logbookId: string) => {
try {
const books = await fetchLogbooks()
const match = books.find((b) => b.id === logbookId)
if (match) {
selectLogbook(match.id, match.title)
return
}
} catch (err) {
console.error('Failed to resolve logbook from push:', err)
}
selectLogbook(logbookId, `${logbookId.slice(0, 8)}`)
},
[selectLogbook]
)
const consumePendingPushLogbook = useCallback(() => {
const pending = sessionStorage.getItem(PENDING_PUSH_LOGBOOK_KEY)
if (!pending) return
sessionStorage.removeItem(PENDING_PUSH_LOGBOOK_KEY)
void openLogbookById(pending)
}, [openLogbookById])
useEffect(() => {
if (isAuthenticated) {
consumePendingPushLogbook()
}
}, [isAuthenticated, consumePendingPushLogbook])
useEffect(() => {
if (!isAuthenticated || !('serviceWorker' in navigator)) return
const onSwMessage = (event: MessageEvent) => {
if (event.data?.type === 'OPEN_LOGBOOK' && typeof event.data.logbookId === 'string') {
void openLogbookById(event.data.logbookId)
}
}
navigator.serviceWorker.addEventListener('message', onSwMessage)
return () => navigator.serviceWorker.removeEventListener('message', onSwMessage)
}, [isAuthenticated, openLogbookById])
useEffect(() => {
if (!isAuthenticated) return
if (sessionStorage.getItem('storage_persist_hint_dismissed')) return
void requestPersistentStorage().then(({ persisted, supported }) => {
if (supported && !persisted) setStoragePersistHint(true)
})
}, [isAuthenticated])
const handleAuthenticated = async () => {
setIsAuthenticated(true)
trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
void ensurePushSubscriptionIfEnabled()
void requestPersistentStorage()
try {
const demo = await seedDemoLogbookIfNeeded()
@@ -229,6 +459,7 @@ function App() {
setDemoHighlightEntryId(demo.firstEntryId)
}
requestStartAfterLogin()
consumePendingPushLogbook()
return
}
} catch (err) {
@@ -238,23 +469,45 @@ function App() {
const savedLogbookId = localStorage.getItem('active_logbook_id')
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
if (savedLogbookId && savedLogbookTitle) {
setActiveLogbookId(savedLogbookId)
setActiveLogbookTitle(savedLogbookTitle)
try {
const books = await fetchLogbooks()
const match = books.find((b) => b.id === savedLogbookId)
if (match) {
setActiveLogbookId(match.id)
setActiveLogbookTitle(match.title)
} else {
localStorage.removeItem('active_logbook_id')
localStorage.removeItem('active_logbook_title')
}
} catch {
setActiveLogbookId(savedLogbookId)
setActiveLogbookTitle(savedLogbookTitle)
}
}
consumePendingPushLogbook()
}
const handleLogout = () => {
logoutUser()
const handleTabChange = async (tab: AppTab) => {
if (tab === activeTab) return
if (!(await confirmLeave())) return
setActiveTab(tab)
}
const handleLogout = async () => {
if (!(await confirmLeave())) return
void logoutUser()
setIsAuthenticated(false)
setActiveLogbookId(null)
setActiveLogbookTitle(null)
setShowUserProfile(false)
setTourSelectedEntryId(null)
setDemoHighlightEntryId(null)
localStorage.removeItem('active_logbook_id')
localStorage.removeItem('active_logbook_title')
}
const handleBackToDashboard = () => {
const handleBackToDashboard = async () => {
if (!(await confirmLeave())) return
setActiveLogbookId(null)
setActiveLogbookTitle(null)
setTourSelectedEntryId(null)
@@ -263,8 +516,7 @@ function App() {
}
const toggleLanguage = () => {
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
i18n.changeLanguage(nextLang)
cycleAppLanguage(i18n)
}
const handleExitDemo = () => {
@@ -317,7 +569,24 @@ function App() {
)
}
const pwaInstallBanner = <PwaInstallPrompt variant="banner" />
const pwaInstallBanner = !isActive ? <PwaInstallPrompt variant="banner" /> : null
const logbookReadOnly =
activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ'
const isLogbookOwner =
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1
if (showUserProfile) {
return (
<div style={{ display: 'contents' }}>
{pwaInstallBanner}
<UserProfilePage
onBack={() => setShowUserProfile(false)}
onLogout={handleLogout}
/>
</div>
)
}
if (!activeLogbookId) {
return (
@@ -326,6 +595,7 @@ function App() {
<LogbookDashboard
onSelectLogbook={selectLogbook}
onLogout={handleLogout}
onOpenProfile={() => setShowUserProfile(true)}
/>
</div>
)
@@ -339,21 +609,22 @@ function App() {
{/* Active Logbook Header */}
<header className="app-header">
<div className="app-header-left">
<button className="btn-back" onClick={handleBackToDashboard}>
<button className="btn-back" onClick={handleBackToDashboard} title={t('nav.dashboard')}>
<ChevronLeft size={16} />
{t('nav.dashboard')}
<span className="hide-mobile">{t('nav.dashboard')}</span>
</button>
<div className="app-title-area">
<div className="app-title-row">
<h2>{activeLogbookTitle}</h2>
{activeAccessRole !== 'OWNER' && (
<BetaBadge />
{activeAccessRole && activeAccessRole !== 'OWNER' && (
<LogbookRoleBadge role={activeAccessRole} />
)}
</div>
<p className="app-subtitle">
{activeAccessRole !== 'OWNER'
{activeAccessRole && activeAccessRole !== 'OWNER'
? t('dashboard.section_shared_hint')
: `${t('app.name')} / ${activeLogbookId?.substring(0, 8)}...`}
: t('app.tagline')}
</p>
</div>
</div>
@@ -375,21 +646,49 @@ function App() {
<Languages size={18} />
</button>
<ProfileHeaderButton onClick={() => setShowUserProfile(true)} />
<DisclaimerHeaderButton />
<FeedbackHeaderButton
logbookId={activeLogbookId}
logbookTitle={activeLogbookTitle}
tourOpen={tourFeedbackOpen}
onTourOpenChange={setTourFeedbackOpen}
tourHighlight={isActive && currentStepId === 'nav_feedback'}
/>
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
<LogOut size={18} />
</button>
</div>
</header>
<SyncConflictBanner logbookId={activeLogbookId} />
{storagePersistHint && (
<div className="storage-persist-hint glass" role="status">
<p>{t('pwa.storage_persist_hint')}</p>
<button
type="button"
className="btn secondary"
onClick={() => {
sessionStorage.setItem('storage_persist_hint_dismissed', '1')
setStoragePersistHint(false)
}}
>
{t('pwa.later')}
</button>
</div>
)}
{/* Active Workspace */}
<div className="app-body">
{/* Navigation Sidebar */}
<aside className="app-sidebar">
<aside className="app-sidebar" aria-label={t('nav.dashboard')}>
<button
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
onClick={() => setActiveTab('logs')}
onClick={() => void handleTabChange('logs')}
data-tour="nav-logs"
>
<FileText size={18} />
@@ -398,7 +697,7 @@ function App() {
<button
className={`sidebar-btn ${activeTab === 'vessel' ? 'active' : ''}`}
onClick={() => setActiveTab('vessel')}
onClick={() => void handleTabChange('vessel')}
data-tour="nav-vessel"
>
<Ship size={18} />
@@ -407,8 +706,8 @@ function App() {
<button
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
onClick={() => setActiveTab('crew')}
data-tour="nav-crew"
onClick={() => void handleTabChange('crew')}
data-tour="nav-logbook-crew"
>
<Users size={18} />
{t('nav.crew')}
@@ -426,7 +725,8 @@ function App() {
<button
className={`sidebar-btn ${activeTab === 'stats' ? 'active' : ''}`}
onClick={() => setActiveTab('stats')}
onClick={() => void handleTabChange('stats')}
data-tour="nav-stats"
>
<BarChart2 size={18} />
{t('nav.stats')}
@@ -434,7 +734,7 @@ function App() {
<button
className={`sidebar-btn ${activeTab === 'settings' ? 'active' : ''}`}
onClick={() => setActiveTab('settings')}
onClick={() => void handleTabChange('settings')}
>
<Settings size={18} />
{t('nav.settings')}
@@ -446,6 +746,7 @@ function App() {
{activeTab === 'logs' && (
<LogEntriesList
logbookId={activeLogbookId}
readOnly={logbookReadOnly}
controlledSelectedEntryId={tourSelectedEntryId}
onSelectedEntryIdChange={setTourSelectedEntryId}
highlightEntryId={demoHighlightEntryId}
@@ -453,11 +754,20 @@ function App() {
)}
{activeTab === 'vessel' && (
<VesselForm logbookId={activeLogbookId} />
<LogbookVesselPicker
logbookId={activeLogbookId}
readOnly={logbookReadOnly || !isLogbookOwner}
selectionOnly={!isLogbookOwner && activeLogbookRecord?.isShared === 1}
onOpenProfile={isLogbookOwner ? () => setShowUserProfile(true) : undefined}
/>
)}
{activeTab === 'crew' && (
<CrewForm logbookId={activeLogbookId} />
<LogbookCrewPicker
logbookId={activeLogbookId}
readOnly={logbookReadOnly}
selectionOnly={!isLogbookOwner && activeLogbookRecord?.isShared === 1}
/>
)}
{activeTab === 'stats' && activeLogbookId && activeLogbookTitle && (
@@ -477,6 +787,53 @@ function App() {
/>
)}
</main>
<nav className="app-bottom-nav" aria-label={t('nav.dashboard')}>
<button
type="button"
className={`bottom-nav-btn ${activeTab === 'logs' ? 'active' : ''}`}
onClick={() => void handleTabChange('logs')}
data-tour="nav-logs"
>
<FileText size={20} />
<span>{t('nav.logs')}</span>
</button>
<button
type="button"
className={`bottom-nav-btn ${activeTab === 'vessel' ? 'active' : ''}`}
onClick={() => void handleTabChange('vessel')}
data-tour="nav-vessel"
>
<Ship size={20} />
<span>{t('nav.vessel')}</span>
</button>
<button
type="button"
className={`bottom-nav-btn ${activeTab === 'crew' ? 'active' : ''}`}
onClick={() => void handleTabChange('crew')}
data-tour="nav-logbook-crew"
>
<Users size={20} />
<span>{t('nav.crew')}</span>
</button>
<button
type="button"
className={`bottom-nav-btn ${activeTab === 'stats' ? 'active' : ''}`}
onClick={() => void handleTabChange('stats')}
data-tour="nav-stats"
>
<BarChart2 size={20} />
<span>{t('nav.stats')}</span>
</button>
<button
type="button"
className={`bottom-nav-btn ${activeTab === 'settings' ? 'active' : ''}`}
onClick={() => void handleTabChange('settings')}
>
<Settings size={20} />
<span>{t('nav.settings')}</span>
</button>
</nav>
</div>
</div>
</div>
@@ -485,13 +842,17 @@ function App() {
export default function AppWrapper() {
return (
<DialogProvider>
<AppTourProvider>
<PwaUpdatePrompt />
<App />
<AppTourOverlay />
</AppTourProvider>
<AppFooter />
</DialogProvider>
<AppErrorBoundary>
<DialogProvider>
<UnsavedChangesProvider>
<AppTourProvider>
<PwaUpdatePrompt />
<App />
<AppTourOverlay />
</AppTourProvider>
<AppFooter />
</UnsavedChangesProvider>
</DialogProvider>
</AppErrorBoundary>
)
}
@@ -46,6 +46,7 @@ export default function AccountDangerZone({ className = '' }: AccountDangerZoneP
</div>
<p className="account-danger-zone__desc">{t('settings.danger_zone_desc')}</p>
<p className="account-danger-zone__hint">{t('settings.delete_backup_hint')}</p>
<div className="form-actions account-danger-zone__actions">
<button
@@ -0,0 +1,42 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
interface Props {
children: ReactNode
}
interface State {
error: Error | null
}
export default class AppErrorBoundary extends Component<Props, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error }
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('Unhandled app error:', error, info.componentStack)
}
render() {
if (!this.state.error) {
return this.props.children
}
return (
<div className="auth-screen">
<div className="auth-card glass" role="alert">
<h2 style={{ marginTop: 0 }}>Kapteins Daagbok</h2>
<p style={{ color: 'var(--app-text-muted)', lineHeight: 1.5 }}>
Die App ist nach dem Neustart in einen fehlerhaften Zustand geraten. Bitte neu laden
oder die App vollständig beenden und erneut öffnen.
</p>
<button type="button" className="btn primary" style={{ width: '100%', marginTop: 16 }} onClick={() => window.location.reload()}>
Neu laden
</button>
</div>
</div>
)
}
}
+11 -3
View File
@@ -1,3 +1,5 @@
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'
export default function AppFooter() {
@@ -7,9 +9,15 @@ export default function AppFooter() {
<span className="app-version-footer__sep" aria-hidden="true">
·
</span>
<a className="app-version-footer__copyright" href="mailto:elpatron+kd@mailbox.org">
© 2026 Markus F.J. Busche
</a>
<span className="app-version-footer__copyright">
© 2026 KnorrLabs/
<a
href="mailto:elpatron+kd@mailbox.org"
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
>
Markus F.J. Busche
</a>
</span>
</footer>
)
}
+88 -17
View File
@@ -4,6 +4,7 @@ import { X, ChevronLeft, ChevronRight } from 'lucide-react'
import {
getTourStepCopy,
getTourTargetSelector,
getTourTargetRetryDelay,
isCenteredTourStep,
useAppTour
} from '../context/AppTourContext.tsx'
@@ -15,12 +16,63 @@ interface SpotlightRect {
height: number
}
const TOOLTIP_EDGE_MARGIN = 16
const TOOLTIP_ESTIMATED_HEIGHT = 240
const TOOLTIP_WIDTH = 420
const TARGET_VIEWPORT_MARGIN = 24
function clampTooltipTop(preferred: number): number {
const maxTop = window.innerHeight - TOOLTIP_EDGE_MARGIN - TOOLTIP_ESTIMATED_HEIGHT
return Math.max(TOOLTIP_EDGE_MARGIN, Math.min(preferred, maxTop))
}
function computeTooltipLeft(spotlight: SpotlightRect): number {
const tooltipWidth = Math.min(TOOLTIP_WIDTH, window.innerWidth - TOOLTIP_EDGE_MARGIN * 2)
const ideal = spotlight.left + spotlight.width / 2 - tooltipWidth / 2
const maxLeft = window.innerWidth - TOOLTIP_EDGE_MARGIN - tooltipWidth
return Math.max(TOOLTIP_EDGE_MARGIN, Math.min(ideal, maxLeft))
}
function buildCutoutClipPath(rect: SpotlightRect): string {
const right = rect.left + rect.width
const bottom = rect.top + rect.height
return `polygon(evenodd, 0 0, 100vw 0, 100vw 100vh, 0 100vh, 0 0, ${rect.left}px ${rect.top}px, ${right}px ${rect.top}px, ${right}px ${bottom}px, ${rect.left}px ${bottom}px, ${rect.left}px ${rect.top}px)`
}
function computeTooltipTop(spotlight: SpotlightRect): number {
const viewportBottom = window.innerHeight - TOOLTIP_EDGE_MARGIN
const below = spotlight.top + spotlight.height + 12
if (below + TOOLTIP_ESTIMATED_HEIGHT <= viewportBottom) {
return clampTooltipTop(below)
}
const above = spotlight.top - 12 - TOOLTIP_ESTIMATED_HEIGHT
if (above >= TOOLTIP_EDGE_MARGIN) {
return clampTooltipTop(above)
}
return clampTooltipTop(below)
}
function isTargetVisibleInViewport(rect: DOMRect): boolean {
return (
rect.top >= TARGET_VIEWPORT_MARGIN &&
rect.bottom <= window.innerHeight - TARGET_VIEWPORT_MARGIN
)
}
function measureSpotlight(el: Element): SpotlightRect | null {
const rect = el.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return null
const padding = 8
return {
top: Math.max(8, rect.top - padding),
left: Math.max(8, rect.left - padding),
width: rect.width + padding * 2,
height: rect.height + padding * 2
}
}
export default function AppTourOverlay() {
const { t } = useTranslation()
const {
@@ -29,6 +81,7 @@ export default function AppTourOverlay() {
currentStepId,
currentStepIndex,
totalSteps,
layoutTick,
nextStep,
prevStep,
skipTour
@@ -44,7 +97,10 @@ export default function AppTourOverlay() {
return
}
let cancelled = false
const updateSpotlight = () => {
if (cancelled) return
const selector = getTourTargetSelector(currentStepId)
if (!selector) {
setSpotlight(null)
@@ -55,27 +111,38 @@ export default function AppTourOverlay() {
setSpotlight(null)
return
}
const rect = el.getBoundingClientRect()
const padding = 8
setSpotlight({
top: Math.max(8, rect.top - padding),
left: Math.max(8, rect.left - padding),
width: rect.width + padding * 2,
height: rect.height + padding * 2
})
if (!isTargetVisibleInViewport(rect)) {
el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'nearest' })
window.requestAnimationFrame(() => {
if (cancelled) return
const next = measureSpotlight(el)
setSpotlight(next)
})
return
}
setSpotlight(measureSpotlight(el))
}
updateSpotlight()
window.addEventListener('resize', updateSpotlight)
window.addEventListener('scroll', updateSpotlight, true)
const timer = window.setTimeout(updateSpotlight, 120)
const retryDelays =
currentStepId === 'entry_track'
? [400, 700, 1100, 1600]
: [getTourTargetRetryDelay(currentStepId), 120, 280, 480]
const timers = retryDelays.map((delay) => window.setTimeout(updateSpotlight, delay))
return () => {
window.clearTimeout(timer)
cancelled = true
for (const timer of timers) window.clearTimeout(timer)
window.removeEventListener('resize', updateSpotlight)
window.removeEventListener('scroll', updateSpotlight, true)
}
}, [currentStepId, isActive])
}, [currentStepId, isActive, layoutTick])
useEffect(() => {
if (!isActive) return
@@ -111,12 +178,16 @@ export default function AppTourOverlay() {
const tooltipStyle = centered
? undefined
: spotlight
? {
top: Math.min(window.innerHeight - 220, spotlight.top + spotlight.height + 12),
left: Math.min(window.innerWidth - 340, Math.max(16, spotlight.left)),
maxWidth: '420px'
}
: { top: '20%', left: '50%', transform: 'translateX(-50%)', maxWidth: '420px' }
? { top: computeTooltipTop(spotlight), left: computeTooltipLeft(spotlight) }
: { top: '20%' }
const tooltipClassName = [
'app-tour-tooltip',
centered ? 'centered' : '',
!centered && spotlight ? 'app-tour-tooltip--anchored' : ''
]
.filter(Boolean)
.join(' ')
const backdropStyle = spotlight && !centered
? { clipPath: buildCutoutClipPath(spotlight) }
@@ -142,7 +213,7 @@ export default function AppTourOverlay() {
/>
)}
<div className={`app-tour-tooltip${centered ? ' centered' : ''}`} style={tooltipStyle}>
<div className={tooltipClassName} style={tooltipStyle}>
<button type="button" className="app-tour-close" onClick={skipTour} aria-label={t('tour.skip')}>
<X size={18} />
</button>
+133 -35
View File
@@ -1,18 +1,28 @@
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
registerUser,
loginUser,
completeLoginWithRecovery,
setLocalPin,
hasLocalPin,
decryptWithLocalPin,
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import {
registerUser,
loginUser,
completeLoginWithRecovery,
setLocalPin,
hasLocalPin,
decryptWithLocalPin,
getActiveMasterKey,
getKnownUsernames,
forgetUsername
forgetUsername,
hasUnlockedLocalSession,
logoutUser
} from '../services/auth.js'
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
import DisclaimerModal from './DisclaimerModal.tsx'
import BetaBadge from './BetaBadge.tsx'
import {
isPasskeyCompatibleLocation,
localizeWebAuthnError,
toPasskeyCompatibleUrl
} from '../utils/passkeyHost.ts'
interface AuthOnboardingProps {
onAuthenticated: () => void
@@ -49,6 +59,17 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
const [isNewRegistration, setIsNewRegistration] = useState(false)
const [showDisclaimer, setShowDisclaimer] = useState(false)
const [showHelp, setShowHelp] = useState(false)
const passkeyHostOk = isPasskeyCompatibleLocation()
const passkeyCompatibleUrl = passkeyHostOk ? null : toPasskeyCompatibleUrl(window.location.href)
const formatAuthError = (message: string) =>
localizeWebAuthnError(message, {
invalidHost: t('auth.error_invalid_host'),
cancelled: t('auth.error_passkey_cancelled'),
invalidRpId: t('auth.error_invalid_rp_id')
})
const finishAuth = () => {
if (isNewRegistration) {
@@ -77,7 +98,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
setRecoveryPhrase(result.recoveryPhrase)
}
} catch (err: any) {
setError(err.message || 'Registration failed')
setError(formatAuthError(err.message || 'Registration failed'))
} finally {
setLoading(false)
}
@@ -117,7 +138,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
}
}
} catch (err: any) {
setError(err.message || 'Login failed')
setError(formatAuthError(err.message || 'Login failed'))
} finally {
setLoading(false)
}
@@ -181,19 +202,33 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
const handlePinLoginSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!pinLoginInput.trim()) return
if (!pinLoginInput.trim() || loading) return
const resolvedUser =
username.trim() ||
encryptedPayloads?.username ||
localStorage.getItem('active_username') ||
''
if (!resolvedUser) {
setError(t('auth.error_session_incomplete'))
return
}
setLoading(true)
setError(null)
try {
const resolvedUser = username.trim() || encryptedPayloads?.username
const key = await decryptWithLocalPin(pinLoginInput.trim(), resolvedUser)
if (key) {
onAuthenticated()
} else {
if (!key) {
setError(t('auth.error_incorrect_pin'))
return
}
} catch (err: any) {
if (!hasUnlockedLocalSession()) {
setError(t('auth.error_session_incomplete'))
return
}
setShowPinLogin(false)
onAuthenticated()
} catch {
setError(t('auth.error_incorrect_pin'))
} finally {
setLoading(false)
@@ -206,8 +241,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
}
const toggleLanguage = () => {
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
i18n.changeLanguage(nextLang)
cycleAppLanguage(i18n)
}
const copyToClipboard = () => {
@@ -272,6 +306,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
</label>
<input
type="password"
name="new-pin"
inputMode="numeric"
pattern="[0-9]*"
maxLength={8}
@@ -281,6 +316,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
onChange={(e) => setPinInput(e.target.value.replace(/\D/g, ''))}
disabled={loading}
required
autoComplete="new-password"
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
/>
</div>
@@ -321,6 +357,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
<div className="input-group">
<input
type="password"
name="pin"
inputMode="numeric"
pattern="[0-9]*"
maxLength={8}
@@ -330,6 +367,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
onChange={(e) => setPinLoginInput(e.target.value.replace(/\D/g, ''))}
disabled={loading}
required
autoComplete="current-password"
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
/>
</div>
@@ -354,6 +392,24 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
>
{t('auth.use_recovery_instead')}
</button>
<button
type="button"
className="btn secondary"
onClick={() => {
void (async () => {
setShowPinLogin(false)
setPinLoginInput('')
setEncryptedPayloads(null)
setError(null)
await logoutUser()
})()
}}
disabled={loading}
style={{ width: '100%' }}
>
{t('auth.back')}
</button>
</div>
</form>
</div>
@@ -372,16 +428,37 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
{t('auth.recovery_fallback_warning')}
</p>
<form onSubmit={handleRecoverySubmit} className="auth-form">
<textarea
className="input-textarea"
placeholder={t('auth.recovery_placeholder')}
value={recoveryInput}
onChange={(e) => setRecoveryInput(e.target.value)}
disabled={loading}
rows={3}
required
/>
<form onSubmit={handleRecoverySubmit} className="auth-form" autoComplete="on">
{(username.trim() || encryptedPayloads?.username) && (
<input
type="text"
name="username"
autoComplete="username"
value={username.trim() || encryptedPayloads?.username || ''}
readOnly
tabIndex={-1}
aria-hidden="true"
style={{ position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' }}
/>
)}
<div className="input-group">
<label htmlFor="recovery-key" className="input-label" style={{ display: 'block', marginBottom: '8px', color: '#94a3b8' }}>
{t('auth.enter_recovery')}
</label>
<input
id="recovery-key"
name="recovery-key"
type="password"
className="input-text"
placeholder={t('auth.recovery_placeholder')}
value={recoveryInput}
onChange={(e) => setRecoveryInput(e.target.value)}
disabled={loading}
required
autoComplete="current-password"
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
/>
</div>
{error && <div className="auth-error">{error}</div>}
@@ -405,20 +482,33 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
// Render 3: Standard Login / Registration options form
return (
<>
<div className="auth-card glass">
<div className="auth-brand">
<img src="/logo.png" alt="Kapteins Daagbok" className="auth-logo-img" />
<h1>{t('app.name')}</h1>
<div className="auth-brand-title-row">
<h1>{t('app.name')}</h1>
<BetaBadge />
</div>
<p className="tagline">{t('auth.tagline')}</p>
</div>
<div className="auth-form" style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: '20px' }}>
{!passkeyHostOk && passkeyCompatibleUrl && (
<div className="auth-error" role="alert">
<p style={{ margin: '0 0 8px' }}>{t('auth.error_invalid_host')}</p>
<a href={passkeyCompatibleUrl} className="btn secondary" style={{ display: 'inline-block', textDecoration: 'none' }}>
{t('auth.use_localhost_link')}
</a>
</div>
)}
{/* Prominent Login button */}
<button
type="button"
className="btn primary"
onClick={() => handleLogin()}
disabled={loading}
disabled={loading || !passkeyHostOk}
style={{ width: '100%', padding: '16px' }}
>
{loading
@@ -551,7 +641,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
<button
type="submit"
className="btn secondary"
disabled={loading || !username.trim()}
disabled={loading || !username.trim() || !passkeyHostOk}
style={{ width: '100%' }}
>
{t('auth.register')}
@@ -562,15 +652,23 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
</div>
<div className="auth-footer">
<button className="btn-icon-text" onClick={toggleLanguage}>
<button type="button" className="btn-icon-text" onClick={toggleLanguage}>
<Languages size={18} />
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
{t(`languages.${getNextLanguage(i18n.language)}`)}
</button>
<a href="#help" className="btn-icon-text link-sec">
<button
type="button"
className="btn-icon-text link-sec"
onClick={() => setShowHelp(true)}
title={t('disclaimer.button_title')}
aria-label={t('disclaimer.button_title')}
>
<HelpCircle size={18} />
{t('auth.help')}
</a>
</button>
</div>
</div>
<DisclaimerModal open={showHelp} onClose={() => setShowHelp(false)} />
</>
)
}
+19
View File
@@ -0,0 +1,19 @@
import { useTranslation } from 'react-i18next'
interface BetaBadgeProps {
className?: string
}
export default function BetaBadge({ className = '' }: BetaBadgeProps) {
const { t } = useTranslation()
return (
<span
className={`beta-badge ${className}`.trim()}
title={t('app.beta_hint')}
aria-label={t('app.beta_hint')}
>
{t('app.beta')}
</span>
)
}
+285
View File
@@ -0,0 +1,285 @@
import { useCallback, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
type CourseOutputMode,
type CourseStep,
dialDegreesToStorageValue,
formatCourseAngle,
formatCourseDisplay,
isCardinalDirection,
loadCourseDialStep,
parseCourseAngle,
pointerAngleToDegrees,
resolveCourseOutputMode,
saveCourseDialStep,
snapDegrees,
valueToDialDegrees
} from '../utils/courseAngle.js'
interface CourseDialInputProps {
value: string
onChange: (value: string) => void
disabled?: boolean
step?: CourseStep
allowCardinal?: boolean
displayMode?: 'degrees' | 'cardinal' | 'auto'
size?: 'md' | 'sm'
'aria-label': string
id?: string
}
const TICK_DEGREES = [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330]
function polarPoint(degrees: number, radius: number): { x: number; y: number } {
const rad = (degrees * Math.PI) / 180
return {
x: 100 + Math.sin(rad) * radius,
y: 100 - Math.cos(rad) * radius
}
}
export default function CourseDialInput({
value,
onChange,
disabled = false,
step: stepProp,
allowCardinal = false,
displayMode = 'degrees',
size = 'md',
'aria-label': ariaLabel,
id: idProp
}: CourseDialInputProps) {
const { t } = useTranslation()
const generatedId = useId()
const inputId = idProp ?? `${generatedId}-input`
const svgRef = useRef<SVGSVGElement>(null)
const [step, setStep] = useState<CourseStep>(() => stepProp ?? loadCourseDialStep())
const [inputDraft, setInputDraft] = useState<string | null>(null)
const [inputError, setInputError] = useState<string | null>(null)
const [outputModeOverride, setOutputModeOverride] = useState<CourseOutputMode | null>(null)
const effectiveStep = stepProp ?? step
const outputMode =
outputModeOverride ??
resolveCourseOutputMode(value, displayMode, allowCardinal)
const dialDegrees = useMemo(
() => snapDegrees(valueToDialDegrees(value, allowCardinal), effectiveStep),
[value, allowCardinal, effectiveStep]
)
const centerLabel = useMemo(
() => formatCourseDisplay(value, allowCardinal),
[value, allowCardinal]
)
const tickLabel = useCallback(
(degrees: number) => {
if (degrees === 0) return t('logs.compass_n')
if (degrees === 90) return t('logs.compass_e')
if (degrees === 180) return t('logs.compass_s')
if (degrees === 270) return t('logs.compass_w')
return String(degrees).padStart(3, '0')
},
[t]
)
const applyDegrees = useCallback(
(degrees: number) => {
onChange(dialDegreesToStorageValue(degrees, outputMode, effectiveStep))
setInputDraft(null)
setInputError(null)
},
[onChange, outputMode, effectiveStep]
)
const updateFromPointer = useCallback(
(clientX: number, clientY: number) => {
const svg = svgRef.current
if (!svg || disabled) return
const rect = svg.getBoundingClientRect()
const cx = rect.left + rect.width / 2
const cy = rect.top + rect.height / 2
const raw = pointerAngleToDegrees(clientX, clientY, cx, cy)
applyDegrees(raw)
},
[applyDegrees, disabled]
)
const handlePointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
if (disabled) return
e.preventDefault()
e.currentTarget.setPointerCapture(e.pointerId)
updateFromPointer(e.clientX, e.clientY)
}
const handlePointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
if (disabled || !e.currentTarget.hasPointerCapture(e.pointerId)) return
updateFromPointer(e.clientX, e.clientY)
}
const handlePointerUp = (e: React.PointerEvent<SVGSVGElement>) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId)
}
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputDraft(e.target.value)
}
const commitInput = () => {
const draft = (inputDraft ?? value).trim()
setInputDraft(null)
if (!draft) {
onChange('')
setInputError(null)
return
}
if (allowCardinal && outputMode === 'cardinal' && isCardinalDirection(draft)) {
onChange(draft.toUpperCase())
setInputError(null)
return
}
const parsed = parseCourseAngle(draft)
if (parsed === null) {
setInputError(t('logs.course_invalid'))
return
}
onChange(formatCourseAngle(snapDegrees(parsed, effectiveStep)))
setInputError(null)
}
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
commitInput()
return
}
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault()
const base = parseCourseAngle(value) ?? dialDegrees
const delta = e.key === 'ArrowUp' ? effectiveStep : -effectiveStep
applyDegrees(base + delta)
}
}
const handleStepChange = (next: CourseStep) => {
if (stepProp !== undefined) return
setStep(next)
saveCourseDialStep(next)
const parsed = parseCourseAngle(value)
if (parsed !== null) {
onChange(formatCourseAngle(snapDegrees(parsed, next)))
}
}
const toggleOutputMode = () => {
const next: CourseOutputMode = outputMode === 'cardinal' ? 'degrees' : 'cardinal'
setOutputModeOverride(next)
const deg = valueToDialDegrees(value, allowCardinal)
onChange(dialDegreesToStorageValue(deg, next, effectiveStep))
}
const inputValue = inputDraft ?? value
const sliderNow = dialDegrees
return (
<div
className={`course-dial course-dial--${size}${disabled ? ' course-dial--disabled' : ''}`}
>
{!stepProp && (
<div className="course-dial__step-toolbar" role="group" aria-label={t('logs.course_dial_step_label')}>
{([1, 5, 10] as const).map((s) => (
<button
key={s}
type="button"
className={`course-dial__step-btn${effectiveStep === s ? ' is-active' : ''}`}
onClick={() => handleStepChange(s)}
disabled={disabled}
aria-pressed={effectiveStep === s}
>
{s === 1 ? t('logs.course_step_fine') : s === 5 ? t('logs.course_step_medium') : t('logs.course_step_coarse')}
</button>
))}
</div>
)}
<div
className="course-dial__ring-wrap"
role="slider"
aria-label={ariaLabel}
aria-valuemin={0}
aria-valuemax={360}
aria-valuenow={sliderNow}
aria-disabled={disabled}
>
<svg
ref={svgRef}
className="course-dial__svg"
viewBox="0 0 200 200"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
<circle className="course-dial__track" cx="100" cy="100" r="88" />
{TICK_DEGREES.map((deg) => {
const inner = polarPoint(deg, 76)
const outer = polarPoint(deg, 88)
const label = polarPoint(deg, 64)
return (
<g key={deg}>
<line className="course-dial__tick" x1={inner.x} y1={inner.y} x2={outer.x} y2={outer.y} />
<text className="course-dial__label" x={label.x} y={label.y} textAnchor="middle" dominantBaseline="middle">
{tickLabel(deg)}
</text>
</g>
)
})}
<g className="course-dial__needle" transform={`rotate(${dialDegrees} 100 100)`}>
<line x1="100" y1="100" x2="100" y2="28" />
<circle cx="100" cy="100" r="6" />
</g>
<text className="course-dial__center" x="100" y="100" textAnchor="middle" dominantBaseline="middle">
{centerLabel}
</text>
</svg>
</div>
<p className="course-dial__hint">{t('logs.course_dial_hint')}</p>
<input
id={inputId}
type="text"
inputMode="numeric"
className="input-text course-dial__input"
value={inputValue}
onChange={handleInputChange}
onBlur={commitInput}
onKeyDown={handleInputKeyDown}
disabled={disabled}
placeholder={
outputMode === 'cardinal'
? t('logs.course_placeholder_cardinal')
: t('logs.course_placeholder_degrees')
}
aria-label={ariaLabel}
aria-invalid={inputError ? true : undefined}
/>
{inputError && <p className="course-dial__error">{inputError}</p>}
{allowCardinal && displayMode === 'auto' && (
<button
type="button"
className="course-dial__mode-toggle"
onClick={toggleOutputMode}
disabled={disabled}
>
{outputMode === 'cardinal' ? t('logs.wind_mode_degrees') : t('logs.wind_mode_cardinal')}
</button>
)}
</div>
)
}
+28 -16
View File
@@ -6,12 +6,14 @@ import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { MAX_POOL_CREW_MEMBERS } from '../types/person.js'
import { useDialog } from './ModalDialog.tsx'
import { Users, User, Plus, Trash2, Edit2, Save, X, Check, Camera } from 'lucide-react'
interface CrewFormProps {
logbookId: string
readOnly?: boolean
skipperReadOnly?: boolean
preloadedData?: any[]
}
@@ -34,9 +36,15 @@ interface DecryptedCrew {
data: CrewMemberData
}
export default function CrewForm({ logbookId, readOnly = false, preloadedData }: CrewFormProps) {
export default function CrewForm({
logbookId,
readOnly = false,
skipperReadOnly = false,
preloadedData
}: CrewFormProps) {
const { t } = useTranslation()
const { showConfirm } = useDialog()
const skipperFormReadOnly = readOnly || skipperReadOnly
// Skipper profile state
const [skipName, setSkipName] = useState('')
@@ -192,7 +200,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
const handleSaveSkipper = async (e: React.FormEvent) => {
e.preventDefault()
if (readOnly) return
if (skipperFormReadOnly) return
setSavingSkipper(true)
setError(null)
setSkipperSuccess(false)
@@ -397,10 +405,14 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
{error && <div className="auth-error mb-4">{error}</div>}
{skipperReadOnly && !readOnly && (
<p className="help-text mb-4">{t('crew.skipper_read_only_hint')}</p>
)}
<form onSubmit={handleSaveSkipper} className="vessel-form">
<div className="form-grid">
<div className="vessel-photo-wrapper">
<div className="vessel-photo-preview" onClick={readOnly ? undefined : () => skipFileInputRef.current?.click()} style={{ cursor: readOnly ? 'default' : 'pointer' }}>
<div className="vessel-photo-preview" onClick={skipperFormReadOnly ? undefined : () => skipFileInputRef.current?.click()} style={{ cursor: skipperFormReadOnly ? 'default' : 'pointer' }}>
{skipPhoto ? (
<img src={skipPhoto} alt={skipName || 'Skipper'} className="vessel-photo" />
) : (
@@ -408,7 +420,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
<User size={48} className="placeholder-icon" />
</div>
)}
{!readOnly && (
{!skipperFormReadOnly && (
<div className="vessel-photo-overlay">
<Camera size={24} />
<span>{skipPhoto ? t('vessel.photo_change') : t('vessel.photo_add')}</span>
@@ -416,7 +428,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
)}
</div>
{!readOnly && (
{!skipperFormReadOnly && (
<div className="vessel-photo-actions">
<button
type="button"
@@ -473,7 +485,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text"
value={skipName}
onChange={(e) => setSkipName(e.target.value)}
disabled={savingSkipper || readOnly}
disabled={savingSkipper || skipperFormReadOnly}
required
/>
</div>
@@ -485,7 +497,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text"
value={skipAddress}
onChange={(e) => setSkipAddress(e.target.value)}
disabled={savingSkipper || readOnly}
disabled={savingSkipper || skipperFormReadOnly}
/>
</div>
@@ -496,7 +508,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text"
value={skipBirthDate}
onChange={(e) => setSkipBirthDate(e.target.value)}
disabled={savingSkipper || readOnly}
disabled={savingSkipper || skipperFormReadOnly}
/>
</div>
@@ -507,7 +519,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text"
value={skipPhone}
onChange={(e) => setSkipPhone(e.target.value)}
disabled={savingSkipper || readOnly}
disabled={savingSkipper || skipperFormReadOnly}
/>
</div>
@@ -518,7 +530,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text"
value={skipNationality}
onChange={(e) => setSkipNationality(e.target.value)}
disabled={savingSkipper || readOnly}
disabled={savingSkipper || skipperFormReadOnly}
/>
</div>
@@ -529,7 +541,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text"
value={skipPassport}
onChange={(e) => setSkipPassport(e.target.value)}
disabled={savingSkipper || readOnly}
disabled={savingSkipper || skipperFormReadOnly}
/>
</div>
@@ -540,7 +552,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text"
value={skipBloodType}
onChange={(e) => setSkipBloodType(e.target.value)}
disabled={savingSkipper || readOnly}
disabled={savingSkipper || skipperFormReadOnly}
/>
</div>
@@ -551,7 +563,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text"
value={skipAllergies}
onChange={(e) => setSkipAllergies(e.target.value)}
disabled={savingSkipper || readOnly}
disabled={savingSkipper || skipperFormReadOnly}
/>
</div>
@@ -562,12 +574,12 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text"
value={skipDiseases}
onChange={(e) => setSkipDiseases(e.target.value)}
disabled={savingSkipper || readOnly}
disabled={savingSkipper || skipperFormReadOnly}
/>
</div>
</div>
{!readOnly && (
{!skipperFormReadOnly && (
<div className="form-actions">
{skipperSuccess && (
<div className="success-toast">
@@ -592,7 +604,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
<Users size={24} className="form-icon" />
<h2>{t('crew.crew_section')}</h2>
</div>
{!readOnly && crewList.length < 5 && !showMemberForm && (
{!readOnly && crewList.length < MAX_POOL_CREW_MEMBERS && !showMemberForm && (
<button className="btn primary" onClick={openAddMember} style={{ width: 'auto', padding: '8px 16px' }}>
<Plus size={16} />
{t('crew.add_crew')}
+52 -10
View File
@@ -1,10 +1,15 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import VesselForm from './VesselForm.tsx'
import CrewForm from './CrewForm.tsx'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
import type { LogbookCrewSelectionData } from '../types/person.js'
import { personToSnapshot } from '../utils/personSnapshots.js'
import LogEntriesList from './LogEntriesList.tsx'
import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react'
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
import type { VesselData } from '../types/vessel.js'
import type { LogbookVesselSelectionData } from '../types/vessel.js'
import { useAppTour, type AppTab } from '../context/AppTourContext.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
@@ -30,7 +35,10 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
useEffect(() => {
registerNavigation({
setActiveTab,
setSelectedEntryId: setTourSelectedEntryId
setSelectedEntryId: setTourSelectedEntryId,
setFeedbackOpen: () => {},
setLogbookActive: () => {},
setProfileOpen: () => {}
})
registerDemoTourContext({ firstEntryId: fixture.firstEntryId })
@@ -45,11 +53,32 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
}, [registerNavigation, registerDemoTourContext, startTour, fixture.firstEntryId])
const toggleLanguage = () => {
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
i18n.changeLanguage(nextLang)
cycleAppLanguage(i18n)
}
const { title, yacht, crews, entries, gpsTracks, photos, firstEntryId } = fixture
const {
title,
yacht,
vesselPool,
logbookVesselSelection,
personPool,
logbookCrewSelection,
entries,
gpsTracks,
photos,
firstEntryId
} = fixture
const demoSelection: LogbookCrewSelectionData = {
activeSkipperId: logbookCrewSelection.activeSkipperId,
activeCrewIds: logbookCrewSelection.activeCrewIds,
snapshotsById: Object.fromEntries(
Object.entries(logbookCrewSelection.snapshotsById).map(([id, snap]) => [
id,
personToSnapshot(id, snap)
])
)
}
return (
<div className="app-layout">
@@ -84,7 +113,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
</button>
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}>
<Globe size={14} style={{ marginRight: '4px' }} />
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
{t(`languages.${getNextLanguage(i18n.language)}`)}
</button>
</div>
</header>
@@ -112,7 +141,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
<button
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
onClick={() => setActiveTab('crew')}
data-tour="nav-crew"
data-tour="nav-logbook-crew"
>
<Users size={18} />
{t('nav.crew')}
@@ -135,11 +164,24 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
)}
{activeTab === 'vessel' && (
<VesselForm logbookId="demo" readOnly={true} preloadedData={yacht} />
<LogbookVesselPicker
logbookId="demo"
readOnly={true}
preloadedPool={vesselPool.map((v) => ({
payloadId: v.payloadId,
data: v.data as VesselData
}))}
preloadedSelection={logbookVesselSelection as LogbookVesselSelectionData}
/>
)}
{activeTab === 'crew' && (
<CrewForm logbookId="demo" readOnly={true} preloadedData={crews} />
<LogbookCrewPicker
logbookId="demo"
readOnly={true}
preloadedPool={personPool}
preloadedSelection={demoSelection}
/>
)}
</main>
</div>
+6 -1
View File
@@ -13,7 +13,12 @@ export default function DisclaimerModal({ open, onClose }: DisclaimerModalProps)
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
window.removeEventListener('keydown', onKeyDown)
document.body.style.overflow = prevOverflow
}
}, [open, onClose])
if (!open) return null
+168
View File
@@ -0,0 +1,168 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Users } from 'lucide-react'
import type { EntryCrewFields, PersonSnapshot } from '../types/person.js'
import { loadPersonPool } from '../services/personPool.js'
import { loadLogbookCrewSelection } from '../services/logbookCrewSelection.js'
import { buildSnapshotsForSelection } from '../utils/personSnapshots.js'
import type { PersonData } from '../types/person.js'
export interface EntryCrewSectionProps {
logbookId: string
readOnly?: boolean
value: EntryCrewFields
onChange: (next: EntryCrewFields) => void
/** Demo: fixed pool */
preloadedPool?: Map<string, PersonData>
}
export default function EntryCrewSection({
logbookId,
readOnly = false,
value,
onChange,
preloadedPool
}: EntryCrewSectionProps) {
const { t } = useTranslation()
const [pool, setPool] = useState<Map<string, PersonData>>(preloadedPool ?? new Map())
useEffect(() => {
if (preloadedPool) {
setPool(preloadedPool)
return
}
let cancelled = false
void (async () => {
try {
const people = await loadPersonPool()
if (cancelled) return
setPool(new Map(people.map((p) => [p.payloadId, p.data])))
} catch {
/* use snapshots only */
}
})()
return () => {
cancelled = true
}
}, [preloadedPool])
const displayPool = useMemo(() => {
const merged = new Map(pool)
for (const snap of Object.values(value.crewSnapshotsById)) {
if (!merged.has(snap.id)) {
merged.set(snap.id, {
name: snap.name,
address: snap.address,
birthDate: snap.birthDate,
phone: snap.phone,
nationality: snap.nationality,
passportNumber: snap.passportNumber,
bloodType: snap.bloodType,
allergies: snap.allergies,
diseases: snap.diseases,
role: snap.role,
photo: snap.photo
})
}
}
return merged
}, [pool, value.crewSnapshotsById])
const skippers = [...displayPool.entries()].filter(([, d]) => d.role === 'skipper')
const crewEntries = [...displayPool.entries()].filter(([, d]) => d.role === 'crew')
const applyChange = (skipperId: string | null, crewIds: string[]) => {
const snapshots = buildSnapshotsForSelection(skipperId, crewIds, displayPool)
onChange({
selectedSkipperId: skipperId,
selectedCrewIds: crewIds,
crewSnapshotsById: snapshots
})
}
const toggleCrew = (id: string) => {
if (readOnly) return
const next = value.selectedCrewIds.includes(id)
? value.selectedCrewIds.filter((x) => x !== id)
: [...value.selectedCrewIds, id]
applyChange(value.selectedSkipperId, next)
}
return (
<div className="form-card" data-tour="entry-crew">
<div className="form-header">
<Users size={22} className="form-icon" />
<h3>{t('entry_crew.title')}</h3>
</div>
<p className="help-text mb-3">{t('entry_crew.subtitle')}</p>
<div className="input-group mb-3">
<label>{t('entry_crew.day_skipper')}</label>
{skippers.length === 0 ? (
<p className="help-text">{t('entry_crew.no_skipper')}</p>
) : (
<div className="crew-selection-list">
{skippers.map(([id, data]) => (
<label key={id} className="crew-selection-item">
<input
type="radio"
name={`entry-skipper-${logbookId}`}
checked={value.selectedSkipperId === id}
onChange={() => !readOnly && applyChange(id, value.selectedCrewIds)}
disabled={readOnly}
/>
<span>{data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)}
</div>
<div className="input-group">
<label>{t('entry_crew.day_crew')}</label>
{crewEntries.length === 0 ? (
<p className="help-text">{t('entry_crew.no_crew')}</p>
) : (
<div className="crew-selection-list">
{crewEntries.map(([id, data]) => (
<label key={id} className="crew-selection-item">
<input
type="checkbox"
checked={value.selectedCrewIds.includes(id)}
onChange={() => toggleCrew(id)}
disabled={readOnly}
/>
<span>{data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)}
</div>
</div>
)
}
export async function loadDefaultEntryCrewForNewDay(
logbookId: string,
previousEntry: Record<string, unknown> | null
): Promise<EntryCrewFields> {
if (previousEntry) {
const selectedSkipperId =
typeof previousEntry.selectedSkipperId === 'string' ? previousEntry.selectedSkipperId : null
const selectedCrewIds = Array.isArray(previousEntry.selectedCrewIds)
? previousEntry.selectedCrewIds.filter((id): id is string => typeof id === 'string')
: []
const crewSnapshotsById =
previousEntry.crewSnapshotsById && typeof previousEntry.crewSnapshotsById === 'object'
? (previousEntry.crewSnapshotsById as Record<string, PersonSnapshot>)
: {}
return { selectedSkipperId, selectedCrewIds, crewSnapshotsById }
}
const selection = await loadLogbookCrewSelection(logbookId)
return {
selectedSkipperId: selection.activeSkipperId,
selectedCrewIds: [...selection.activeCrewIds],
crewSnapshotsById: { ...selection.snapshotsById }
}
}
@@ -0,0 +1,58 @@
import { useId, useMemo } from 'react'
import { joinTimeHHMM, splitTimeHHMM } from '../utils/logEntryPayload.js'
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'))
const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'))
interface EventTimeInput24hProps {
value: string
onChange: (value: string) => void
disabled?: boolean
'aria-label'?: string
}
export default function EventTimeInput24h({
value,
onChange,
disabled = false,
'aria-label': ariaLabel
}: EventTimeInput24hProps) {
const baseId = useId()
const { hours, minutes } = useMemo(() => splitTimeHHMM(value), [value])
return (
<div className="time-input-24h">
<select
id={`${baseId}-hours`}
className="input-text time-input-24h__select"
value={hours}
onChange={(e) => onChange(joinTimeHHMM(e.target.value, minutes))}
disabled={disabled}
aria-label={ariaLabel ? `${ariaLabel} (h)` : undefined}
>
{HOURS.map((hour) => (
<option key={hour} value={hour}>
{hour}
</option>
))}
</select>
<span className="time-input-24h__sep" aria-hidden="true">
:
</span>
<select
id={`${baseId}-minutes`}
className="input-text time-input-24h__select"
value={minutes}
onChange={(e) => onChange(joinTimeHHMM(hours, e.target.value))}
disabled={disabled}
aria-label={ariaLabel ? `${ariaLabel} (min)` : undefined}
>
{MINUTES.map((minute) => (
<option key={minute} value={minute}>
{minute}
</option>
))}
</select>
</div>
)
}
@@ -0,0 +1,51 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { MessageSquarePlus } from 'lucide-react'
import FeedbackModal from './FeedbackModal.tsx'
interface FeedbackHeaderButtonProps {
logbookId?: string | null
logbookTitle?: string | null
tourOpen?: boolean
onTourOpenChange?: (open: boolean) => void
tourHighlight?: boolean
}
export default function FeedbackHeaderButton({
logbookId,
logbookTitle,
tourOpen = false,
onTourOpenChange,
tourHighlight = false
}: FeedbackHeaderButtonProps) {
const { t } = useTranslation()
const [userOpen, setUserOpen] = useState(false)
const open = tourOpen || userOpen
const handleClose = () => {
setUserOpen(false)
onTourOpenChange?.(false)
}
return (
<>
<button
type="button"
className="btn-icon"
onClick={() => setUserOpen(true)}
title={t('feedback.button_title')}
aria-label={t('feedback.button_title')}
data-tour="feedback-button"
>
<MessageSquarePlus size={18} />
</button>
<FeedbackModal
open={open}
onClose={handleClose}
logbookId={logbookId}
logbookTitle={logbookTitle}
tourMode={tourHighlight}
/>
</>
)
}
+241
View File
@@ -0,0 +1,241 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CheckCircle2, MessageSquarePlus, X } from 'lucide-react'
import { FeedbackApiError, sendFeedback, type FeedbackCategory } from '../services/feedback.js'
const SUCCESS_CLOSE_DELAY_MS = 1800
interface FeedbackModalProps {
open: boolean
onClose: () => void
logbookId?: string | null
logbookTitle?: string | null
tourMode?: boolean
}
type SubmitState = 'idle' | 'submitting' | 'success' | 'error'
export default function FeedbackModal({
open,
onClose,
logbookId,
logbookTitle,
tourMode = false
}: FeedbackModalProps) {
const { t } = useTranslation()
const [category, setCategory] = useState<FeedbackCategory>('general')
const [contactEmail, setContactEmail] = useState('')
const [message, setMessage] = useState('')
const [website, setWebsite] = useState('')
const [submitState, setSubmitState] = useState<SubmitState>('idle')
const [statusMessage, setStatusMessage] = useState<string | null>(null)
const closeTimerRef = useRef<number | null>(null)
const openedAtRef = useRef<number>(Date.now())
const isBusy = submitState === 'submitting' || submitState === 'success'
const clearCloseTimer = () => {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current)
closeTimerRef.current = null
}
}
useEffect(() => {
return () => clearCloseTimer()
}, [])
useEffect(() => {
if (!open) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !isBusy) onClose()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [open, onClose, isBusy])
useEffect(() => {
if (!open) {
clearCloseTimer()
setCategory('general')
setContactEmail('')
setMessage('')
setWebsite('')
setSubmitState('idle')
setStatusMessage(null)
return
}
openedAtRef.current = Date.now()
}, [open])
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault()
if (!message.trim() || submitState === 'submitting' || submitState === 'success') return
setSubmitState('submitting')
setStatusMessage(null)
try {
await sendFeedback({
category,
message: message.trim(),
contactEmail: contactEmail.trim() || undefined,
logbookId,
logbookTitle,
openedAt: openedAtRef.current,
website
})
setSubmitState('success')
setStatusMessage(t('feedback.success'))
closeTimerRef.current = window.setTimeout(() => {
closeTimerRef.current = null
onClose()
}, SUCCESS_CLOSE_DELAY_MS)
} catch (error) {
setSubmitState('error')
setStatusMessage(
error instanceof FeedbackApiError && error.code === 'NOT_CONFIGURED'
? t('feedback.error_not_configured')
: error instanceof FeedbackApiError && error.code === 'INVALID_EMAIL'
? t('feedback.error_invalid_email')
: error instanceof FeedbackApiError && error.code === 'RATE_LIMITED'
? t('feedback.error_rate_limited')
: error instanceof FeedbackApiError && error.code === 'SPAM_DETECTED'
? t('feedback.error_spam')
: t('feedback.error_send')
)
}
}
if (!open) return null
return (
<div
className={`disclaimer-modal-overlay${tourMode ? ' feedback-modal-overlay--tour' : ''}`}
onClick={isBusy || tourMode ? undefined : onClose}
>
<div className="disclaimer-modal-panel" onClick={(event) => event.stopPropagation()}>
<div
className="auth-card glass registration-disclaimer registration-disclaimer--modal feedback-modal"
data-tour="feedback-form"
>
<button
type="button"
className="registration-disclaimer__close feedback-modal__close"
onClick={onClose}
disabled={isBusy || tourMode}
aria-label={t('feedback.cancel')}
>
<X size={18} />
</button>
<div className="auth-header">
<MessageSquarePlus className="auth-icon accent" size={48} />
<h2>{t('feedback.title')}</h2>
</div>
{submitState === 'success' ? (
<div className="feedback-status feedback-status--success" role="status" aria-live="polite">
<CheckCircle2 size={40} aria-hidden="true" />
<p>{statusMessage}</p>
</div>
) : (
<>
<p className="registration-disclaimer__intro">{t('feedback.intro')}</p>
{statusMessage && submitState === 'error' && (
<div className="feedback-status feedback-status--error" role="alert">
<p>{statusMessage}</p>
</div>
)}
<form className="feedback-form" onSubmit={handleSubmit}>
<label className="feedback-form__honeypot" aria-hidden="true">
<span>Website</span>
<input
type="text"
name="website"
value={website}
onChange={(event) => setWebsite(event.target.value)}
tabIndex={-1}
autoComplete="off"
/>
</label>
<label className="feedback-form__field">
<span>{t('feedback.category_label')}</span>
<select
value={category}
onChange={(event) => setCategory(event.target.value as FeedbackCategory)}
disabled={submitState === 'submitting'}
>
<option value="general">{t('feedback.category_general')}</option>
<option value="bug">{t('feedback.category_bug')}</option>
<option value="feature">{t('feedback.category_feature')}</option>
<option value="translation">{t('feedback.category_translation')}</option>
</select>
</label>
<label className="feedback-form__field">
<span>{t('feedback.contact_label')}</span>
<input
type="email"
value={contactEmail}
onChange={(event) => {
setContactEmail(event.target.value)
if (submitState === 'error') {
setSubmitState('idle')
setStatusMessage(null)
}
}}
placeholder={t('feedback.contact_placeholder')}
autoComplete="email"
maxLength={254}
disabled={submitState === 'submitting'}
/>
</label>
<label className="feedback-form__field">
<span>{t('feedback.message_label')}</span>
<textarea
value={message}
onChange={(event) => {
setMessage(event.target.value)
if (submitState === 'error') {
setSubmitState('idle')
setStatusMessage(null)
}
}}
placeholder={t('feedback.message_placeholder')}
rows={6}
maxLength={2000}
required
disabled={submitState === 'submitting'}
/>
</label>
<div className="auth-actions feedback-form__actions">
<button
type="button"
className="btn secondary"
onClick={onClose}
disabled={submitState === 'submitting' || tourMode}
>
{t('feedback.cancel')}
</button>
<button
type="submit"
className="btn primary"
disabled={submitState === 'submitting' || !message.trim()}
>
{submitState === 'submitting' ? t('feedback.sending') : t('feedback.send')}
</button>
</div>
</form>
</>
)}
</div>
</div>
</div>
)
}
+93 -76
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight, KeyRound } from 'lucide-react'
import {
getActiveMasterKey,
@@ -14,12 +15,32 @@ import { parseCollaborationRole } from '../services/logbook.js'
import { syncLogbook } from '../services/sync.js'
import { db } from '../services/db.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { apiJson } from '../services/api.js'
interface InvitationAcceptanceProps {
onAccepted: (logbookId: string, title: string) => void
onCancel: () => void
}
type LocalizedError =
| { source: 'i18n'; key: string }
| { source: 'raw'; text: string }
const resolveLocalizedError = (
error: LocalizedError | null,
t: (key: string) => string
): string | null => {
if (!error) return null
return error.source === 'i18n' ? t(error.key) : error.text
}
const localizedErrorFromMessage = (
message: string | undefined,
fallbackKey: string
): LocalizedError => {
return message ? { source: 'raw', text: message } : { source: 'i18n', key: fallbackKey }
}
const hexToBuffer = (hex: string): ArrayBuffer => {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < bytes.length; i++) {
@@ -33,7 +54,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
const [loading, setLoading] = useState(true)
const [accepting, setAccepting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [error, setError] = useState<LocalizedError | null>(null)
const [token, setToken] = useState('')
const [logbookKey, setLogbookKey] = useState<ArrayBuffer | null>(null)
@@ -47,7 +68,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
const [username, setUsername] = useState('')
const [loginMode, setLoginMode] = useState<'options' | 'register'>('options')
const [regUsername, setRegUsername] = useState('')
const [authError, setAuthError] = useState<string | null>(null)
const [authError, setAuthError] = useState<LocalizedError | null>(null)
const [recoveryPhrase, setRecoveryPhrase] = useState<string | null>(null)
const [showRecoveryFallback, setShowRecoveryFallback] = useState(false)
@@ -56,7 +77,8 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
const autoAcceptStarted = useRef(false)
const isDe = i18n.language.startsWith('de')
const errorText = resolveLocalizedError(error, t)
const authErrorText = resolveLocalizedError(authError, t)
const sessionReady = (): boolean => {
return !!(getActiveMasterKey() && localStorage.getItem('active_userid'))
@@ -82,19 +104,15 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
setLogbookKey(hexToBuffer(hexKey))
} catch (err) {
console.error('Invalid key in URL fragment:', err)
setError(isDe
? 'Der Einladungslink ist kryptografisch ungültig (Schlüssel fehlerhaft).'
: 'The invitation link is cryptographically invalid (corrupted key).')
setError({ source: 'i18n', key: 'invitation.error_invalid_key' })
}
} else {
setError(isDe
? 'Der Einladungslink enthält keinen Entschlüsselungsschlüssel (#key=...). Bitte den vollständigen Link vom Eigner verwenden.'
: 'The invitation link is missing the decryption key (#key=...). Please use the complete link from the owner.')
setError({ source: 'i18n', key: 'invitation.error_missing_key' })
}
const rand = Math.floor(1000 + Math.random() * 9000)
setRegUsername(`CrewSkipper_${rand}`)
}, [isDe])
}, [])
useEffect(() => {
if (token && logbookKey) {
@@ -109,14 +127,13 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
const res = await fetch(`/api/collaboration/invite-details?token=${token}`)
if (res.status === 410) {
setError(isDe
? 'Diese Einladung ist abgelaufen (48 Stunden gültig).'
: 'This invitation link has expired (valid for 48 hours only).')
setError({ source: 'i18n', key: 'invitation.error_expired' })
return
}
if (!res.ok) {
throw new Error(isDe ? 'Einladungstoken ungültig.' : 'Failed to verify invitation token.')
setError({ source: 'i18n', key: 'invitation.error_invalid_token' })
return
}
const details = await res.json()
@@ -129,7 +146,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
setDecryptedTitle(title)
} catch (err: any) {
console.error('Failed to load invitation details:', err)
setError(err.message || (isDe ? 'Einladungsdetails konnten nicht geladen werden.' : 'Invitation details could not be retrieved.'))
setError(localizedErrorFromMessage(err.message, 'invitation.error_load_failed'))
} finally {
setLoading(false)
}
@@ -140,9 +157,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
const activeUserId = localStorage.getItem('active_userid')
if (!masterKey || !activeUserId) {
autoAcceptStarted.current = false
setError(isDe
? 'Sitzung unvollständig — bitte erneut anmelden (Benutzer-ID fehlt).'
: 'Incomplete session — please log in again (user ID missing).')
setError({ source: 'i18n', key: 'invitation.error_incomplete_session' })
setIsLoggedIn(false)
return
}
@@ -164,12 +179,8 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
)
const encrypted = await encryptBuffer(logbookKey, aesMasterKey)
const res = await fetch('/api/collaboration/accept', {
const acceptResult = await apiJson<{ role: string; logbookId: string }>('/api/collaboration/accept', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': activeUserId
},
body: JSON.stringify({
token,
encryptedLogbookKey: encrypted.ciphertext,
@@ -177,13 +188,6 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
tag: encrypted.tag
})
})
if (!res.ok) {
const serverError = await res.json().catch(() => ({}))
throw new Error(serverError.error || (isDe ? 'Beitritt auf dem Server fehlgeschlagen.' : 'Failed to join logbook on the server.'))
}
const acceptResult = await res.json()
const collaborationRole = parseCollaborationRole(acceptResult.role, 'invitation accept')
await saveLogbookKey(logbookId, logbookKey)
@@ -204,12 +208,12 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
onAccepted(logbookId, decryptedTitle)
} catch (err: any) {
console.error('Accepting invitation failed:', err)
setError(err.message || (isDe ? 'Beitritt fehlgeschlagen.' : 'Acceptance failed.'))
setError(localizedErrorFromMessage(err.message, 'invitation.error_accept_failed'))
autoAcceptStarted.current = false
} finally {
setAccepting(false)
}
}, [logbookId, logbookKey, token, rawEncryptedTitle, decryptedTitle, onAccepted, isDe])
}, [logbookId, logbookKey, token, rawEncryptedTitle, decryptedTitle, onAccepted])
useEffect(() => {
if (loading || accepting || autoAcceptStarted.current) return
@@ -245,7 +249,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
if (resolvedUser) setUsername(resolvedUser)
setShowRecoveryFallback(true)
} catch (err: any) {
setAuthError(err.message || (isDe ? 'Passkey-Anmeldung fehlgeschlagen.' : 'Passkey authentication failed.'))
setAuthError(localizedErrorFromMessage(err.message, 'invitation.error_login_failed'))
} finally {
setLoading(false)
}
@@ -257,9 +261,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
const resolvedUser = (username.trim() || encryptedPayloads.username || '').trim()
if (!resolvedUser) {
setAuthError(isDe
? 'Benutzername konnte nicht ermittelt werden — bitte erneut anmelden.'
: 'Could not determine username — please try logging in again.')
setAuthError({ source: 'i18n', key: 'invitation.error_username_missing' })
return
}
@@ -272,10 +274,10 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
setIsLoggedIn(true)
setUsername(resolvedUser)
} else {
setAuthError(t('auth.error_incorrect_recovery'))
setAuthError({ source: 'i18n', key: 'auth.error_incorrect_recovery' })
}
} catch (err: any) {
setAuthError(err.message || t('auth.error_decryption_failed'))
setAuthError(localizedErrorFromMessage(err.message, 'auth.error_decryption_failed'))
} finally {
setLoading(false)
}
@@ -295,7 +297,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
setRecoveryPhrase(result.recoveryPhrase)
}
} catch (err: any) {
setAuthError(err.message || (isDe ? 'Registrierung fehlgeschlagen.' : 'Registration failed.'))
setAuthError(localizedErrorFromMessage(err.message, 'invitation.error_register_failed'))
} finally {
setLoading(false)
}
@@ -307,7 +309,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
}
const toggleLanguage = () => {
i18n.changeLanguage(i18n.language.startsWith('de') ? 'en' : 'de')
cycleAppLanguage(i18n)
}
if (recoveryPhrase) {
@@ -343,25 +345,46 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
<h2>{t('auth.enter_recovery')}</h2>
</div>
<p className="recovery-warning">{t('auth.recovery_fallback_warning')}</p>
<form onSubmit={handleRecoverySubmit}>
<textarea
className="input-text"
placeholder={t('auth.recovery_placeholder')}
value={recoveryInput}
onChange={(e) => setRecoveryInput(e.target.value)}
rows={3}
required
/>
<form onSubmit={handleRecoverySubmit} autoComplete="on">
{(username.trim() || encryptedPayloads?.username) && (
<input
type="text"
name="username"
autoComplete="username"
value={username.trim() || encryptedPayloads?.username || ''}
readOnly
tabIndex={-1}
aria-hidden="true"
style={{ position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' }}
/>
)}
<div className="input-group">
<label htmlFor="invitation-recovery-key" className="input-label" style={{ display: 'block', marginBottom: '8px', color: '#94a3b8' }}>
{t('auth.enter_recovery')}
</label>
<input
id="invitation-recovery-key"
name="recovery-key"
type="password"
className="input-text"
placeholder={t('auth.recovery_placeholder')}
value={recoveryInput}
onChange={(e) => setRecoveryInput(e.target.value)}
required
autoComplete="current-password"
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
/>
</div>
<div className="auth-actions mt-4">
<button type="button" className="btn secondary" onClick={() => setShowRecoveryFallback(false)}>
{isDe ? 'Zurück' : 'Back'}
{t('auth.back')}
</button>
<button type="submit" className="btn primary" disabled={loading}>
{t('auth.decrypt_logbook')}
</button>
</div>
</form>
{authError && <div className="auth-error mt-4">{authError}</div>}
{authErrorText && <div className="auth-error mt-4">{authErrorText}</div>}
</div>
)
}
@@ -371,12 +394,10 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
<div className="auth-card glass">
<div className="auth-header">
<Ship className="auth-icon accent spin" size={48} />
<h2>{accepting ? (isDe ? 'Beitritt...' : 'Joining...') : (isDe ? 'Einladung wird geprüft...' : 'Checking Invitation...')}</h2>
<h2>{accepting ? t('invitation.loading_joining') : t('invitation.loading_checking')}</h2>
</div>
<p className="recovery-warning">
{accepting
? (isDe ? 'Logbuch wird freigeschaltet und synchronisiert...' : 'Unlocking logbook and syncing data...')
: (isDe ? 'Lade Verschlüsselungsschlüssel...' : 'Retrieving encryption key...')}
{accepting ? t('invitation.loading_unlocking') : t('invitation.loading_retrieving_key')}
</p>
</div>
)
@@ -387,12 +408,12 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
<div className="auth-card glass">
<div className="auth-header">
<AlertTriangle className="auth-icon warn" size={48} />
<h2>{isDe ? 'Einladungsfehler' : 'Invitation Error'}</h2>
<h2>{t('invitation.error_title')}</h2>
</div>
<p className="recovery-warning" style={{ color: '#ef4444' }}>{error}</p>
<p className="recovery-warning" style={{ color: '#ef4444' }}>{errorText}</p>
<div className="auth-actions mt-6">
<button className="btn primary" onClick={onCancel} style={{ width: '100%' }}>
{isDe ? 'Zurück zum Start' : 'Back to Dashboard'}
{t('invitation.back_to_start')}
</button>
</div>
</div>
@@ -403,18 +424,18 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
<div className="auth-card glass">
<div className="auth-header">
<ShieldCheck className="auth-icon success" size={48} style={{ color: '#10b981' }} />
<h2>{isDe ? 'Logbuch-Einladung' : 'Logbook Invitation'}</h2>
<h2>{t('invitation.title')}</h2>
</div>
<div style={{ textAlign: 'center', margin: '20px 0', padding: '16px', background: 'rgba(255,255,255,0.03)', borderRadius: '12px' }}>
<p style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#64748b', textTransform: 'uppercase' }}>
{isDe ? 'Einladung von' : 'INVITED BY'}
{t('invitation.invited_by')}
</p>
<p style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: 600, color: '#f1f5f9' }}>
Skipper {ownerUsername}
</p>
<p style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#64748b', textTransform: 'uppercase' }}>
{isDe ? 'Schiff / Logbuch' : 'VESSEL / LOGBOOK'}
{t('invitation.vessel_logbook')}
</p>
<p style={{ margin: 0, fontSize: '20px', fontWeight: 700, color: '#fbbf24' }}>
{decryptedTitle}
@@ -424,12 +445,10 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
{isLoggedIn ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%' }}>
<p style={{ fontSize: '14px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}>
{isDe
? `Angemeldet als ${username}. Beitritt wird vorbereitet...`
: `Signed in as ${username}. Preparing to join...`}
{t('invitation.signed_in_preparing', { username })}
</p>
<button className="btn primary" onClick={handleAccept} disabled={accepting} style={{ width: '100%' }}>
{accepting ? (isDe ? 'Beitritt...' : 'Joining...') : (isDe ? 'Erneut beitreten' : 'Join again')}
{accepting ? t('invitation.loading_joining') : t('invitation.join_again')}
<ArrowRight size={16} />
</button>
</div>
@@ -438,27 +457,25 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
{loginMode === 'options' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<p style={{ fontSize: '13.5px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}>
{isDe
? 'Melden Sie sich an oder registrieren Sie ein Konto, um dem Logbuch beizutreten.'
: 'Sign in or register an account to join this logbook.'}
{t('invitation.login_or_register_hint')}
</p>
<button className="btn primary" onClick={handleLogin} disabled={loading} style={{ width: '100%', padding: '14px' }}>
<LogIn size={16} />
{isDe ? 'Mit Passkey anmelden' : 'Log In with Passkey'}
{t('auth.login')}
</button>
<div style={{ display: 'flex', alignItems: 'center', margin: '8px 0' }}>
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }} />
<span style={{ padding: '0 10px', fontSize: '12px', color: '#64748b' }}>
{isDe ? 'ODER NEU REGISTRIEREN' : 'OR SIGN UP'}
{t('invitation.or_sign_up')}
</span>
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }} />
</div>
<button className="btn secondary" onClick={() => setLoginMode('register')} style={{ width: '100%' }}>
<UserPlus size={16} />
{isDe ? 'Neues Crew-Konto erstellen' : 'Register New Crew Account'}
{t('invitation.register_crew_account')}
</button>
</div>
)}
@@ -467,7 +484,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
<form onSubmit={handleRegister} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div className="input-group">
<label style={{ display: 'block', fontSize: '13px', color: '#94a3b8', marginBottom: '6px' }}>
{isDe ? 'Benutzername' : 'Username'}
{t('invitation.username_label')}
</label>
<input
type="text"
@@ -479,23 +496,23 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
</div>
<div className="auth-actions">
<button type="button" className="btn secondary" onClick={() => setLoginMode('options')}>
{isDe ? 'Zurück' : 'Back'}
{t('auth.back')}
</button>
<button type="submit" className="btn primary" disabled={!regUsername.trim() || loading}>
{isDe ? 'Passkey erstellen' : 'Create Passkey'}
{t('invitation.create_passkey')}
</button>
</div>
</form>
)}
{authError && <div className="auth-error mt-4" style={{ fontSize: '13px' }}>{authError}</div>}
{authErrorText && <div className="auth-error mt-4" style={{ fontSize: '13px' }}>{authErrorText}</div>}
</div>
)}
<div className="auth-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '16px', marginTop: '24px' }}>
<button className="btn-icon-text" onClick={toggleLanguage}>
<Languages size={18} />
{isDe ? 'English' : 'Deutsch'}
{t(`languages.${getNextLanguage(i18n.language)}`)}
</button>
</div>
</div>
+54
View File
@@ -0,0 +1,54 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import QRCode from 'qrcode'
interface LinkQrCodeProps {
value: string
size?: number
}
export default function LinkQrCode({ value, size = 200 }: LinkQrCodeProps) {
const { t } = useTranslation()
const [dataUrl, setDataUrl] = useState<string | null>(null)
useEffect(() => {
if (!value.trim()) {
setDataUrl(null)
return
}
let cancelled = false
void QRCode.toDataURL(value, {
width: size,
margin: 2,
errorCorrectionLevel: 'M',
color: { dark: '#0f172a', light: '#ffffff' }
})
.then((url) => {
if (!cancelled) setDataUrl(url)
})
.catch((err) => {
console.error('QR code generation failed:', err)
if (!cancelled) setDataUrl(null)
})
return () => {
cancelled = true
}
}, [value, size])
if (!value.trim() || !dataUrl) return null
return (
<div className="link-qr-block">
<p className="link-qr-label">{t('settings.link_qr_hint')}</p>
<img
src={dataUrl}
width={size}
height={size}
className="link-qr-image"
alt={t('settings.link_qr_alt')}
/>
</div>
)
}
+323
View File
@@ -0,0 +1,323 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Camera, X } from 'lucide-react'
import {
captureVideoFrame,
preferNativeCameraPicker
} from '../utils/captureVideoFrame.js'
interface LiveCameraCaptureProps {
open: boolean
busy?: boolean
caption?: string
onCaptionChange?: (value: string) => void
onClose: () => void
onCapture: (blob: Blob) => void
}
type Phase = 'live' | 'preview' | 'native'
export default function LiveCameraCapture({
open,
busy = false,
caption = '',
onCaptionChange,
onClose,
onCapture
}: LiveCameraCaptureProps) {
const { t } = useTranslation()
const videoRef = useRef<HTMLVideoElement | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const previewUrlRef = useRef<string | null>(null)
const [cameraError, setCameraError] = useState<string | null>(null)
const [ready, setReady] = useState(false)
const [capturing, setCapturing] = useState(false)
const [phase, setPhase] = useState<Phase>(() => (preferNativeCameraPicker() ? 'native' : 'live'))
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
const [streamGeneration, setStreamGeneration] = useState(0)
const clearPreview = useCallback(() => {
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current)
previewUrlRef.current = null
}
setPreviewUrl(null)
setPreviewBlob(null)
}, [])
const stopStream = useCallback(() => {
for (const track of streamRef.current?.getTracks() ?? []) {
track.stop()
}
streamRef.current = null
if (videoRef.current) {
videoRef.current.srcObject = null
}
setReady(false)
}, [])
const enterPreview = useCallback((blob: Blob) => {
stopStream()
clearPreview()
const url = URL.createObjectURL(blob)
previewUrlRef.current = url
setPreviewBlob(blob)
setPreviewUrl(url)
setPhase('preview')
}, [stopStream, clearPreview])
const resetToLive = useCallback(() => {
clearPreview()
setCameraError(null)
setCapturing(false)
if (preferNativeCameraPicker()) {
setPhase('native')
} else {
setPhase('live')
setStreamGeneration((n) => n + 1)
}
}, [clearPreview])
useEffect(() => {
if (!open) {
stopStream()
clearPreview()
setCameraError(null)
setCapturing(false)
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
return
}
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
clearPreview()
}, [open, stopStream, clearPreview])
useEffect(() => {
if (!open || phase !== 'live') {
stopStream()
return
}
let cancelled = false
const start = async () => {
setCameraError(null)
setReady(false)
if (!navigator.mediaDevices?.getUserMedia) {
setCameraError(t('logs.live_photo_camera_unavailable'))
return
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: { ideal: 'environment' },
width: { ideal: 1280 },
height: { ideal: 720 }
},
audio: false
})
if (cancelled) {
for (const track of stream.getTracks()) track.stop()
return
}
streamRef.current = stream
const video = videoRef.current
if (!video) return
const markReady = () => {
if (cancelled) return
if (video.videoWidth > 0 && video.videoHeight > 0) {
setReady(true)
}
}
video.onloadedmetadata = markReady
video.srcObject = stream
await video.play()
markReady()
} catch (err) {
console.error('Camera access failed:', err)
if (!cancelled) {
setCameraError(t('logs.live_photo_camera_denied'))
}
}
}
void start()
return () => {
cancelled = true
stopStream()
}
}, [open, phase, streamGeneration, stopStream, t])
const handleCapture = async () => {
const video = videoRef.current
if (!video || !ready || busy || capturing) return
setCapturing(true)
setCameraError(null)
try {
const blob = await captureVideoFrame(video)
enterPreview(blob)
} catch (err) {
console.error('Live camera capture failed:', err)
setCameraError(t('logs.live_photo_capture_failed'))
} finally {
setCapturing(false)
}
}
const handleNativeFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ''
if (!file || busy) return
setCameraError(null)
try {
enterPreview(file)
} catch (err) {
console.error('Live camera file pick failed:', err)
setCameraError(t('logs.live_photo_capture_failed'))
}
}
const handleSave = () => {
if (!previewBlob || busy) return
onCapture(previewBlob)
}
const handleRetake = () => {
if (busy) return
resetToLive()
}
const openNativePicker = () => {
if (busy) return
fileInputRef.current?.click()
}
if (!open) return null
const showPreview = phase === 'preview' && previewUrl
return (
<div
className="live-log-modal-backdrop live-camera-backdrop"
onClick={(e) => { if (e.target === e.currentTarget && !busy) onClose() }}
>
<div className="live-log-modal live-camera-modal" onClick={(e) => e.stopPropagation()}>
<div className="live-camera-header">
<h3>{t('logs.live_photo_btn')}</h3>
<button
type="button"
className="btn secondary live-camera-close"
onClick={onClose}
disabled={busy}
aria-label={t('logs.confirm_no')}
>
<X size={18} />
</button>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
capture="environment"
className="live-camera-file-input"
onChange={(e) => void handleNativeFile(e)}
/>
{cameraError && (
<p className="live-log-modal-hint auth-error">{cameraError}</p>
)}
{showPreview ? (
<div className="live-camera-preview-wrap">
<img
src={previewUrl}
alt=""
className="live-camera-preview live-camera-preview-still"
/>
</div>
) : phase === 'native' ? (
<div className="live-camera-native-prompt">
<p className="live-log-modal-hint">{t('logs.live_photo_native_hint')}</p>
<button
type="button"
className="btn primary live-camera-open-native"
onClick={openNativePicker}
disabled={busy}
>
<Camera size={18} />
{t('logs.live_photo_open_camera_btn')}
</button>
</div>
) : cameraError && !ready ? null : (
<div className="live-camera-preview-wrap">
<video
ref={videoRef}
className="live-camera-preview"
playsInline
muted
autoPlay
/>
{!ready && (
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
)}
</div>
)}
{onCaptionChange && (
<div className="input-group live-camera-caption">
<label>{t('logs.photo_caption_label')}</label>
<input
type="text"
className="input-text"
placeholder={t('logs.photo_caption_placeholder')}
value={caption}
onChange={(e) => onCaptionChange(e.target.value)}
disabled={busy}
/>
</div>
)}
<div className="live-log-modal-actions live-camera-actions">
<button type="button" className="btn secondary" onClick={onClose} disabled={busy}>
{t('logs.confirm_no')}
</button>
{showPreview ? (
<>
<button type="button" className="btn secondary" onClick={handleRetake} disabled={busy}>
{t('logs.live_photo_retake_btn')}
</button>
<button
type="button"
className="btn primary live-camera-shutter"
onClick={handleSave}
disabled={busy || !previewBlob}
>
<Camera size={18} />
{busy ? t('logs.photo_processing') : t('logs.live_photo_save_btn')}
</button>
</>
) : phase === 'native' ? null : (
<button
type="button"
className="btn primary live-camera-shutter"
onClick={() => void handleCapture()}
disabled={busy || capturing || !ready || !!cameraError}
>
<Camera size={18} />
{capturing ? t('logs.photo_processing') : t('logs.live_photo_capture_btn')}
</button>
)}
</div>
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
+150 -44
View File
@@ -8,11 +8,20 @@ import { syncLogbook } from '../services/sync.js'
import { downloadCsv, shareCsv } from '../services/csvExport.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js'
import { findTodayEntryId } from '../services/quickEventLog.js'
import LogEntryEditor from './LogEntryEditor.tsx'
import LiveLogView from './LiveLogView.tsx'
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
import { useDialog } from './ModalDialog.tsx'
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react'
import {
buildEntryListCache,
entryListItemFromLocal,
putEntryRecord
} from '../utils/entryListCache.js'
import { forEachInBatches } from '../utils/yieldToMain.js'
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, List } from 'lucide-react'
import {
carryOverFromPreviousDay,
compareTravelDaysChronological,
@@ -36,6 +45,8 @@ interface LogEntriesListProps {
highlightEntryId?: string | null
}
type LogsViewMode = 'list' | 'live'
interface DecryptedEntryItem {
id: string
date: string
@@ -75,6 +86,8 @@ export default function LogEntriesList({
const [loading, setLoading] = useState(false)
const [exporting, setExporting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<LogsViewMode>('list')
const [returnToLiveAfterEditor, setReturnToLiveAfterEditor] = useState(false)
const prevSelectedEntryIdRef = useRef<string | null | undefined>(undefined)
const loadEntries = useCallback(async () => {
@@ -109,24 +122,34 @@ export default function LogEntriesList({
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const local = await db.entries.where({ logbookId }).toArray()
const list: DecryptedEntryItem[] = []
const needsDecrypt: typeof local = []
for (const entry of local) {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
if (decrypted) {
list.push({
id: entry.payloadId,
date: decrypted.date || '',
dayOfTravel: decrypted.dayOfTravel || '',
departure: decrypted.departure || '',
destination: decrypted.destination || '',
updatedAt: entry.updatedAt,
skipperSignStatus: await getSkipperSignStatus(decrypted as Record<string, unknown>)
})
const cached = entryListItemFromLocal(entry)
if (cached) {
list.push(cached)
} else {
needsDecrypt.push(entry)
}
}
await forEachInBatches(needsDecrypt, 8, async (entry) => {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
if (!decrypted) return
const listCache = await buildEntryListCache(decrypted as Record<string, unknown>)
list.push({
id: entry.payloadId,
...listCache,
updatedAt: entry.updatedAt
})
void db.entries.update(entry.payloadId, { listCache }).catch((err) => {
console.warn('Failed to persist entry list cache:', err)
})
})
// Sort chronological descending (by date, or dayOfTravel numerical)
list.sort((a, b) => {
const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime()
@@ -137,24 +160,26 @@ export default function LogEntriesList({
setEntries(list)
} catch (err: any) {
console.error('Failed to load log entries:', err)
setError(err.message || 'Decryption failed. Could not load journal list.')
setError(getErrorMessage(err, t('errors.load_failed')))
} finally {
setLoading(false)
}
}, [logbookId, readOnly, preloadedEntries])
useEffect(() => {
if (viewMode === 'live') return
loadEntries()
}, [loadEntries])
}, [loadEntries, viewMode])
useEffect(() => {
if (viewMode === 'live') return
const prevSelectedEntryId = prevSelectedEntryIdRef.current
prevSelectedEntryIdRef.current = selectedEntryId
if (prevSelectedEntryId !== undefined && prevSelectedEntryId !== null && selectedEntryId === null) {
loadEntries()
}
}, [selectedEntryId, loadEntries])
}, [selectedEntryId, loadEntries, viewMode])
const handleDownloadCsv = async () => {
setExporting(true)
@@ -169,7 +194,7 @@ export default function LogEntriesList({
trackPlausibleEvent(PlausibleEvents.CSV_EXPORTED)
} catch (err: any) {
console.error('Failed to download CSV:', err)
setError(err.message || 'Failed to generate CSV export.')
setError(getErrorMessage(err, t('errors.export_failed')))
} finally {
setExporting(false)
}
@@ -197,7 +222,7 @@ export default function LogEntriesList({
setError(t('logs.share_unsupported'))
} else {
console.error('Failed to share CSV:', err)
setError(err.message || 'Failed to share CSV export.')
setError(getErrorMessage(err, t('errors.export_failed')))
}
} finally {
setExporting(false)
@@ -218,7 +243,7 @@ export default function LogEntriesList({
trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
} catch (err: any) {
console.error('Failed to download PDF:', err)
setError(err.message || 'Failed to generate PDF export.')
setError(getErrorMessage(err, t('errors.export_failed')))
} finally {
setExporting(false)
}
@@ -231,6 +256,12 @@ export default function LogEntriesList({
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const existingTodayId = await findTodayEntryId(logbookId)
if (existingTodayId) {
setSelectedEntryId(existingTodayId)
return
}
const localEntries = await db.entries.where({ logbookId }).toArray()
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
@@ -241,14 +272,15 @@ export default function LogEntriesList({
decryptedEntries.sort(compareTravelDaysChronological)
const previousEntry = decryptedEntries.at(-1) ?? null
let { freshwater, fuel, departure } = carryOverFromPreviousDay(previousEntry)
let { freshwater, fuel, greywaterLevel, departure } = carryOverFromPreviousDay(previousEntry)
if (previousEntry && hasCarryOverFromPreviousDay({ freshwater, fuel, departure })) {
if (previousEntry && hasCarryOverFromPreviousDay({ freshwater, fuel, greywaterLevel, departure })) {
const confirmed = await showConfirm(
t('logs.carry_over_tanks_confirm', {
departure: departure || '—',
fw: formatTankLiters(freshwater.morning),
fuel: formatTankLiters(fuel.morning)
fuel: formatTankLiters(fuel.morning),
greywater: formatTankLiters(greywaterLevel)
}),
t('logs.carry_over_tanks_title'),
t('logs.carry_over_tanks_yes'),
@@ -257,6 +289,7 @@ export default function LogEntriesList({
if (!confirmed) {
freshwater = emptyTankLevels()
fuel = emptyTankLevels()
greywaterLevel = 0
departure = ''
}
}
@@ -267,6 +300,12 @@ export default function LogEntriesList({
const nowStr = new Date().toISOString()
const todayStr = nowStr.substring(0, 10)
const { loadDefaultEntryCrewForNewDay } = await import('./EntryCrewSection.js')
const entryCrew = await loadDefaultEntryCrewForNewDay(
logbookId,
previousEntry as Record<string, unknown> | null
)
const initialPayload = {
date: todayStr,
dayOfTravel: getNextTravelDayNumber(decryptedEntries),
@@ -274,6 +313,10 @@ export default function LogEntriesList({
destination: '',
freshwater,
fuel,
...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}),
selectedSkipperId: entryCrew.selectedSkipperId,
selectedCrewIds: entryCrew.selectedCrewIds,
crewSnapshotsById: entryCrew.crewSnapshotsById,
signSkipper: '',
signCrew: '',
events: []
@@ -282,14 +325,17 @@ export default function LogEntriesList({
const encrypted = await encryptJson(initialPayload, masterKey)
// Save locally
await db.entries.put({
payloadId: localId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: nowStr
})
await putEntryRecord(
{
payloadId: localId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: nowStr
},
initialPayload
)
// Queue for background sync
await db.syncQueue.put({
@@ -307,7 +353,7 @@ export default function LogEntriesList({
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
console.error('Failed to create entry:', err)
setError(err.message || 'Failed to create new log entry.')
setError(getErrorMessage(err, t('errors.save_failed')))
} finally {
setLoading(false)
}
@@ -337,7 +383,7 @@ export default function LogEntriesList({
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
console.error('Failed to delete log entry:', err)
setError(err.message || 'Failed to delete log entry.')
setError(getErrorMessage(err, t('errors.delete_failed')))
}
}
}
@@ -347,7 +393,13 @@ export default function LogEntriesList({
<LogEntryEditor
entryId={selectedEntryId}
logbookId={logbookId}
onBack={() => setSelectedEntryId(null)}
onBack={() => {
setSelectedEntryId(null)
if (returnToLiveAfterEditor) {
setViewMode('live')
setReturnToLiveAfterEditor(false)
}
}}
readOnly={readOnly}
preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)}
preloadedPhotos={preloadedPhotos}
@@ -356,6 +408,22 @@ export default function LogEntriesList({
)
}
if (viewMode === 'live' && !readOnly) {
return (
<LiveLogView
logbookId={logbookId}
onOpenEditor={(entryId) => {
setReturnToLiveAfterEditor(true)
setSelectedEntryId(entryId)
}}
onSwitchToList={() => {
setViewMode('list')
void loadEntries()
}}
/>
)
}
if (loading) {
return (
<div className="tab-placeholder">
@@ -365,14 +433,42 @@ export default function LogEntriesList({
)
}
const tourFirstEntryId =
highlightEntryId && entries.some((e) => e.id === highlightEntryId)
? highlightEntryId
: entries[0]?.id ?? null
return (
<div className="form-card">
<div className="logs-journal">
<div className="section-title-bar mb-6">
<div className="form-header" style={{ margin: 0 }}>
<Calendar size={24} className="form-icon" />
<h2>{t('logs.title')}</h2>
</div>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<div className="section-toolbar">
{!readOnly && (
<div className="logs-view-toggle" role="group" aria-label={t('logs.view_mode_label')}>
<button
type="button"
className={`btn secondary logs-view-toggle-btn ${viewMode === 'list' ? 'is-active' : ''}`}
onClick={() => setViewMode('list')}
title={t('logs.view_list')}
>
<List size={16} />
<span className="hide-mobile">{t('logs.view_list')}</span>
</button>
<button
type="button"
className={`btn secondary logs-view-toggle-btn ${viewMode === 'live' ? 'is-active' : ''}`}
onClick={() => setViewMode('live')}
title={t('logs.live_mode')}
>
<Radio size={16} />
<span className="hide-mobile">{t('logs.live_mode')}</span>
</button>
</div>
)}
<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>
@@ -384,9 +480,9 @@ export default function LogEntriesList({
</button>
{!readOnly && (
<button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }}>
<button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.new_entry')}>
<Plus size={16} />
{t('logs.new_entry')}
<span className="hide-mobile">{t('logs.new_entry')}</span>
</button>
)}
</div>
@@ -402,10 +498,20 @@ export default function LogEntriesList({
<div
key={item.id}
className="logbook-card glass"
data-tour={highlightEntryId === item.id ? 'entry-first' : undefined}
onClick={() => setSelectedEntryId(item.id)}
data-tour={tourFirstEntryId === item.id ? 'entry-first' : undefined}
>
<div className="card-icon">
<button
type="button"
className="logbook-card-select"
onClick={() => setSelectedEntryId(item.id)}
aria-label={
item.departure && item.destination
? `${item.departure}${item.destination}, ${t('logs.travel_day_number', { number: item.dayOfTravel })}`
: `${t('logs.new_entry')}, ${t('logs.travel_day_number', { number: item.dayOfTravel })}`
}
/>
<div className="card-icon" aria-hidden>
<FileText size={24} />
</div>
@@ -417,7 +523,7 @@ export default function LogEntriesList({
</h3>
<div className="card-meta">
<span className="sync-badge synced">
{t('logs.day_of_travel')} {item.dayOfTravel}
{t('logs.travel_day_number', { number: item.dayOfTravel })}
</span>
<EntrySkipperSignBadge status={item.skipperSignStatus} />
<span className="date-badge">
@@ -426,6 +532,8 @@ export default function LogEntriesList({
</div>
</div>
<ChevronRight size={18} className="logbook-card-chevron" aria-hidden />
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
<Download size={18} />
</button>
@@ -435,8 +543,6 @@ export default function LogEntriesList({
<Trash2 size={18} />
</button>
)}
<ChevronRight size={18} style={{ color: '#475569', marginLeft: 'auto' }} />
</div>
))}
</div>
File diff suppressed because it is too large Load Diff
+105 -86
View File
@@ -12,6 +12,7 @@ import {
type LogbookBackupPreview
} from '../services/logbookBackup.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
interface LogbookBackupPanelProps {
logbookId: string
@@ -41,7 +42,7 @@ function mapBackupError(code: string, t: (key: string) => string): string {
}
export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBackupPanelProps) {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const { showConfirm } = useDialog()
const fileInputRef = useRef<HTMLInputElement>(null)
@@ -58,6 +59,16 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const handleExportSubmit = async (e: React.FormEvent) => {
e.preventDefault()
await handleExport()
}
const handleImportSubmit = async (e: React.FormEvent) => {
e.preventDefault()
await handleRestore()
}
const handleExport = async () => {
setError(null)
setSuccess(null)
@@ -209,40 +220,45 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
</h4>
<p className="text-muted backup-section-desc">{t('settings.backup_export_desc')}</p>
<div className="input-group">
<label htmlFor="backup-export-passphrase">{t('settings.backup_passphrase')}</label>
<input
id="backup-export-passphrase"
type="password"
className="input-text"
value={exportPassphrase}
onChange={(e) => setExportPassphrase(e.target.value)}
placeholder={t('settings.backup_passphrase_placeholder')}
autoComplete="new-password"
disabled={exporting}
/>
</div>
<div className="input-group">
<label htmlFor="backup-export-confirm">{t('settings.backup_passphrase_confirm')}</label>
<input
id="backup-export-confirm"
type="password"
className="input-text"
value={exportConfirm}
onChange={(e) => setExportConfirm(e.target.value)}
autoComplete="new-password"
disabled={exporting}
/>
</div>
<button
type="button"
className="btn primary"
onClick={handleExport}
disabled={exporting || !exportPassphrase || !exportConfirm}
>
<Download size={16} />
{exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')}
</button>
<form onSubmit={handleExportSubmit} className="backup-export-form">
<div className="input-group">
<label htmlFor="backup-export-passphrase">{t('settings.backup_passphrase')}</label>
<input
id="backup-export-passphrase"
name="backup-export-passphrase"
type="password"
className="input-text"
value={exportPassphrase}
onChange={(e) => setExportPassphrase(e.target.value)}
placeholder={t('settings.backup_passphrase_placeholder')}
autoComplete="new-password"
disabled={exporting}
required
/>
</div>
<div className="input-group">
<label htmlFor="backup-export-confirm">{t('settings.backup_passphrase_confirm')}</label>
<input
id="backup-export-confirm"
name="backup-export-confirm"
type="password"
className="input-text"
value={exportConfirm}
onChange={(e) => setExportConfirm(e.target.value)}
autoComplete="new-password"
disabled={exporting}
required
/>
</div>
<button
type="submit"
className="btn primary"
disabled={exporting || !exportPassphrase || !exportConfirm}
>
<Download size={16} />
{exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')}
</button>
</form>
</section>
<section className="backup-section backup-section--import" aria-labelledby="backup-import-heading">
@@ -252,58 +268,61 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
</h4>
<p className="text-muted backup-section-desc">{t('settings.backup_restore_desc')}</p>
<div className="input-group">
<label htmlFor="backup-import-file">{t('settings.backup_file_label')}</label>
<input
id="backup-import-file"
ref={fileInputRef}
type="file"
accept=".daagbok.json,application/json"
className="input-text"
onChange={handleFileChange}
disabled={importing}
/>
</div>
<form onSubmit={handleImportSubmit} className="backup-import-form">
<div className="input-group">
<label htmlFor="backup-import-file">{t('settings.backup_file_label')}</label>
<input
id="backup-import-file"
ref={fileInputRef}
type="file"
accept=".daagbok.json,application/json"
className="input-text"
onChange={handleFileChange}
disabled={importing}
/>
</div>
{importFile && (
<>
<div className="input-group">
<label htmlFor="backup-import-passphrase">{t('settings.backup_passphrase')}</label>
<input
id="backup-import-passphrase"
type="password"
className="input-text"
value={importPassphrase}
onChange={(e) => {
setImportPassphrase(e.target.value)
setImportPreview(null)
}}
autoComplete="current-password"
disabled={importing}
/>
</div>
{importFile && (
<>
<div className="input-group">
<label htmlFor="backup-import-passphrase">{t('settings.backup_passphrase')}</label>
<input
id="backup-import-passphrase"
name="backup-import-passphrase"
type="password"
className="input-text"
value={importPassphrase}
onChange={(e) => {
setImportPassphrase(e.target.value)
setImportPreview(null)
}}
autoComplete="current-password"
disabled={importing}
required
/>
</div>
<div className="backup-actions-row">
<button
type="button"
className="btn secondary"
onClick={handlePreviewImport}
disabled={previewing || importing || !importPassphrase}
>
{previewing ? t('settings.backup_previewing') : t('settings.backup_preview_btn')}
</button>
<button
type="button"
className="btn primary"
onClick={() => handleRestore()}
disabled={importing || !importPassphrase}
>
<Upload size={16} />
{importing ? t('settings.backup_restoring') : t('settings.backup_restore_btn')}
</button>
</div>
</>
)}
<div className="backup-actions-row">
<button
type="button"
className="btn secondary"
onClick={handlePreviewImport}
disabled={previewing || importing || !importPassphrase}
>
{previewing ? t('settings.backup_previewing') : t('settings.backup_preview_btn')}
</button>
<button
type="submit"
className="btn primary"
disabled={importing || !importPassphrase}
>
<Upload size={16} />
{importing ? t('settings.backup_restoring') : t('settings.backup_restore_btn')}
</button>
</div>
</>
)}
</form>
{importPreview && (
<div className="backup-preview glass">
@@ -316,7 +335,7 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
</ul>
<p className="text-muted backup-preview-date">
{t('settings.backup_exported_at', {
date: new Date(importPreview.exportedAt).toLocaleString()
date: formatAppDateTime(importPreview.exportedAt, i18n.language)
})}
</p>
</div>
+224
View File
@@ -0,0 +1,224 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Users, User, Save, Check } from 'lucide-react'
import type { LogbookCrewSelectionData, PersonSnapshot } from '../types/person.js'
import type { DecryptedPerson } from '../services/personPool.js'
import { loadPersonPool, filterSkippers, filterCrew } from '../services/personPool.js'
import { loadLogbookCrewSelection, saveLogbookCrewSelectionFromIds } from '../services/logbookCrewSelection.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
export interface LogbookCrewPickerProps {
logbookId: string
readOnly?: boolean
/** Demo / share: in-memory pool */
preloadedPool?: Array<{ payloadId: string; data: DecryptedPerson['data'] }>
preloadedSelection?: LogbookCrewSelectionData
/** Shared logbook: only people from selection snapshots */
selectionOnly?: boolean
}
function snapshotsToPoolList(
selection: LogbookCrewSelectionData
): Array<{ payloadId: string; data: DecryptedPerson['data'] }> {
return Object.values(selection.snapshotsById).map((snap) => ({
payloadId: snap.id,
data: {
name: snap.name,
address: snap.address,
birthDate: snap.birthDate,
phone: snap.phone,
nationality: snap.nationality,
passportNumber: snap.passportNumber,
bloodType: snap.bloodType,
allergies: snap.allergies,
diseases: snap.diseases,
role: snap.role,
photo: snap.photo
}
}))
}
export default function LogbookCrewPicker({
logbookId,
readOnly = false,
preloadedPool,
preloadedSelection,
selectionOnly = false
}: LogbookCrewPickerProps) {
const { t } = useTranslation()
const [loading, setLoading] = useState(!preloadedSelection)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | null>(null)
const [pool, setPool] = useState<DecryptedPerson[]>([])
const [activeSkipperId, setActiveSkipperId] = useState<string | null>(null)
const [activeCrewIds, setActiveCrewIds] = useState<string[]>([])
const loadData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const selection =
preloadedSelection ??
(logbookId === 'demo' ? null : await loadLogbookCrewSelection(logbookId))
if (selection) {
setActiveSkipperId(selection.activeSkipperId)
setActiveCrewIds([...selection.activeCrewIds])
}
if (preloadedPool) {
setPool(
preloadedPool.map((p) => ({
payloadId: p.payloadId,
data: p.data
}))
)
} else if (selectionOnly && selection) {
setPool(snapshotsToPoolList(selection))
} else {
setPool(await loadPersonPool())
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to load crew selection')
} finally {
setLoading(false)
}
}, [logbookId, preloadedPool, preloadedSelection, selectionOnly])
useEffect(() => {
void loadData()
}, [loadData])
const skippers = useMemo(() => filterSkippers(pool), [pool])
const crewMembers = useMemo(() => filterCrew(pool), [pool])
const toggleCrew = (id: string) => {
if (readOnly) return
setActiveCrewIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
)
}
const handleSave = async () => {
if (readOnly || logbookId === 'demo') return
setSaving(true)
setError(null)
setSaved(false)
try {
await saveLogbookCrewSelectionFromIds(logbookId, activeSkipperId, activeCrewIds)
setSaved(true)
trackPlausibleEvent(PlausibleEvents.CREW_SAVED, { context: 'logbook_selection' })
setTimeout(() => setSaved(false), 3000)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to save')
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div className="tab-placeholder">
<Users className="header-logo spin" size={48} />
<p>{t('person_pool.loading')}</p>
</div>
)
}
return (
<div className="crew-dashboard-layout" data-tour="logbook-crew-picker">
<div className="form-card">
<div className="form-header">
<Users size={24} className="form-icon" />
<h2>{t('logbook_crew.title')}</h2>
</div>
<p className="help-text mb-4">{t('logbook_crew.subtitle')}</p>
{selectionOnly && <p className="help-text mb-4">{t('logbook_crew.selection_only_hint')}</p>}
{error && <div className="auth-error mb-4">{error}</div>}
<div className="input-group mb-4">
<label>{t('logbook_crew.active_skipper')}</label>
{skippers.length === 0 ? (
<p className="help-text">{t('logbook_crew.no_skippers_in_pool')}</p>
) : (
<div className="crew-selection-list">
{skippers.map((s) => (
<label key={s.payloadId} className="crew-selection-item">
<input
type="radio"
name={`skipper-${logbookId}`}
checked={activeSkipperId === s.payloadId}
onChange={() => !readOnly && setActiveSkipperId(s.payloadId)}
disabled={readOnly}
/>
<User size={16} aria-hidden="true" />
<span>{s.data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
{!readOnly && (
<label className="crew-selection-item">
<input
type="radio"
name={`skipper-${logbookId}`}
checked={activeSkipperId === null}
onChange={() => setActiveSkipperId(null)}
/>
<span>{t('logbook_crew.no_skipper')}</span>
</label>
)}
</div>
)}
</div>
<div className="input-group mb-4">
<label>{t('logbook_crew.active_crew')}</label>
{crewMembers.length === 0 ? (
<p className="help-text">{t('logbook_crew.no_crew_in_pool')}</p>
) : (
<div className="crew-selection-list">
{crewMembers.map((c) => (
<label key={c.payloadId} className="crew-selection-item">
<input
type="checkbox"
checked={activeCrewIds.includes(c.payloadId)}
onChange={() => toggleCrew(c.payloadId)}
disabled={readOnly}
/>
<span>{c.data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)}
</div>
{!readOnly && logbookId !== 'demo' && (
<div className="form-actions">
{saved && (
<div className="success-toast">
<Check size={16} />
<span>{t('logbook_crew.saved')}</span>
</div>
)}
<button type="button" className="btn primary" onClick={() => void handleSave()} disabled={saving}>
<Save size={18} />
{t('logbook_crew.save')}
</button>
</div>
)}
</div>
</div>
)
}
export function selectionFromSnapshots(
snapshotsById: Record<string, PersonSnapshot>
): LogbookCrewSelectionData {
const snapshots = Object.values(snapshotsById)
const skipper = snapshots.find((s) => s.role === 'skipper')
return {
activeSkipperId: skipper?.id ?? null,
activeCrewIds: snapshots.filter((s) => s.role === 'crew').map((s) => s.id),
snapshotsById
}
}
+328 -54
View File
@@ -1,34 +1,68 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useLiveQuery } from 'dexie-react-hooks'
import { db } from '../services/db.js'
import { fetchLogbooks, createLogbook, deleteLogbook, type DecryptedLogbook } from '../services/logbook.js'
import { cycleAppLanguage } from '../utils/i18nLanguages.js'
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
import { loadLogbookSearchFieldsBatch } from '../services/logbookSearchIndex.js'
import { logbookMatchesFilter, type LogbookSearchFields } from '../utils/logbookFilter.js'
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
import BetaBadge from './BetaBadge.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js'
import { logoutUser } from '../services/auth.js'
import { useDialog } from './ModalDialog.tsx'
import AccountDangerZone from './AccountDangerZone.tsx'
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
interface LogbookDashboardProps {
onSelectLogbook: (id: string, title: string) => void
onLogout: () => void
onOpenProfile: () => void
}
export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookDashboardProps) {
type LogbookSortKey = 'name' | 'date'
type LogbookSortDirection = 'asc' | 'desc'
function sortLogbooks(
items: DecryptedLogbook[],
sortBy: LogbookSortKey,
direction: LogbookSortDirection,
locale: string
): DecryptedLogbook[] {
const sorted = [...items]
sorted.sort((a, b) => {
const cmp =
sortBy === 'name'
? a.title.localeCompare(b.title, locale, { sensitivity: 'base' })
: new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
return direction === 'asc' ? cmp : -cmp
})
return sorted
}
export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile }: LogbookDashboardProps) {
const { t, i18n } = useTranslation()
const { showConfirm } = useDialog()
const [logbooks, setLogbooks] = useState<DecryptedLogbook[]>([])
const [newTitle, setNewTitle] = useState('')
const [editingLogbookId, setEditingLogbookId] = useState<string | null>(null)
const [editingTitleDraft, setEditingTitleDraft] = useState('')
const titleInputRef = useRef<HTMLInputElement>(null)
const [loading, setLoading] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [filterQuery, setFilterQuery] = useState('')
const [searchFieldsByLogbookId, setSearchFieldsByLogbookId] = useState<Map<string, LogbookSearchFields>>(
() => new Map()
)
const [sortBy, setSortBy] = useState<LogbookSortKey>('date')
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
const filterInputRef = useRef<HTMLInputElement>(null)
const [online, setOnline] = useState(navigator.onLine)
const [username] = useState(localStorage.getItem('active_username') || 'Skipper')
// Reactive sync queue count
const pendingCount = useLiveQuery(() => db.syncQueue.count()) || 0
const { pendingCount, showSpinner, showPendingWarning, connStatusClassName } = useSyncIndicator()
// Listen to connectivity changes
useEffect(() => {
@@ -47,6 +81,23 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
loadLogbooks()
}, [])
useEffect(() => {
const ids = logbooks.map((lb) => lb.id)
if (ids.length === 0) {
setSearchFieldsByLogbookId(new Map())
return
}
let cancelled = false
void loadLogbookSearchFieldsBatch(ids).then((index) => {
if (!cancelled) setSearchFieldsByLogbookId(index)
})
return () => {
cancelled = true
}
}, [logbooks])
const loadLogbooks = async (isRefresh = false) => {
if (isRefresh) setRefreshing(true)
else setLoading(true)
@@ -54,8 +105,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
try {
const data = await fetchLogbooks()
setLogbooks(data)
} catch (err: any) {
setError(err.message || 'Failed to load logbooks')
} catch (err: unknown) {
setError(getErrorMessage(err, t('errors.load_failed')))
} finally {
setLoading(false)
setRefreshing(false)
@@ -73,8 +124,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
setLogbooks((prev) => [created, ...prev])
setNewTitle('')
trackPlausibleEvent(PlausibleEvents.LOGBOOK_CREATED)
} catch (err: any) {
setError(err.message || 'Failed to create logbook')
} catch (err: unknown) {
setError(getErrorMessage(err, t('errors.save_failed')))
} finally {
setLoading(false)
}
@@ -90,39 +141,145 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
await deleteLogbook(id)
setLogbooks((prev) => prev.filter((lb) => lb.id !== id))
} catch (err: any) {
setError(err.message || 'Failed to delete logbook')
setError(getErrorMessage(err, t('errors.delete_failed')))
} finally {
setLoading(false)
}
}
}
useEffect(() => {
if (editingLogbookId) {
titleInputRef.current?.focus()
titleInputRef.current?.select()
}
}, [editingLogbookId])
const startTitleEdit = (lb: DecryptedLogbook, e: React.MouseEvent) => {
e.stopPropagation()
setEditingLogbookId(lb.id)
setEditingTitleDraft(lb.title)
}
const cancelTitleEdit = () => {
setEditingLogbookId(null)
setEditingTitleDraft('')
}
const commitTitleEdit = async (id: string) => {
if (editingLogbookId !== id) return
const lb = logbooks.find((item) => item.id === id)
const trimmedTitle = editingTitleDraft.trim()
cancelTitleEdit()
if (!lb || !trimmedTitle || trimmedTitle === lb.title.trim()) return
setLoading(true)
setError(null)
try {
await updateLogbookTitle(id, trimmedTitle)
setLogbooks((prev) =>
prev.map((item) =>
item.id === id ? { ...item, title: trimmedTitle, updatedAt: new Date().toISOString() } : item
)
)
} catch (err: any) {
setError(getErrorMessage(err, t('errors.save_failed')))
} finally {
setLoading(false)
}
}
const handleLogout = () => {
logoutUser()
void logoutUser()
onLogout()
}
const toggleLanguage = () => {
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
i18n.changeLanguage(nextLang)
cycleAppLanguage(i18n)
}
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
const sharedLogbooks = logbooks.filter((lb) => lb.isShared)
const renderLogbookCard = (lb: DecryptedLogbook) => (
const filterActive = filterQuery.trim().length > 0
const filteredOwnedLogbooks = useMemo(
() =>
ownedLogbooks.filter((lb) =>
logbookMatchesFilter(lb, filterQuery, i18n.language, searchFieldsByLogbookId.get(lb.id))
),
[ownedLogbooks, filterQuery, i18n.language, searchFieldsByLogbookId]
)
const filteredSharedLogbooks = useMemo(
() =>
sharedLogbooks.filter((lb) =>
logbookMatchesFilter(lb, filterQuery, i18n.language, searchFieldsByLogbookId.get(lb.id))
),
[sharedLogbooks, filterQuery, i18n.language, searchFieldsByLogbookId]
)
const sortedOwnedLogbooks = useMemo(
() => sortLogbooks(filteredOwnedLogbooks, sortBy, sortDirection, i18n.language),
[filteredOwnedLogbooks, sortBy, sortDirection, i18n.language]
)
const sortedSharedLogbooks = useMemo(
() => sortLogbooks(filteredSharedLogbooks, sortBy, sortDirection, i18n.language),
[filteredSharedLogbooks, sortBy, sortDirection, i18n.language]
)
const filteredLogbookCount = sortedOwnedLogbooks.length + sortedSharedLogbooks.length
const renderLogbookCard = (lb: DecryptedLogbook) => {
const isEditingTitle = editingLogbookId === lb.id
return (
<div
key={lb.id}
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}`}
onClick={() => onSelectLogbook(lb.id, lb.title)}
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}${isEditingTitle ? ' logbook-card--editing-title' : ''}`}
>
<div className="card-icon">
{!isEditingTitle && (
<button
type="button"
className="logbook-card-select"
onClick={() => onSelectLogbook(lb.id, lb.title)}
aria-label={t('dashboard.open_logbook', { title: lb.title })}
/>
)}
<div className="card-icon" aria-hidden>
<BookOpen size={24} />
</div>
<div className="card-info">
<div className="card-title-row">
<h3>{lb.title}</h3>
{isEditingTitle ? (
<input
ref={titleInputRef}
type="text"
className="logbook-title-inline-edit input-text"
value={editingTitleDraft}
onChange={(e) => setEditingTitleDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
void commitTitleEdit(lb.id)
} else if (e.key === 'Escape') {
e.preventDefault()
cancelTitleEdit()
}
}}
onBlur={() => void commitTitleEdit(lb.id)}
disabled={loading}
aria-label={t('dashboard.edit_title')}
/>
) : (
<h3
className={lb.isShared ? undefined : 'logbook-title-editable'}
onClick={lb.isShared ? undefined : (e) => startTitleEdit(lb, e)}
title={lb.isShared ? undefined : t('dashboard.edit_title')}
>
{lb.title}
</h3>
)}
<LogbookRoleBadge role={lb.accessRole} />
</div>
<div className="card-meta">
@@ -142,16 +299,22 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
</div>
</div>
<button
className="btn-delete"
onClick={(e) => handleDelete(lb.id, e)}
title={t('dashboard.delete_btn')}
style={{ visibility: lb.isShared ? 'hidden' : 'visible' }}
>
<Trash2 size={18} />
</button>
{!lb.isShared && (
<div className="logbook-card-actions">
<button
type="button"
className="btn-delete"
onClick={(e) => handleDelete(lb.id, e)}
title={t('dashboard.delete_btn')}
aria-label={t('dashboard.delete_btn')}
>
<Trash2 size={18} />
</button>
</div>
)}
</div>
)
)
}
const renderLogbookSection = (
title: string,
@@ -176,18 +339,37 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
<div className="header-brand">
<Ship className="header-logo" size={32} />
<div>
<h1>{t('app.name')}</h1>
<div className="header-brand-title-row">
<h1>{t('app.name')}</h1>
<BetaBadge />
</div>
<p className="subtitle">{t('app.tagline')}</p>
</div>
</div>
<div className="header-actions">
{/* Connection Indicator */}
<div className={`conn-status ${online ? (pendingCount > 0 ? 'unsynced' : 'online') : 'offline'}`} title={online ? (pendingCount > 0 ? 'Pending Sync' : 'Synced') : 'Offline'}>
<div
className={connStatusClassName(online)}
title={
online
? showSpinner
? 'Syncing'
: pendingCount > 0
? 'Pending Sync'
: 'Synced'
: 'Offline'
}
>
{online ? (
pendingCount > 0 ? (
showSpinner ? (
<>
<RefreshCw size={18} className="spin" />
<span>{t('sync.status_syncing')}</span>
</>
) : showPendingWarning ? (
<>
<RefreshCw size={18} />
<span>{t('sync.status_unsynced')} ({pendingCount})</span>
</>
) : (
@@ -204,11 +386,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
)}
</div>
{/* Skipper profile */}
<div className="skipper-badge">
<User size={16} />
<span>{username}</span>
</div>
<ProfileHeaderButton onClick={onOpenProfile} />
{/* Lang toggle */}
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
@@ -217,6 +395,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
<DisclaimerHeaderButton />
<FeedbackHeaderButton />
{/* Logout */}
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
<LogOut size={18} />
@@ -264,24 +444,118 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
) : logbooks.length === 0 ? (
<div className="dashboard-status-msg glass">{t('dashboard.no_logbooks')}</div>
) : (
<div className="logbook-sections">
{ownedLogbooks.length > 0 && renderLogbookSection(
sharedLogbooks.length > 0 ? t('dashboard.section_owned') : t('dashboard.title'),
ownedLogbooks
<>
<div className="dashboard-list-controls">
<div className="dashboard-filter-bar">
<label className="dashboard-filter-label" htmlFor="logbook-list-filter">
{t('dashboard.filter_label')}
</label>
<div className="dashboard-filter-input-wrap">
<Search size={18} className="dashboard-filter-icon" aria-hidden="true" />
<input
ref={filterInputRef}
id="logbook-list-filter"
type="search"
className="input-text dashboard-filter-input"
placeholder={t('dashboard.filter_placeholder')}
value={filterQuery}
onChange={(e) => setFilterQuery(e.target.value)}
autoComplete="off"
spellCheck={false}
aria-describedby={filterActive ? 'logbook-filter-status' : undefined}
/>
{filterActive && (
<button
type="button"
className="dashboard-filter-clear"
onClick={() => {
setFilterQuery('')
filterInputRef.current?.focus()
}}
title={t('dashboard.filter_clear')}
aria-label={t('dashboard.filter_clear')}
>
<X size={16} aria-hidden="true" />
</button>
)}
</div>
{filterActive && (
<p id="logbook-filter-status" className="dashboard-filter-meta" role="status">
{t('dashboard.filter_results', { count: filteredLogbookCount })}
</p>
)}
</div>
<div className="dashboard-sort-bar">
<span className="dashboard-sort-label">{t('dashboard.sort_label')}</span>
<div className="dashboard-sort-row">
<div className="dashboard-sort-group" role="group" aria-label={t('dashboard.sort_by_label')}>
<button
type="button"
className={`dashboard-sort-btn${sortBy === 'name' ? ' is-active' : ''}`}
onClick={() => setSortBy('name')}
aria-pressed={sortBy === 'name'}
aria-label={t('dashboard.sort_by_name')}
title={t('dashboard.sort_by_name')}
>
<CaseSensitive size={16} aria-hidden="true" />
</button>
<button
type="button"
className={`dashboard-sort-btn${sortBy === 'date' ? ' is-active' : ''}`}
onClick={() => setSortBy('date')}
aria-pressed={sortBy === 'date'}
aria-label={t('dashboard.sort_by_date')}
title={t('dashboard.sort_by_date')}
>
<CalendarDays size={16} aria-hidden="true" />
</button>
</div>
<div className="dashboard-sort-group" role="group" aria-label={t('dashboard.sort_dir_label')}>
<button
type="button"
className={`dashboard-sort-btn${sortDirection === 'asc' ? ' is-active' : ''}`}
onClick={() => setSortDirection('asc')}
aria-pressed={sortDirection === 'asc'}
aria-label={sortBy === 'name' ? t('dashboard.sort_name_asc') : t('dashboard.sort_date_asc')}
title={sortBy === 'name' ? t('dashboard.sort_name_asc') : t('dashboard.sort_date_asc')}
>
<ArrowUp size={16} aria-hidden="true" />
</button>
<button
type="button"
className={`dashboard-sort-btn${sortDirection === 'desc' ? ' is-active' : ''}`}
onClick={() => setSortDirection('desc')}
aria-pressed={sortDirection === 'desc'}
aria-label={sortBy === 'name' ? t('dashboard.sort_name_desc') : t('dashboard.sort_date_desc')}
title={sortBy === 'name' ? t('dashboard.sort_name_desc') : t('dashboard.sort_date_desc')}
>
<ArrowDown size={16} aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
{filterActive && filteredLogbookCount === 0 ? (
<div className="dashboard-status-msg glass">{t('dashboard.filter_no_results')}</div>
) : (
<div className="logbook-sections">
{sortedOwnedLogbooks.length > 0 && renderLogbookSection(
sortedSharedLogbooks.length > 0 ? t('dashboard.section_owned') : t('dashboard.title'),
sortedOwnedLogbooks
)}
{sortedSharedLogbooks.length > 0 && renderLogbookSection(
t('dashboard.section_shared'),
sortedSharedLogbooks,
t('dashboard.section_shared_hint')
)}
</div>
)}
{sharedLogbooks.length > 0 && renderLogbookSection(
t('dashboard.section_shared'),
sharedLogbooks,
t('dashboard.section_shared_hint')
)}
</div>
</>
)}
</section>
</main>
<section className="dashboard-account-section" aria-label={t('settings.danger_zone_title')}>
<AccountDangerZone />
</section>
</div>
)
}
@@ -0,0 +1,199 @@
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Ship, Save, Check } from 'lucide-react'
import type { LogbookVesselSelectionData, VesselData } from '../types/vessel.js'
import type { DecryptedVessel } from '../services/vesselPool.js'
import { loadVesselPool } from '../services/vesselPool.js'
import { loadLogbookVesselSelection, saveLogbookVesselSelectionFromId } from '../services/logbookVesselSelection.js'
import { resolveVesselForLogbook } from '../services/resolveVessel.js'
import { vesselDataFromSnapshot } from '../utils/vesselSnapshot.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
export interface LogbookVesselPickerProps {
logbookId: string
readOnly?: boolean
preloadedPool?: Array<{ payloadId: string; data: VesselData }>
preloadedSelection?: LogbookVesselSelectionData
selectionOnly?: boolean
onOpenProfile?: () => void
}
export default function LogbookVesselPicker({
logbookId,
readOnly = false,
preloadedPool,
preloadedSelection,
selectionOnly = false,
onOpenProfile
}: LogbookVesselPickerProps) {
const { t } = useTranslation()
const [loading, setLoading] = useState(!preloadedSelection)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | null>(null)
const [pool, setPool] = useState<DecryptedVessel[]>([])
const [activeVesselId, setActiveVesselId] = useState<string | null>(null)
const [resolvedVessel, setResolvedVessel] = useState<VesselData | null>(null)
const loadData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const selection =
preloadedSelection ??
(logbookId === 'demo' ? null : await loadLogbookVesselSelection(logbookId))
if (selection) {
setActiveVesselId(selection.activeVesselId)
}
if (preloadedPool) {
setPool(preloadedPool.map((p) => ({ payloadId: p.payloadId, data: p.data })))
} else if (selectionOnly && selection?.vesselSnapshot) {
const data = vesselDataFromSnapshot(selection.vesselSnapshot)
if (data) {
setPool([{ payloadId: selection.vesselSnapshot.id, data }])
}
} else {
setPool(await loadVesselPool())
}
const vessel = await resolveVesselForLogbook(logbookId, {
preloadedSelection: selection ?? undefined
})
setResolvedVessel(vessel)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to load vessel selection')
} finally {
setLoading(false)
}
}, [logbookId, preloadedPool, preloadedSelection, selectionOnly])
useEffect(() => {
void loadData()
}, [loadData])
const handleSave = async () => {
if (readOnly || logbookId === 'demo') return
setSaving(true)
setError(null)
setSaved(false)
try {
const selection = await saveLogbookVesselSelectionFromId(logbookId, activeVesselId)
const vessel = vesselDataFromSnapshot(selection.vesselSnapshot)
setResolvedVessel(vessel)
setSaved(true)
trackPlausibleEvent(PlausibleEvents.VESSEL_SAVED, { context: 'logbook_selection' })
setTimeout(() => setSaved(false), 3000)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to save')
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div className="tab-placeholder">
<Ship className="header-logo spin" size={48} />
<p>{t('vessel_pool.loading')}</p>
</div>
)
}
return (
<div className="crew-dashboard-layout" data-tour="logbook-vessel-picker">
<div className="form-card">
<div className="form-header">
<Ship size={24} className="form-icon" />
<h2>{t('logbook_vessel.title')}</h2>
</div>
<p className="help-text mb-4">{t('logbook_vessel.subtitle')}</p>
{selectionOnly && <p className="help-text mb-4">{t('logbook_vessel.selection_only_hint')}</p>}
{!selectionOnly && !readOnly && onOpenProfile && (
<p className="help-text mb-4">
<button type="button" className="btn-link" onClick={onOpenProfile}>
{t('logbook_vessel.manage_in_profile')}
</button>
</p>
)}
{error && <div className="auth-error mb-4">{error}</div>}
<div className="input-group mb-4">
<label>{t('logbook_vessel.active_vessel')}</label>
{pool.length === 0 ? (
<p className="help-text">{t('logbook_vessel.no_vessels_in_pool')}</p>
) : (
<div className="crew-selection-list">
{pool.map((v) => (
<label key={v.payloadId} className="crew-selection-item">
<input
type="radio"
name={`vessel-${logbookId}`}
checked={activeVesselId === v.payloadId}
onChange={() => !readOnly && setActiveVesselId(v.payloadId)}
disabled={readOnly}
/>
<Ship size={16} aria-hidden="true" />
<span>{v.data.name || t('logbook_vessel.unnamed')}</span>
</label>
))}
{!readOnly && (
<label className="crew-selection-item">
<input
type="radio"
name={`vessel-${logbookId}`}
checked={activeVesselId === null}
onChange={() => setActiveVesselId(null)}
/>
<span>{t('logbook_vessel.no_vessel')}</span>
</label>
)}
</div>
)}
</div>
{resolvedVessel && (
<div className="member-editor-card glass mb-4 logbook-vessel-summary">
<h3 className="mb-2">{resolvedVessel.name}</h3>
<dl className="profile-dl">
{resolvedVessel.homePort && (
<div className="profile-dl-row">
<dt>{t('vessel.port')}</dt>
<dd>{resolvedVessel.homePort}</dd>
</div>
)}
{resolvedVessel.registrationNumber && (
<div className="profile-dl-row">
<dt>{t('vessel.registration')}</dt>
<dd>{resolvedVessel.registrationNumber}</dd>
</div>
)}
{resolvedVessel.mmsi && (
<div className="profile-dl-row">
<dt>{t('vessel.mmsi')}</dt>
<dd>{resolvedVessel.mmsi}</dd>
</div>
)}
</dl>
</div>
)}
{!readOnly && logbookId !== 'demo' && (
<div className="form-actions">
{saved && (
<div className="success-toast">
<Check size={16} />
<span>{t('logbook_vessel.saved')}</span>
</div>
)}
<button type="button" className="btn primary" onClick={() => void handleSave()} disabled={saving}>
<Save size={18} />
{t('logbook_vessel.save')}
</button>
</div>
)}
</div>
</div>
)
}
+196
View File
@@ -0,0 +1,196 @@
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
export interface MetricRangeInputProps {
id?: string
label: string
value: string
onChange: (value: string) => void
disabled?: boolean
min?: number
max?: number
step?: number
discreteValues?: readonly number[]
parse: (value: string) => number | null
format: (numeric: number) => string
defaultNumeric: number
/** Shown next to the label (current value). */
formatDisplay: (numeric: number, unset: boolean) => string
numberMin?: number
numberMax?: number
numberStep?: number | 'any'
numberPlaceholder?: string
allowLegacyText?: boolean
hideNumberInput?: boolean
}
function clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n))
}
export default function MetricRangeInput({
id,
label,
value,
onChange,
disabled = false,
min,
max,
discreteValues,
parse,
format,
defaultNumeric,
formatDisplay,
numberMin,
numberMax,
numberStep = 'any',
numberPlaceholder,
allowLegacyText = false,
hideNumberInput = false
}: MetricRangeInputProps) {
const { t } = useTranslation()
const unsetLabel = t('logs.weather_slider_unset', { defaultValue: '—' })
const isLegacyText =
allowLegacyText && value.trim() !== '' && parse(value) === null
const emitNumeric = useCallback(
(numeric: number) => {
onChange(format(numeric))
},
[onChange, format]
)
const parsed = parse(value)
const unset = parsed === null
const sliderNumeric = unset ? defaultNumeric : parsed
const useDiscrete = discreteValues != null && discreteValues.length > 1
let sliderMin = 0
let sliderMax = 0
let sliderValue = 0
if (useDiscrete) {
sliderMin = 0
sliderMax = discreteValues.length - 1
if (unset) {
sliderValue = 0
} else {
let bestIdx = 0
let bestDiff = Math.abs(discreteValues[0] - sliderNumeric)
for (let i = 1; i < discreteValues.length; i++) {
const diff = Math.abs(discreteValues[i] - sliderNumeric)
if (diff < bestDiff) {
bestDiff = diff
bestIdx = i
}
}
sliderValue = bestIdx
}
} else if (min != null && max != null) {
sliderMin = min
sliderMax = max
sliderValue = clamp(sliderNumeric, min, max)
}
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const idx = Number(e.target.value)
if (useDiscrete && discreteValues) {
emitNumeric(discreteValues[clamp(idx, 0, discreteValues.length - 1)])
return
}
if (min != null && max != null) {
emitNumeric(Number(e.target.value))
}
}
const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value)
}
const handleNumberBlur = () => {
const next = parse(value)
if (next == null) {
if (!value.trim()) onChange('')
return
}
onChange(format(next))
}
const hintNumeric = useDiscrete && discreteValues
? discreteValues[sliderValue]
: sliderValue
const displayLabel = unset ? unsetLabel : formatDisplay(hintNumeric, false)
if (isLegacyText) {
return (
<div className="input-group metric-range-input metric-range-input--compact">
<div className="metric-range-header">
<label htmlFor={id}>{label}</label>
</div>
<input
id={id}
type="text"
className="input-text"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
placeholder={numberPlaceholder}
/>
</div>
)
}
const hasSlider = useDiscrete || (min != null && max != null)
return (
<div className="input-group metric-range-input metric-range-input--compact">
<div className="metric-range-header">
<label htmlFor={hideNumberInput ? undefined : id}>{label}</label>
{hasSlider && (
<span className="metric-range-value" aria-live="polite">
{displayLabel}
</span>
)}
</div>
{hasSlider && (
<div className="metric-range-control-row">
<input
type="range"
className="tank-liter-slider metric-range-slider"
min={sliderMin}
max={sliderMax}
step={1}
value={sliderValue}
onChange={handleSliderChange}
disabled={disabled}
aria-valuemin={sliderMin}
aria-valuemax={sliderMax}
aria-valuenow={sliderValue}
aria-label={label}
aria-valuetext={displayLabel}
/>
{!hideNumberInput && (
<input
id={id}
type="number"
className="input-text metric-range-number"
value={unset ? '' : value.replace(/\s*hPa\s*$/i, '').replace(/°\s*$/, '')}
onChange={handleNumberChange}
onBlur={handleNumberBlur}
disabled={disabled}
min={numberMin}
max={numberMax}
step={numberStep}
placeholder={numberPlaceholder}
inputMode="decimal"
aria-label={label}
/>
)}
</div>
)}
</div>
)
}
+187 -36
View File
@@ -1,8 +1,28 @@
import React, { createContext, useContext, useState, useRef } from 'react'
import React, {
createContext,
useContext,
useState,
useRef,
useCallback,
useMemo,
useEffect,
useId
} from 'react'
import { useTranslation } from 'react-i18next'
export type ConfirmLeaveChoice = 'stay' | 'save' | 'discard'
interface DialogContextType {
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
showConfirm: (message: string, title?: string, confirmText?: string, cancelText?: string) => Promise<boolean>
showConfirmLeave: (
message: string,
title?: string,
stayLabel?: string,
saveLabel?: string,
discardLabel?: string,
options?: { showSave?: boolean }
) => Promise<ConfirmLeaveChoice>
}
const DialogContext = createContext<DialogContextType | undefined>(undefined)
@@ -16,73 +36,204 @@ export function useDialog() {
}
export function DialogProvider({ children }: { children: React.ReactNode }) {
const { t } = useTranslation()
const titleId = useId()
const messageId = useId()
const confirmRef = useRef<HTMLButtonElement>(null)
const [isOpen, setIsOpen] = useState(false)
const [title, setTitle] = useState('')
const [message, setMessage] = useState('')
const [type, setType] = useState<'alert' | 'confirm'>('alert')
const [type, setType] = useState<'alert' | 'confirm' | 'confirm-leave'>('alert')
const [confirmLabel, setConfirmLabel] = useState('OK')
const [cancelLabel, setCancelLabel] = useState('Cancel')
const [saveLabel, setSaveLabel] = useState('')
const [discardLabel, setDiscardLabel] = useState('')
const [showSaveOption, setShowSaveOption] = useState(false)
const resolveRef = useRef<((val: any) => void) | null>(null)
const alertResolveRef = useRef<(() => void) | null>(null)
const confirmResolveRef = useRef<((val: boolean) => void) | null>(null)
const confirmLeaveResolveRef = useRef<((val: ConfirmLeaveChoice) => void) | null>(null)
const showAlert = (msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
const showAlert = useCallback((msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
setMessage(msg)
setTitle(headerTitle || '')
setType('alert')
setConfirmLabel(btnText || 'OK')
setConfirmLabel(btnText || t('dialog.ok'))
setIsOpen(true)
return new Promise<void>((resolve) => {
resolveRef.current = resolve
alertResolveRef.current = resolve
})
}
}, [t])
const showConfirm = (msg: string, headerTitle?: string, btnConfirm?: string, btnCancel?: string): Promise<boolean> => {
const showConfirm = useCallback((
msg: string,
headerTitle?: string,
btnConfirm?: string,
btnCancel?: string
): Promise<boolean> => {
setMessage(msg)
setTitle(headerTitle || '')
setType('confirm')
setConfirmLabel(btnConfirm || 'Yes')
setCancelLabel(btnCancel || 'No')
setConfirmLabel(btnConfirm || t('dialog.yes'))
setCancelLabel(btnCancel || t('dialog.no'))
setIsOpen(true)
return new Promise<boolean>((resolve) => {
resolveRef.current = resolve
confirmResolveRef.current = resolve
})
}
}, [t])
const handleConfirm = () => {
setIsOpen(false)
if (resolveRef.current) {
resolveRef.current(type === 'confirm' ? true : undefined)
resolveRef.current = null
}
}
const showConfirmLeave = useCallback((
msg: string,
headerTitle?: string,
btnStay?: string,
btnSave?: string,
btnDiscard?: string,
options?: { showSave?: boolean }
): Promise<ConfirmLeaveChoice> => {
setMessage(msg)
setTitle(headerTitle || '')
setType('confirm-leave')
setCancelLabel(btnStay || t('common.unsaved_changes_stay'))
setSaveLabel(btnSave || t('common.unsaved_changes_save_leave'))
setDiscardLabel(btnDiscard || t('common.unsaved_changes_discard'))
setShowSaveOption(options?.showSave !== false)
setIsOpen(true)
const handleCancel = () => {
return new Promise<ConfirmLeaveChoice>((resolve) => {
confirmLeaveResolveRef.current = resolve
})
}, [t])
const closeConfirmLeave = useCallback((choice: ConfirmLeaveChoice) => {
setIsOpen(false)
if (resolveRef.current) {
resolveRef.current(false)
resolveRef.current = null
if (confirmLeaveResolveRef.current) {
confirmLeaveResolveRef.current(choice)
confirmLeaveResolveRef.current = null
}
}
}, [])
const handleConfirm = useCallback(() => {
setIsOpen(false)
if (type === 'confirm' && confirmResolveRef.current) {
confirmResolveRef.current(true)
confirmResolveRef.current = null
} else if (alertResolveRef.current) {
alertResolveRef.current()
alertResolveRef.current = null
}
}, [type])
const handleCancel = useCallback(() => {
if (type === 'confirm-leave') {
closeConfirmLeave('stay')
return
}
setIsOpen(false)
if (confirmResolveRef.current) {
confirmResolveRef.current(false)
confirmResolveRef.current = null
}
}, [type, closeConfirmLeave])
useEffect(() => {
if (!isOpen) return
confirmRef.current?.focus()
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (type === 'confirm' || type === 'confirm-leave') handleCancel()
else handleConfirm()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [isOpen, type, handleCancel, handleConfirm])
const contextValue = useMemo(
() => ({ showAlert, showConfirm, showConfirmLeave }),
[showAlert, showConfirm, showConfirmLeave]
)
return (
<DialogContext.Provider value={{ showAlert, showConfirm }}>
<DialogContext.Provider value={contextValue}>
{children}
{isOpen && (
<div className="custom-dialog-overlay" onClick={type === 'alert' ? handleConfirm : undefined}>
<div className="custom-dialog-card glass scale-in" onClick={(e) => e.stopPropagation()}>
{title && <h3 className="custom-dialog-title">{title}</h3>}
<p className="custom-dialog-message">{message}</p>
<div
className="custom-dialog-overlay"
onClick={type === 'confirm' || type === 'confirm-leave' ? handleCancel : handleConfirm}
>
<div
className="custom-dialog-card glass scale-in"
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
aria-describedby={messageId}
onClick={(e) => e.stopPropagation()}
>
{title && (
<h3 id={titleId} className="custom-dialog-title">
{title}
</h3>
)}
<p id={messageId} className="custom-dialog-message">
{message}
</p>
<div className="custom-dialog-actions">
{type === 'confirm' && (
<button type="button" className="btn secondary" onClick={handleCancel} style={{ width: 'auto', padding: '8px 20px', margin: 0 }}>
{cancelLabel}
</button>
{type === 'confirm-leave' ? (
<>
<button
ref={confirmRef}
type="button"
className="btn secondary"
onClick={handleCancel}
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
>
{cancelLabel}
</button>
{showSaveOption && (
<button
type="button"
className="btn primary"
onClick={() => closeConfirmLeave('save')}
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
>
{saveLabel}
</button>
)}
<button
type="button"
className="btn danger"
onClick={() => closeConfirmLeave('discard')}
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
>
{discardLabel}
</button>
</>
) : (
<>
{type === 'confirm' && (
<button
type="button"
className="btn secondary"
onClick={handleCancel}
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
>
{cancelLabel}
</button>
)}
<button
ref={confirmRef}
type="button"
className="btn primary"
onClick={handleConfirm}
style={{ width: 'auto', minWidth: '80px', padding: '8px 20px', margin: 0 }}
>
{confirmLabel}
</button>
</>
)}
<button type="button" className="btn primary" onClick={handleConfirm} style={{ width: 'auto', minWidth: '80px', padding: '8px 20px', margin: 0 }}>
{confirmLabel}
</button>
</div>
</div>
</div>
+333
View File
@@ -0,0 +1,333 @@
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { FileText, X } from 'lucide-react'
import type { LogEventPayload } from '../utils/logEntryPayload.js'
import { sortLogEventsByTime } from '../utils/logEntryPayload.js'
import { parseNmeaFile, nmeaPointsToWaypoints } from '../services/nmea/nmeaParse.js'
import { filterPointsForDate } from '../services/nmea/nmeaTimeSeries.js'
import { generateNmeaJournalCandidates } from '../services/nmea/nmeaJournalGenerator.js'
import type { NmeaImportMode, NmeaParseResult } from '../services/nmea/nmeaTypes.js'
import { saveNmeaArchive, recordNmeaFileImport, type NmeaArchiveRecord } from '../services/nmeaArchive.js'
import { nmeaFileCrc32 } from '../utils/crc32.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import type { TrackWaypoint } from '../services/trackUpload.js'
interface NmeaImportWizardProps {
open: boolean
onClose: () => void
logbookId: string
entryId: string
entryDate: string
nmeaArchive: NmeaArchiveRecord | null
onImport: (events: LogEventPayload[], waypoints?: TrackWaypoint[]) => void
}
type WizardStep = 'config' | 'preview' | 'archive'
export default function NmeaImportWizard({
open,
onClose,
logbookId,
entryId,
entryDate,
nmeaArchive,
onImport
}: NmeaImportWizardProps) {
const { t } = useTranslation()
const [step, setStep] = useState<WizardStep>('config')
const [parseResult, setParseResult] = useState<NmeaParseResult | null>(null)
const [mode, setMode] = useState<NmeaImportMode>('both')
const [intervalMinutes, setIntervalMinutes] = useState(60)
const [importTrack, setImportTrack] = useState(true)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [error, setError] = useState<string | null>(null)
const [pendingRaw, setPendingRaw] = useState<{ filename: string; text: string } | null>(null)
const [duplicateFile, setDuplicateFile] = useState(false)
const filteredPoints = useMemo(() => {
if (!parseResult) return []
return filterPointsForDate(parseResult.points, entryDate)
}, [parseResult, entryDate])
const candidates = useMemo(() => {
if (!parseResult || filteredPoints.length === 0) return []
return generateNmeaJournalCandidates({
points: filteredPoints,
mode,
intervalMinutes,
t
}).candidates
}, [parseResult, filteredPoints, mode, intervalMinutes, t])
const reset = () => {
setStep('config')
setParseResult(null)
setMode('both')
setIntervalMinutes(60)
setImportTrack(true)
setSelectedIds(new Set())
setError(null)
setDuplicateFile(false)
setPendingRaw(null)
}
const handleClose = () => {
reset()
onClose()
}
const handleFile = (file: File) => {
setError(null)
setDuplicateFile(false)
const reader = new FileReader()
reader.onload = () => {
try {
const text = String(reader.result ?? '')
const crc32 = nmeaFileCrc32(text)
const alreadyImported = nmeaArchive?.importedFiles.some((item) => item.crc32 === crc32) ?? false
setDuplicateFile(alreadyImported)
const result = parseNmeaFile(text, file.name)
if (result.points.length === 0) {
setError(t('logs.nmea_error_no_samples'))
return
}
setParseResult(result)
setPendingRaw({ filename: file.name, text })
const generated = generateNmeaJournalCandidates({
points: filterPointsForDate(result.points, entryDate),
mode,
intervalMinutes,
t
}).candidates
setSelectedIds(new Set(generated.map((c) => c.id)))
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, {
duplicate: alreadyImported,
lines: result.stats.parsedLines,
candidates: generated.length,
has_position: !result.warnings.includes('no_position')
})
} catch (err) {
setError(err instanceof Error ? err.message : t('logs.nmea_error_parse'))
}
}
reader.onerror = () => setError(t('logs.nmea_error_read'))
reader.readAsText(file)
}
const toggleAll = (checked: boolean) => {
setSelectedIds(checked ? new Set(candidates.map((c) => c.id)) : new Set())
}
const toggleOne = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const goPreview = () => {
if (!parseResult) {
setError(t('logs.nmea_error_no_file'))
return
}
const generated = generateNmeaJournalCandidates({
points: filteredPoints,
mode,
intervalMinutes,
t
}).candidates
setSelectedIds(new Set(generated.map((c) => c.id)))
setStep('preview')
}
const applyImport = async () => {
const picked = candidates.filter((c) => selectedIds.has(c.id)).map((c) => c.event)
if (picked.length === 0) {
setError(t('logs.nmea_error_no_selection'))
return
}
const waypoints = importTrack ? nmeaPointsToWaypoints(filteredPoints) : undefined
onImport(sortLogEventsByTime(picked), waypoints)
if (pendingRaw) {
try {
await recordNmeaFileImport(logbookId, entryId, pendingRaw.filename, pendingRaw.text)
} catch (err) {
console.warn('NMEA import CRC record failed:', err)
}
}
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, {
mode,
events: picked.length,
track: importTrack && (waypoints?.length ?? 0) > 0
})
setStep('archive')
}
const finishArchive = async (archive: boolean) => {
try {
if (archive && pendingRaw) {
await saveNmeaArchive(logbookId, entryId, pendingRaw.filename, pendingRaw.text)
}
} catch (err) {
console.warn('NMEA archive save failed:', err)
}
handleClose()
}
useEffect(() => {
if (!open) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') handleClose()
}
window.addEventListener('keydown', onKeyDown)
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
window.removeEventListener('keydown', onKeyDown)
document.body.style.overflow = prevOverflow
}
}, [open])
if (!open) return null
return createPortal(
<div className="disclaimer-modal-overlay" onClick={handleClose}>
<div className="disclaimer-modal-panel" onClick={(e) => e.stopPropagation()}>
<div className="auth-card glass registration-disclaimer registration-disclaimer--modal feedback-modal">
<button
type="button"
className="registration-disclaimer__close feedback-modal__close"
onClick={handleClose}
aria-label={t('logs.nmea_cancel')}
>
<X size={18} />
</button>
<div className="auth-header">
<FileText className="auth-icon accent" size={40} />
<h2>{t('logs.nmea_import_title')}</h2>
</div>
{error && <div className="track-error-msg">{error}</div>}
{duplicateFile && (
<div className="nmea-import-warning" role="status">
{t('logs.nmea_warn_duplicate_file')}
</div>
)}
{step === 'config' && (
<>
<p className="registration-disclaimer__intro">{t('logs.nmea_import_intro')}</p>
<label className="feedback-form__field">
<span>{t('logs.nmea_file_label')}</span>
<input
type="file"
accept=".nmea,.log,.txt"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleFile(file)
}}
/>
</label>
{parseResult && (
<div className="nmea-import-summary">
<p>{t('logs.nmea_stats', {
lines: parseResult.stats.parsedLines,
types: parseResult.stats.sentenceTypes.join(', ')
})}</p>
{parseResult.warnings.includes('no_position') && (
<p>{t('logs.nmea_warn_no_position')}</p>
)}
</div>
)}
<fieldset className="nmea-import-mode">
<legend>{t('logs.nmea_mode_label')}</legend>
<label><input type="radio" name="nmea-mode" checked={mode === 'interval'} onChange={() => setMode('interval')} /> {t('logs.nmea_mode_interval')}</label>
<label><input type="radio" name="nmea-mode" checked={mode === 'change'} onChange={() => setMode('change')} /> {t('logs.nmea_mode_change')}</label>
<label><input type="radio" name="nmea-mode" checked={mode === 'both'} onChange={() => setMode('both')} /> {t('logs.nmea_mode_both')}</label>
</fieldset>
{(mode === 'interval' || mode === 'both') && (
<label className="feedback-form__field">
<span>{t('logs.nmea_interval_label')}</span>
<select value={intervalMinutes} onChange={(e) => setIntervalMinutes(Number(e.target.value))}>
<option value={30}>30 min</option>
<option value={60}>60 min</option>
<option value={90}>90 min</option>
<option value={120}>120 min</option>
</select>
</label>
)}
<label className="nmea-import-checkbox">
<input type="checkbox" checked={importTrack} onChange={(e) => setImportTrack(e.target.checked)} />
{t('logs.nmea_import_track')}
</label>
<div className="auth-actions feedback-form__actions">
<button type="button" className="btn secondary" onClick={handleClose}>{t('logs.nmea_cancel')}</button>
<button type="button" className="btn primary" onClick={goPreview} disabled={!parseResult}>
{t('logs.nmea_preview')}
</button>
</div>
</>
)}
{step === 'preview' && (
<>
<p>{t('logs.nmea_preview_hint', { count: candidates.length })}</p>
<div className="nmea-preview-actions">
<button type="button" className="btn secondary" onClick={() => toggleAll(true)}>{t('logs.nmea_select_all')}</button>
<button type="button" className="btn secondary" onClick={() => toggleAll(false)}>{t('logs.nmea_select_none')}</button>
</div>
<div className="nmea-preview-list">
{candidates.map((c) => (
<label key={c.id} className="nmea-preview-row">
<input
type="checkbox"
className="nmea-preview-row__check"
checked={selectedIds.has(c.id)}
onChange={() => toggleOne(c.id)}
/>
<div className="nmea-preview-row__body">
<div className="nmea-preview-row__meta">
<span className="nmea-preview-time">{c.event.time}</span>
<span className="nmea-preview-source">{t(`logs.nmea_source_${c.source}`)}</span>
</div>
<span className="nmea-preview-remarks">{c.event.remarks || c.event.mgk || '—'}</span>
</div>
</label>
))}
</div>
<div className="auth-actions feedback-form__actions">
<button type="button" className="btn secondary" onClick={() => setStep('config')}>{t('logs.nmea_back')}</button>
<button type="button" className="btn primary" onClick={applyImport}>{t('logs.nmea_apply')}</button>
</div>
</>
)}
{step === 'archive' && (
<>
<p>{t('logs.nmea_archive_question')}</p>
<div className="auth-actions feedback-form__actions">
<button type="button" className="btn secondary" onClick={() => finishArchive(false)}>
{t('logs.nmea_archive_discard')}
</button>
<button type="button" className="btn primary" onClick={() => finishArchive(true)}>
{t('logs.nmea_archive_keep')}
</button>
</div>
</>
)}
</div>
</div>
</div>,
document.body
)
}
+2 -3
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Fingerprint, Loader2, AlertTriangle } from 'lucide-react'
import type { PasskeySignature } from '../types/signatures.js'
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
interface PasskeySignButtonProps {
label: string
@@ -42,9 +43,7 @@ export default function PasskeySignButton({
}
}
const formattedDate = signature
? new Date(signature.signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB')
: ''
const formattedDate = signature ? formatAppDateTime(signature.signedAt, i18n.language) : ''
return (
<div className="passkey-sign-block">
+313
View File
@@ -0,0 +1,313 @@
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Users, User, Plus, Trash2, Edit2, X, Camera, Save } from 'lucide-react'
import { useDialog } from './ModalDialog.tsx'
import { resizeImageFile } from '../utils/resizeImageFile.js'
import type { PersonData, PersonRole } from '../types/person.js'
import { MAX_POOL_CREW_MEMBERS } from '../types/person.js'
import {
loadPersonPool,
savePerson,
deletePerson,
filterSkippers,
filterCrew,
type DecryptedPerson
} from '../services/personPool.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
const emptyPerson = (role: PersonRole): PersonData => ({
name: '',
address: '',
birthDate: '',
phone: '',
nationality: '',
passportNumber: '',
bloodType: '',
allergies: '',
diseases: '',
role,
photo: null
})
export default function PersonPoolForm() {
const { t } = useTranslation()
const { showConfirm } = useDialog()
const [people, setPeople] = useState<DecryptedPerson[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [formRole, setFormRole] = useState<PersonRole>('crew')
const [form, setForm] = useState<PersonData>(emptyPerson('crew'))
const [saving, setSaving] = useState(false)
const [photoError, setPhotoError] = useState<string | null>(null)
const fileRef = React.useRef<HTMLInputElement>(null)
const reload = useCallback(async () => {
setLoading(true)
setError(null)
try {
setPeople(await loadPersonPool())
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to load')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void reload()
}, [reload])
const openAdd = (role: PersonRole) => {
setEditingId(null)
setFormRole(role)
setForm(emptyPerson(role))
setPhotoError(null)
setShowForm(true)
}
const openEdit = (person: DecryptedPerson) => {
setEditingId(person.payloadId)
setFormRole(person.data.role)
setForm({ ...person.data })
setPhotoError(null)
setShowForm(true)
}
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.name.trim()) return
setSaving(true)
setError(null)
try {
const id = editingId ?? window.crypto.randomUUID()
await savePerson(id, { ...form, role: formRole }, !editingId)
setShowForm(false)
trackPlausibleEvent(PlausibleEvents.CREW_SAVED, { role: formRole, context: 'person_pool' })
await reload()
} catch (err: unknown) {
if (err instanceof Error && err.message === 'MAX_CREW') {
setError(t('crew.max_crew'))
} else {
setError(err instanceof Error ? err.message : 'Failed to save')
}
} finally {
setSaving(false)
}
}
const handleDelete = async (id: string) => {
if (
!(await showConfirm(
t('person_pool.delete_confirm'),
t('person_pool.title'),
t('logs.confirm_yes'),
t('logs.confirm_no')
))
) {
return
}
try {
await deletePerson(id)
await reload()
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to delete')
}
}
const skippers = filterSkippers(people)
const crewList = filterCrew(people)
if (loading) {
return (
<div className="tab-placeholder">
<Users className="header-logo spin" size={48} />
<p>{t('person_pool.loading')}</p>
</div>
)
}
const renderCard = (person: DecryptedPerson) => (
<div key={person.payloadId} className="crew-member-card glass">
<div className="crew-card-header">
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{person.data.photo ? (
<img src={person.data.photo} alt="" className="crew-card-avatar" />
) : (
<div className="crew-card-avatar-placeholder">
<User size={18} />
</div>
)}
<h4>{person.data.name}</h4>
</div>
<div className="card-actions">
<button type="button" className="btn-icon" onClick={() => openEdit(person)} title="Edit">
<Edit2 size={14} />
</button>
<button
type="button"
className="btn-icon logout"
onClick={() => void handleDelete(person.payloadId)}
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
</div>
{person.data.phone && (
<p className="help-text">
<strong>{t('crew.phone')}:</strong> {person.data.phone}
</p>
)}
</div>
)
return (
<section className="form-card" data-tour="profile-crew-pool">
<div className="form-header">
<Users size={24} className="form-icon" />
<h2>{t('person_pool.title')}</h2>
</div>
<p className="help-text mb-4">{t('person_pool.subtitle')}</p>
{error && <div className="auth-error mb-4">{error}</div>}
<div className="section-title-bar mb-4">
<h3>{t('person_pool.skippers_section')}</h3>
{!showForm && (
<button type="button" className="btn primary" style={{ width: 'auto', padding: '8px 16px' }} onClick={() => openAdd('skipper')}>
<Plus size={16} />
{t('person_pool.add_skipper')}
</button>
)}
</div>
{skippers.length === 0 ? (
<p className="help-text mb-4">{t('person_pool.no_skippers')}</p>
) : (
<div className="crew-grid mb-6">{skippers.map(renderCard)}</div>
)}
<div className="section-title-bar mb-4">
<h3>{t('person_pool.crew_section')}</h3>
{!showForm && crewList.length < MAX_POOL_CREW_MEMBERS && (
<button type="button" className="btn primary" style={{ width: 'auto', padding: '8px 16px' }} onClick={() => openAdd('crew')}>
<Plus size={16} />
{t('person_pool.add_crew')}
</button>
)}
</div>
{crewList.length === 0 ? (
<p className="help-text">{t('person_pool.no_crew')}</p>
) : (
<div className="crew-grid">{crewList.map(renderCard)}</div>
)}
{showForm && (
<form onSubmit={(e) => void handleSave(e)} className="member-editor-card glass mt-6">
<div className="editor-header mb-4">
<h3>
{editingId
? formRole === 'skipper'
? t('person_pool.edit_skipper')
: t('crew.edit_crew')
: formRole === 'skipper'
? t('person_pool.add_skipper')
: t('crew.add_crew')}
</h3>
<button type="button" className="btn-icon" onClick={() => setShowForm(false)}>
<X size={16} />
</button>
</div>
<div className="form-grid">
<div className="vessel-photo-wrapper">
<div className="vessel-photo-preview" onClick={() => fileRef.current?.click()}>
{form.photo ? (
<img src={form.photo} alt="" className="vessel-photo" />
) : (
<div className="vessel-photo-placeholder">
<User size={48} />
</div>
)}
<div className="vessel-photo-overlay">
<Camera size={24} />
</div>
</div>
<input
ref={fileRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0]
if (!file) return
void resizeImageFile(file)
.then((photo) => setForm((f) => ({ ...f, photo })))
.catch((err: unknown) => {
setPhotoError(err instanceof Error ? err.message : 'Image error')
})
}}
/>
{photoError && <div className="auth-error mt-2">{photoError}</div>}
</div>
<div className="input-group">
<label>{t('crew.name')} *</label>
<input
className="input-text"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
required
/>
</div>
<div className="input-group">
<label>{t('crew.address')}</label>
<input
className="input-text"
value={form.address}
onChange={(e) => setForm((f) => ({ ...f, address: e.target.value }))}
/>
</div>
<div className="input-group">
<label>{t('crew.birthdate')}</label>
<input
type="date"
className="input-text"
value={form.birthDate}
onChange={(e) => setForm((f) => ({ ...f, birthDate: e.target.value }))}
/>
</div>
<div className="input-group">
<label>{t('crew.phone')}</label>
<input
className="input-text"
value={form.phone}
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
/>
</div>
<div className="input-group">
<label>{t('crew.nationality')}</label>
<input
className="input-text"
value={form.nationality}
onChange={(e) => setForm((f) => ({ ...f, nationality: e.target.value }))}
/>
</div>
<div className="input-group">
<label>{t('crew.passport')}</label>
<input
className="input-text"
value={form.passportNumber}
onChange={(e) => setForm((f) => ({ ...f, passportNumber: e.target.value }))}
/>
</div>
</div>
<div className="editor-actions mt-4">
<button type="submit" className="btn primary" disabled={saving || !form.name.trim()}>
<Save size={18} />
{t('crew.save_member')}
</button>
</div>
</form>
)}
</section>
)
}
+21 -101
View File
@@ -3,9 +3,9 @@ import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { decryptJson } from '../services/crypto.js'
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
import { useLiveQuery } from 'dexie-react-hooks'
import { useDialog } from './ModalDialog.tsx'
import { Camera, Trash2 } from 'lucide-react'
@@ -90,109 +90,30 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
setUploading(true)
setError(null)
const reader = new FileReader()
reader.onload = (event) => {
const img = new Image()
img.onload = async () => {
try {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get canvas context')
let width = img.width
let height = img.height
const MAX_WIDTH = 1280
const MAX_HEIGHT = 720
// Calculate resizing conserving aspect ratio
if (width > MAX_WIDTH || height > MAX_HEIGHT) {
const ratio = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height)
width = Math.round(width * ratio)
height = Math.round(height * ratio)
}
canvas.width = width
canvas.height = height
ctx.drawImage(img, 0, 0, width, height)
// Compress to JPEG, 70% quality
const compressedBase64 = canvas.toDataURL('image/jpeg', 0.7)
// Encrypt
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const photoId = window.crypto.randomUUID()
const photoPayload = {
image: compressedBase64,
caption: caption.trim()
}
const encrypted = await encryptJson(photoPayload, masterKey)
const now = new Date().toISOString()
// Store locally
await db.photos.put({
payloadId: photoId,
entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
caption: '', // stored encrypted inside payload
updatedAt: now
})
// Queue for background sync
await db.syncQueue.put({
action: 'create',
type: 'photo',
payloadId: photoId,
logbookId,
data: JSON.stringify({
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
entryId
}),
updatedAt: now
})
setCaption('')
if (fileInputRef.current) fileInputRef.current.value = ''
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'logbook' })
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
console.error('Failed to process image:', err)
setError(err.message || 'Failed to process image')
} finally {
setUploading(false)
}
}
img.src = event.target?.result as string
try {
const compressedBase64 = await fileToCompressedJpegDataUrl(file)
await saveEntryPhoto({
logbookId,
entryId,
imageDataUrl: compressedBase64,
caption: caption.trim(),
analyticsContext: 'logbook'
})
setCaption('')
if (fileInputRef.current) fileInputRef.current.value = ''
} catch (err: unknown) {
console.error('Failed to process image:', err)
setError(err instanceof Error ? err.message : 'Failed to process image')
} finally {
setUploading(false)
}
reader.readAsDataURL(file)
}
const handleDelete = async (photoId: string) => {
if (await showConfirm(t('logs.photo_delete_confirm'), t('logs.photos_title'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
try {
const now = new Date().toISOString()
await db.photos.delete(photoId)
await db.syncQueue.put({
action: 'delete',
type: 'photo',
payloadId: photoId,
logbookId,
data: '',
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
await deleteEntryPhoto(logbookId, photoId)
} catch (err: unknown) {
console.error('Failed to delete photo:', err)
}
}
@@ -233,7 +154,6 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
<input
type="file"
accept="image/*"
capture="environment"
ref={fileInputRef}
onChange={handleFileChange}
style={{ display: 'none' }}
@@ -0,0 +1,36 @@
import { ChevronDown } from 'lucide-react'
import type { ReactNode } from 'react'
interface ProfileAccordionSectionProps {
id: string
title: string
icon?: ReactNode
defaultOpen?: boolean
/** When set, forces the section open (e.g. during onboarding tour). */
forceOpen?: boolean
children: ReactNode
}
export default function ProfileAccordionSection({
id,
title,
icon,
defaultOpen = false,
forceOpen,
children
}: ProfileAccordionSectionProps) {
const isOpen = forceOpen !== undefined ? forceOpen : defaultOpen
return (
<details className="profile-accordion" open={isOpen || undefined} data-section={id}>
<summary className="profile-accordion__summary">
<span className="profile-accordion__title">
{icon}
<span>{title}</span>
</span>
<ChevronDown size={20} className="profile-accordion__chevron" aria-hidden="true" />
</summary>
<div className="profile-accordion__body">{children}</div>
</details>
)
}
@@ -0,0 +1,25 @@
import { useTranslation } from 'react-i18next'
import { User } from 'lucide-react'
interface ProfileHeaderButtonProps {
onClick: () => void
}
export default function ProfileHeaderButton({ onClick }: ProfileHeaderButtonProps) {
const { t } = useTranslation()
const username = localStorage.getItem('active_username') || 'Skipper'
return (
<button
type="button"
className="btn-icon skipper-badge"
onClick={onClick}
title={t('dashboard.open_profile', { name: username })}
aria-label={t('dashboard.open_profile', { name: username })}
data-tour="nav-profile"
>
<User size={18} aria-hidden="true" />
<span className="skipper-badge__name">{username}</span>
</button>
)
}
@@ -0,0 +1,137 @@
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Bell, BellOff } from 'lucide-react'
import {
disableCollaboratorChangePush,
enableCollaboratorChangePush,
fetchPushPrefs,
getNotificationPermission,
isPushSupported,
preloadPushService
} from '../services/pushNotifications.js'
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { useDialog } from './ModalDialog.tsx'
export default function PushNotificationSettings() {
const { t } = useTranslation()
const { showAlert } = useDialog()
const [enabled, setEnabled] = useState(false)
const [loading, setLoading] = useState(true)
const [toggling, setToggling] = useState(false)
const supported = isPushSupported()
const permission = getNotificationPermission()
const iosNeedsInstall = isIosDevice() && !isRunningStandalone()
const loadPrefs = useCallback(async () => {
if (!supported) {
setLoading(false)
return
}
void preloadPushService()
try {
const prefs = await fetchPushPrefs()
setEnabled(prefs.collaboratorChangesEnabled)
} catch (err) {
console.error('Failed to load push prefs:', err)
} finally {
setLoading(false)
}
}, [supported])
useEffect(() => {
void loadPrefs()
}, [loadPrefs])
const handleToggle = async (e: React.ChangeEvent<HTMLInputElement>) => {
const next = e.target.checked
setToggling(true)
try {
if (next) {
await enableCollaboratorChangePush()
setEnabled(true)
trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED)
} else {
await disableCollaboratorChangePush()
setEnabled(false)
trackPlausibleEvent(PlausibleEvents.PUSH_DISABLED)
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('profile.push_error')
showAlert(message)
void loadPrefs()
} finally {
setToggling(false)
}
}
if (!supported) {
return (
<div className="member-editor-card glass mt-4">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<BellOff size={20} style={{ color: '#94a3b8' }} />
<h3 style={{ margin: 0, color: '#94a3b8', fontSize: '16px' }}>{t('profile.push_title')}</h3>
</div>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: 0 }}>
{t('profile.push_unsupported')}
</p>
</div>
)
}
return (
<div className="member-editor-card glass mt-4">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<Bell size={20} style={{ color: 'var(--app-accent-light)' }} />
<h3 style={{ margin: 0, color: 'var(--app-accent-light)', fontSize: '16px' }}>
{t('profile.push_title')}
</h3>
</div>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('profile.push_desc')}
</p>
{iosNeedsInstall && (
<p className="text-muted" style={{ fontSize: '13px', margin: '0 0 12px 0' }}>
{t('profile.push_ios_install_hint')}
</p>
)}
{permission === 'denied' && (
<p style={{ fontSize: '13px', color: '#f87171', margin: '0 0 12px 0' }}>
{t('profile.push_denied_hint')}
</p>
)}
<label
className="switch-label"
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
cursor: loading || toggling || iosNeedsInstall ? 'not-allowed' : 'pointer',
fontSize: '14px',
color: '#f1f5f9',
opacity: loading || iosNeedsInstall ? 0.6 : 1
}}
>
<input
type="checkbox"
checked={enabled}
onChange={handleToggle}
disabled={loading || toggling || iosNeedsInstall}
style={{ width: '18px', height: '18px', cursor: 'inherit' }}
/>
<span>{t('profile.push_enable')}</span>
</label>
{enabled && permission === 'granted' && (
<p className="text-muted" style={{ fontSize: '12px', margin: '12px 0 0 0' }}>
{t('profile.push_active')}
</p>
)}
</div>
)
}
+91 -25
View File
@@ -1,8 +1,16 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage, isGermanLocale } from '../utils/i18nLanguages.js'
import { decryptJson } from '../services/crypto.js'
import VesselForm from './VesselForm.tsx'
import CrewForm from './CrewForm.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
import type { LogbookVesselSelectionData } from '../types/vessel.js'
import { emptyLogbookVesselSelection } from '../types/vessel.js'
import type { LogbookCrewSelectionData } from '../types/person.js'
import { emptyLogbookCrewSelection } from '../types/person.js'
import { legacyCrewRecordsToLogbookSelection } from '../utils/personSnapshots.js'
import type { PersonData } from '../types/person.js'
import LogEntriesList from './LogEntriesList.tsx'
import { Ship, Users, FileText, Lock, AlertCircle, Globe } from 'lucide-react'
@@ -29,7 +37,13 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
// Logbook data states
const [logbookTitle, setLogbookTitle] = useState('Logbook')
const [yacht, setYacht] = useState<any>(null)
const [crews, setCrews] = useState<any[]>([])
const [logbookCrewSelection, setLogbookCrewSelection] = useState<LogbookCrewSelectionData>(
emptyLogbookCrewSelection()
)
const [logbookVesselSelection, setLogbookVesselSelection] = useState<LogbookVesselSelectionData>(
emptyLogbookVesselSelection()
)
const [legacyCrews, setLegacyCrews] = useState<any[]>([])
const [entries, setEntries] = useState<any[]>([])
const [photos, setPhotos] = useState<any[]>([])
const [gpsTracks, setGpsTracks] = useState<any[]>([])
@@ -47,9 +61,9 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
const res = await fetch(`/api/collaboration/share-pull?token=${token}`)
if (!res.ok) {
if (res.status === 410) {
throw new Error(i18n.language.startsWith('de') ? 'Dieser Freigabelink ist abgelaufen.' : 'This share link has expired.')
throw new Error(isGermanLocale(i18n.language) ? 'Dieser Freigabelink ist abgelaufen.' : 'This share link has expired.')
}
throw new Error(i18n.language.startsWith('de') ? 'Fehler beim Laden des freigegebenen Logbuchs.' : 'Failed to fetch shared logbook data.')
throw new Error(isGermanLocale(i18n.language) ? 'Fehler beim Laden des freigegebenen Logbuchs.' : 'Failed to fetch shared logbook data.')
}
const data = await res.json()
@@ -69,18 +83,67 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
}
setYacht(decYacht)
// Decrypt Crews
const decCrews = []
if (data.crews) {
for (const c of data.crews) {
const dec = await decryptJson(c.encryptedData, c.iv, c.tag, keyBuffer)
decCrews.push({
payloadId: c.payloadId,
data: dec
if (data.logbookCrewSelection) {
const decSel = await decryptJson(
data.logbookCrewSelection.encryptedData,
data.logbookCrewSelection.iv,
data.logbookCrewSelection.tag,
keyBuffer
)
if (decSel) {
setLogbookCrewSelection({
activeSkipperId: decSel.activeSkipperId ?? null,
activeCrewIds: Array.isArray(decSel.activeCrewIds) ? decSel.activeCrewIds : [],
snapshotsById:
decSel.snapshotsById && typeof decSel.snapshotsById === 'object'
? decSel.snapshotsById
: {}
})
}
}
setCrews(decCrews)
if (data.logbookVesselSelection) {
const decVessel = await decryptJson(
data.logbookVesselSelection.encryptedData,
data.logbookVesselSelection.iv,
data.logbookVesselSelection.tag,
keyBuffer
)
if (decVessel) {
setLogbookVesselSelection({
activeVesselId: decVessel.activeVesselId ?? null,
vesselSnapshot: decVessel.vesselSnapshot ?? null
})
}
} else if (decYacht) {
const legacy = decYacht as Record<string, unknown>
setLogbookVesselSelection({
activeVesselId: 'legacy-yacht',
vesselSnapshot: {
id: 'legacy-yacht',
name: typeof legacy.name === 'string' ? legacy.name : '',
...legacy
} as import('../types/vessel.js').VesselSnapshot
})
}
const decCrews: Array<{ payloadId: string; data: PersonData }> = []
if (data.crews) {
for (const c of data.crews) {
const dec = await decryptJson(c.encryptedData, c.iv, c.tag, keyBuffer)
if (dec) {
decCrews.push({
payloadId: c.payloadId,
data: dec as PersonData
})
}
}
}
setLegacyCrews(decCrews)
if (!data.logbookCrewSelection && decCrews.length > 0) {
setLogbookCrewSelection(legacyCrewRecordsToLogbookSelection(decCrews))
}
// Decrypt Entries
const decEntries = []
@@ -124,6 +187,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
}
}
setGpsTracks(decGpsTracks)
trackPlausibleEvent(PlausibleEvents.PUBLIC_LINK_OPENED)
} catch (err: any) {
console.error(err)
@@ -134,15 +198,14 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
}
const toggleLanguage = () => {
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
i18n.changeLanguage(nextLang)
cycleAppLanguage(i18n)
}
if (loading) {
return (
<div className="tab-placeholder" style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }}>
<Ship className="header-logo spin" size={48} />
<p>{i18n.language.startsWith('de') ? 'Lade freigegebenes Logbuch...' : 'Loading shared logbook...'}</p>
<p>{isGermanLocale(i18n.language) ? 'Lade freigegebenes Logbuch...' : 'Loading shared logbook...'}</p>
</div>
)
}
@@ -151,10 +214,10 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
return (
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', padding: '20px', textAlign: 'center' }}>
<AlertCircle size={48} style={{ color: '#ef4444', marginBottom: '16px' }} />
<h2 style={{ color: '#f1f5f9', marginBottom: '8px' }}>{i18n.language.startsWith('de') ? 'Verbindungsfehler' : 'Access Error'}</h2>
<h2 style={{ color: '#f1f5f9', marginBottom: '8px' }}>{isGermanLocale(i18n.language) ? 'Verbindungsfehler' : 'Access Error'}</h2>
<p style={{ color: '#94a3b8', maxWidth: '400px', marginBottom: '24px' }}>{error}</p>
<button className="btn primary" onClick={loadData} style={{ width: 'auto' }}>
{i18n.language.startsWith('de') ? 'Erneut versuchen' : 'Retry'}
{isGermanLocale(i18n.language) ? 'Erneut versuchen' : 'Retry'}
</button>
</div>
)
@@ -171,7 +234,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
<h2>{logbookTitle}</h2>
<p className="app-subtitle" style={{ color: '#10b981', display: 'flex', alignItems: 'center', gap: '4px' }}>
<Lock size={12} />
<span>{i18n.language.startsWith('de') ? 'Schreibgeschützte Ansicht (Ende-zu-Ende verschlüsselt)' : 'Read-Only View (End-to-End Encrypted)'}</span>
<span>{isGermanLocale(i18n.language) ? 'Schreibgeschützte Ansicht (Ende-zu-Ende verschlüsselt)' : 'Read-Only View (End-to-End Encrypted)'}</span>
</p>
</div>
</div>
@@ -179,7 +242,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
<div className="header-actions">
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}>
<Globe size={14} style={{ marginRight: '4px' }} />
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
{t(`languages.${getNextLanguage(i18n.language)}`)}
</button>
</div>
</header>
@@ -224,18 +287,21 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
)}
{activeTab === 'vessel' && (
<VesselForm
<LogbookVesselPicker
logbookId="shared"
readOnly={true}
preloadedData={yacht}
selectionOnly={true}
preloadedSelection={logbookVesselSelection}
/>
)}
{activeTab === 'crew' && (
<CrewForm
<LogbookCrewPicker
logbookId="shared"
readOnly={true}
preloadedData={crews}
selectionOnly={true}
preloadedPool={legacyCrews.length > 0 ? legacyCrews : undefined}
preloadedSelection={logbookCrewSelection}
/>
)}
</main>
@@ -28,19 +28,19 @@ export default function RegistrationDisclaimer({
className={`auth-card glass registration-disclaimer${variant === 'view' ? ' registration-disclaimer--modal' : ''}`}
role="document"
>
{variant === 'view' && (
<button
type="button"
className="registration-disclaimer__close"
onClick={onDismiss}
aria-label={t('disclaimer.close')}
>
<X size={18} />
</button>
)}
<div className="auth-header">
<ScrollText className="auth-icon accent" size={48} />
<h2>{t('disclaimer.title')}</h2>
{variant === 'view' && (
<button
type="button"
className="registration-disclaimer__close"
onClick={onDismiss}
aria-label={t('disclaimer.close')}
>
<X size={18} />
</button>
)}
</div>
<p className="registration-disclaimer__intro">{t('disclaimer.intro')}</p>
+130 -244
View File
@@ -1,15 +1,19 @@
import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon, Compass } from 'lucide-react'
import { Settings as SettingsIcon, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
import { ensureLogbookKey } from '../services/logbookKeys.js'
import LogbookBackupPanel from './LogbookBackupPanel.tsx'
import AccountDangerZone from './AccountDangerZone.tsx'
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
import LinkQrCode from './LinkQrCode.tsx'
import { useDialog } from './ModalDialog.tsx'
import { notifyAppearanceChanged } from '../services/appearance.js'
import ThemedSelect from './ThemedSelect.tsx'
import { useAppTour } from '../context/AppTourContext.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { apiFetch } from '../services/api.js'
import {
enableCollaboratorChangePush,
isCollaboratorPushActive,
isPushSupported,
preloadPushService
} from '../services/pushNotifications.js'
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
interface SettingsFormProps {
logbookId?: string | null
@@ -24,7 +28,6 @@ interface Collaborator {
createdAt: string
}
// Convert ArrayBuffer to Hex String for URL fragment
const bufferToHex = (buffer: ArrayBuffer): string => {
return Array.from(new Uint8Array(buffer))
.map(b => b.toString(16).padStart(2, '0'))
@@ -34,14 +37,7 @@ const bufferToHex = (buffer: ArrayBuffer): string => {
export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsFormProps) {
const { t } = useTranslation()
const { showConfirm, showAlert } = useDialog()
const { restartTour } = useAppTour()
const [apiKey, setApiKey] = useState(localStorage.getItem('owm_api_key') || '')
const [theme, setTheme] = useState(localStorage.getItem('active_theme') || 'auto')
const [colorScheme, setColorScheme] = useState(localStorage.getItem('active_color_scheme') || 'auto')
const [saving, setSaving] = useState(false)
const [success, setSuccess] = useState(false)
// Collaboration States
const [collaborators, setCollaborators] = useState<Collaborator[]>([])
const [isOwner, setIsOwner] = useState(true)
const [inviteLink, setInviteLink] = useState('')
@@ -50,7 +46,6 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
const [collabError, setCollabError] = useState<string | null>(null)
const [loadingCollabs, setLoadingCollabs] = useState(false)
// Public Share Link States
const [shareEnabled, setShareEnabled] = useState(false)
const [shareLink, setShareLink] = useState('')
const [shareCopied, setShareCopied] = useState(false)
@@ -61,20 +56,16 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
loadCollaborators()
loadShareLink()
}
void preloadPushService()
}, [logbookId])
const loadShareLink = async () => {
if (!logbookId) return
setLoadingShareLink(true)
const userId = localStorage.getItem('active_userid')
if (!userId) return
if (!localStorage.getItem('active_userid')) return
try {
const res = await fetch(`/api/collaboration/share-link?logbookId=${logbookId}`, {
headers: {
'X-User-Id': userId
}
})
const res = await apiFetch(`/api/collaboration/share-link?logbookId=${logbookId}`)
if (res.ok) {
const data = await res.json()
@@ -98,17 +89,12 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
const handleToggleShare = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!logbookId) return
const checked = e.target.checked
const userId = localStorage.getItem('active_userid')
if (!userId) return
if (!localStorage.getItem('active_userid')) return
setLoadingShareLink(true)
try {
const res = await fetch('/api/collaboration/share-link', {
const res = await apiFetch('/api/collaboration/share-link', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify({ logbookId, enabled: checked })
})
@@ -119,6 +105,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
const logbookKey = await ensureLogbookKey(logbookId)
const hexKey = bufferToHex(logbookKey)
setShareLink(`${window.location.origin}/share?token=${data.token}#key=${hexKey}`)
trackPlausibleEvent(PlausibleEvents.LOGBOOK_SHARED)
showAlert('Public share link enabled!')
} else {
setShareEnabled(false)
@@ -128,9 +115,9 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
} else {
throw new Error('Failed to toggle public share link.')
}
} catch (err: any) {
} catch (err: unknown) {
console.error('Toggle share link failed:', err)
showAlert(err.message || 'Failed to update public share link.')
showAlert(err instanceof Error ? err.message : 'Failed to update public share link.')
} finally {
setLoadingShareLink(false)
}
@@ -144,19 +131,13 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
}
}
const loadCollaborators = async () => {
setLoadingCollabs(true)
setCollabError(null)
const userId = localStorage.getItem('active_userid')
if (!userId) return
if (!localStorage.getItem('active_userid')) return
try {
const res = await fetch(`/api/collaboration/collaborators?logbookId=${logbookId}`, {
headers: {
'X-User-Id': userId
}
})
const res = await apiFetch(`/api/collaboration/collaborators?logbookId=${logbookId}`)
if (res.status === 403) {
setIsOwner(false)
@@ -179,24 +160,54 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
}
}
const promptPushAfterInviteCreated = async () => {
if (!isPushSupported()) return
if (await isCollaboratorPushActive()) return
const iosNeedsInstall = isIosDevice() && !isRunningStandalone()
if (iosNeedsInstall) {
await showAlert(
t('settings.invite_push_prompt_ios_message'),
t('settings.invite_push_prompt_title'),
t('settings.invite_push_prompt_later')
)
return
}
const enable = await showConfirm(
t('settings.invite_push_prompt_message'),
t('settings.invite_push_prompt_title'),
t('settings.invite_push_prompt_enable'),
t('settings.invite_push_prompt_later')
)
if (!enable) return
try {
await enableCollaboratorChangePush()
await showAlert(
t('settings.invite_push_prompt_success'),
t('settings.invite_push_prompt_title')
)
trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED)
} catch (err: unknown) {
console.error('Failed to enable push after invite:', err)
await showAlert(err instanceof Error ? err.message : t('profile.push_error'))
}
}
const handleGenerateInvite = async () => {
if (!logbookId) return
setGeneratingInvite(true)
setInviteLink('')
const userId = localStorage.getItem('active_userid')
if (!userId) return
if (!localStorage.getItem('active_userid')) return
try {
// 1. Ensure logbook has an E2E key (upgrades legacy logbooks if needed)
const logbookKey = await ensureLogbookKey(logbookId)
// 2. Create invite token on server
const res = await fetch('/api/collaboration/invite', {
const res = await apiFetch('/api/collaboration/invite', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify({ logbookId, role: 'WRITE' })
})
@@ -205,16 +216,15 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
}
const invite = await res.json()
// 3. Format link containing token (URL params) and key (URL hash anchor)
const hexKey = bufferToHex(logbookKey)
const link = `${window.location.origin}/invite?token=${invite.token}#key=${hexKey}`
setInviteLink(link)
trackPlausibleEvent(PlausibleEvents.INVITE_GENERATED)
} catch (err: any) {
await promptPushAfterInviteCreated()
} catch (err: unknown) {
console.error('Failed to generate invite:', err)
showAlert(err.message || 'Failed to generate invite link.')
showAlert(err instanceof Error ? err.message : 'Failed to generate invite link.')
} finally {
setGeneratingInvite(false)
}
@@ -229,16 +239,12 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
}
const handleRevoke = async (collabId: string, collName: string) => {
const userId = localStorage.getItem('active_userid')
if (!userId) return
if (!localStorage.getItem('active_userid')) return
if (await showConfirm(t('logs.revoke_confirm'), collName, t('logs.confirm_yes'), t('logs.confirm_no'))) {
try {
const res = await fetch(`/api/collaboration/collaborators/${collabId}`, {
method: 'DELETE',
headers: {
'X-User-Id': userId
}
const res = await apiFetch(`/api/collaboration/collaborators/${collabId}`, {
method: 'DELETE'
})
if (res.ok) {
@@ -247,40 +253,26 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
} else {
throw new Error('Failed to revoke collaborator access.')
}
} catch (err: any) {
} catch (err: unknown) {
console.error('Revocation failed:', err)
showAlert(err.message || 'Failed to revoke access.')
showAlert(err instanceof Error ? err.message : 'Failed to revoke access.')
}
}
}
const persistAppearance = (nextTheme: string, nextColorScheme: string) => {
localStorage.setItem('active_theme', nextTheme)
localStorage.setItem('active_color_scheme', nextColorScheme)
notifyAppearanceChanged()
}
const handleThemeChange = (nextTheme: string) => {
setTheme(nextTheme)
persistAppearance(nextTheme, colorScheme)
}
const handleColorSchemeChange = (nextColorScheme: string) => {
setColorScheme(nextColorScheme)
persistAppearance(theme, nextColorScheme)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
setSuccess(false)
localStorage.setItem('owm_api_key', apiKey.trim())
persistAppearance(theme, colorScheme)
setSaving(false)
setSuccess(true)
setTimeout(() => setSuccess(false), 3000)
if (!logbookId) {
return (
<div className="form-card">
<div className="form-header">
<SettingsIcon size={24} className="form-icon" />
<div>
<h2>{t('settings.title')}</h2>
<p className="form-subtitle">{t('settings.subtitle')}</p>
</div>
</div>
<p className="text-muted mt-4">{t('settings.select_logbook_hint')}</p>
</div>
)
}
return (
@@ -289,125 +281,12 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
<SettingsIcon size={24} className="form-icon" />
<div>
<h2>{t('settings.title')}</h2>
<p className="form-subtitle" style={{ margin: '4px 0 0 0', fontSize: '13px', color: '#94a3b8' }}>
{t('settings.subtitle')}
</p>
<p className="form-subtitle">{t('settings.subtitle')}</p>
</div>
</div>
<form onSubmit={handleSubmit} className="vessel-form mt-6">
<PwaInstallPrompt variant="inline" />
{/* Weather Integration card */}
<div className="member-editor-card glass">
<h3 style={{ marginTop: 0, marginBottom: '12px', color: '#fbbf24', fontSize: '16px' }}>
{t('settings.owm_title')}
</h3>
<p style={{ fontSize: '13.5px', color: '#94a3b8', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('settings.key_help')}
</p>
<div className="input-group">
<label htmlFor="owm-api-key" style={{ display: 'block', fontSize: '13.5px', color: '#94a3b8', marginBottom: '6px', fontWeight: 500 }}>
{t('settings.owm_key')}
</label>
<input
id="owm-api-key"
type="password"
className="input-text"
placeholder="e.g. 8b6a7f...d8"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
disabled={saving}
/>
</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">
<ThemedSelect
id="app-theme"
value={theme}
disabled={saving}
onChange={handleThemeChange}
options={[
{ value: 'auto', label: t('settings.theme_auto') },
{ value: 'ocean', label: t('settings.theme_ocean') },
{ value: 'material', label: t('settings.theme_material') },
{ value: 'cupertino', label: t('settings.theme_cupertino') }
]}
/>
</div>
</div>
<div className="member-editor-card glass mt-4">
<h3 style={{ marginTop: 0, marginBottom: '12px', color: 'var(--app-accent-light)', fontSize: '16px' }}>
{t('settings.color_scheme_title')}
</h3>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('settings.color_scheme_label')}
</p>
<div className="input-group">
<ThemedSelect
id="app-color-scheme"
value={colorScheme}
disabled={saving}
onChange={handleColorSchemeChange}
options={[
{ value: 'auto', label: t('settings.color_scheme_auto') },
{ value: 'light', label: t('settings.color_scheme_light') },
{ value: 'dark', label: t('settings.color_scheme_dark') }
]}
/>
</div>
</div>
<div className="member-editor-card glass mt-4">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<Compass size={20} style={{ color: 'var(--app-accent-light)' }} />
<h3 style={{ margin: 0, color: 'var(--app-accent-light)', fontSize: '16px' }}>
{t('settings.tour_title')}
</h3>
</div>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('settings.tour_desc')}
</p>
<button
type="button"
className="btn secondary"
onClick={() => restartTour()}
>
{t('settings.tour_restart')}
</button>
</div>
<div className="form-actions mt-4 mb-6">
{success && (
<div className="success-toast">
<Check size={16} />
<span>{t('settings.saved')}</span>
</div>
)}
<button type="submit" className="btn primary" disabled={saving}>
<Save size={18} />
{saving ? t('settings.saving') : t('settings.save')}
</button>
</div>
</form>
{/* Public Share Link Card (Only visible to Logbook Owner) */}
{logbookId && isOwner && (
<div className="member-editor-card glass mt-6" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}>
<div className="member-editor-card glass mt-6">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<LinkIcon size={20} style={{ color: '#fbbf24' }} />
<h3 style={{ margin: 0, color: '#fbbf24', fontSize: '16px' }}>
@@ -419,6 +298,10 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
{t('settings.share_desc')}
</p>
<p className="signature-lock-notice" style={{ marginBottom: '16px' }}>
{t('settings.share_privacy_warning')}
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '20px' }}>
<label className="switch-label" style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', fontSize: '14px', color: '#f1f5f9' }}>
<input
@@ -434,34 +317,36 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
</div>
{shareEnabled && shareLink && (
<div className="input-group mb-4" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<input
type="text"
readOnly
value={shareLink}
className="input-text font-mono text-xs"
style={{ flex: 1, padding: '10px' }}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type="button"
className="btn secondary"
onClick={handleCopyShareLink}
style={{ width: 'auto', padding: '10px' }}
>
{shareCopied ? <Check size={16} /> : <Copy size={16} />}
</button>
<div className="link-with-qr mb-4">
<div className="input-group copy-link-row">
<input
type="text"
readOnly
value={shareLink}
className="input-text font-mono text-xs"
style={{ flex: 1, padding: '10px' }}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type="button"
className="btn secondary"
onClick={handleCopyShareLink}
style={{ width: 'auto', padding: '10px' }}
title={t('settings.share_copy_btn')}
>
{shareCopied ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
<LinkQrCode value={shareLink} />
</div>
)}
</div>
)}
{/* Backup & Restore (owner only) */}
{logbookId && isOwner && (
<LogbookBackupPanel logbookId={logbookId} onRestored={onLogbookRestored} />
)}
{/* Crew Collaboration Card (Only visible to Logbook Owner) */}
{logbookId && isOwner && (
<div className="member-editor-card glass mt-6" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
@@ -475,7 +360,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
{t('logs.invite_link_desc')}
</p>
<div className="form-actions" style={{ justifyContent: 'flex-start', gap: '12px', marginBottom: '20px' }}>
<div className="form-actions form-actions--start" style={{ gap: '12px', marginBottom: '20px' }}>
<button
type="button"
className="btn primary"
@@ -489,27 +374,30 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
</div>
{inviteLink && (
<div className="input-group mb-6" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<input
type="text"
readOnly
value={inviteLink}
className="input-text font-mono text-xs"
style={{ flex: 1, padding: '10px' }}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type="button"
className="btn secondary"
onClick={handleCopyInvite}
style={{ width: 'auto', padding: '10px' }}
>
{inviteCopied ? <Check size={16} /> : <Copy size={16} />}
</button>
<div className="link-with-qr mb-6">
<div className="input-group copy-link-row">
<input
type="text"
readOnly
value={inviteLink}
className="input-text font-mono text-xs"
style={{ flex: 1, padding: '10px' }}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type="button"
className="btn secondary"
onClick={handleCopyInvite}
style={{ width: 'auto', padding: '10px' }}
title={t('settings.share_copy_btn')}
>
{inviteCopied ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
<LinkQrCode value={inviteLink} />
</div>
)}
{/* Collaborator List */}
<h4 style={{ color: '#fbbf24', fontSize: '14px', marginBottom: '12px' }}>
{t('logs.collaborators_list')}
</h4>
@@ -555,8 +443,6 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
)}
</div>
)}
{/* Danger Zone / Account Deletion */}
<AccountDangerZone className="mt-6" />
</div>
)
}
+26 -9
View File
@@ -4,7 +4,8 @@ import { Check } from 'lucide-react'
import SignaturePad from './SignaturePad.tsx'
import PasskeySignButton from './PasskeySignButton.tsx'
import type { PasskeySignature, SignatureValue } from '../types/signatures.js'
import { isPasskeySignature } from '../utils/signatures.js'
import { isPasskeySignature, getSignaturePayload, getSignatureAttribution } from '../utils/signatures.js'
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
type SignatureMode = 'passkey' | 'classic'
@@ -13,7 +14,7 @@ interface SignatureSectionProps {
disabled?: boolean
isOnline: boolean
canSignSkipper: boolean
hasWriteCollaborators: boolean
canSignCrew: boolean
signSkipper: SignatureValue | ''
signCrew: SignatureValue | ''
skipperSignatureValid: boolean
@@ -25,14 +26,28 @@ interface SignatureSectionProps {
onBeforeSign?: () => Promise<boolean>
}
function SignerAttributionBadge({ value }: { value: SignatureValue | '' }) {
const { t, i18n } = useTranslation()
const attribution = getSignatureAttribution(value)
if (!attribution) return null
const formattedDate = formatAppDateTime(attribution.signedAt, i18n.language)
return (
<div className="passkey-sign-badge valid signature-attribution-badge">
<span>{t('logs.sign_passkey_signed', { username: attribution.username })}</span>
<span className="passkey-sign-date">{formattedDate}</span>
</div>
)
}
function padValue(value: SignatureValue | ''): string {
if (!value || isPasskeySignature(value)) return ''
return value
return getSignaturePayload(value)
}
function modeFromValue(value: SignatureValue | '', passkeyAvailable: boolean): SignatureMode {
if (isPasskeySignature(value)) return 'passkey'
if (value) return 'classic'
if (getSignaturePayload(value)) return 'classic'
return passkeyAvailable ? 'passkey' : 'classic'
}
@@ -108,6 +123,7 @@ function RoleSignatureBlock({
}
return (
<div className="signature-role-block">
<SignerAttributionBadge value={value} />
<SignaturePad
id={padId}
label={roleLabel}
@@ -162,6 +178,7 @@ function RoleSignatureBlock({
{showClassicPanel && (
<>
<SignerAttributionBadge value={value} />
<SignaturePad
id={padId}
label={roleLabel}
@@ -189,7 +206,7 @@ export default function SignatureSection({
disabled = false,
isOnline,
canSignSkipper,
hasWriteCollaborators,
canSignCrew,
signSkipper,
signCrew,
skipperSignatureValid,
@@ -203,7 +220,7 @@ export default function SignatureSection({
const { t } = useTranslation()
const showSkipperPasskey = canSignSkipper && isOnline
const showCrewPasskey = hasWriteCollaborators && isOnline
const showCrewPasskey = canSignCrew && isOnline
const hasSignature = !!(signSkipper || signCrew)
return (
@@ -228,7 +245,7 @@ export default function SignatureSection({
passkeySignature={isPasskeySignature(signSkipper) ? signSkipper : undefined}
signatureValid={skipperSignatureValid}
showPasskey={showSkipperPasskey}
readOnly={readOnly}
readOnly={readOnly || !canSignSkipper}
disabled={disabled}
classicHint={showSkipperPasskey ? t('logs.sign_classic_or_passkey') : undefined}
offlineHint={!isOnline && canSignSkipper ? t('logs.sign_offline_hint') : undefined}
@@ -245,7 +262,7 @@ export default function SignatureSection({
passkeySignature={isPasskeySignature(signCrew) ? signCrew : undefined}
signatureValid={crewSignatureValid}
showPasskey={showCrewPasskey}
readOnly={readOnly}
readOnly={readOnly || !canSignCrew}
disabled={disabled}
classicHint={showCrewPasskey ? t('logs.sign_crew_passkey_hint') : undefined}
onChange={onSignCrewChange}
+153 -6
View File
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { BarChart2, Anchor, Droplets, Fuel, Sailboat, Gauge } from 'lucide-react'
import { BarChart2, Anchor, Droplets, Fuel, Sailboat, Gauge, Timer } from 'lucide-react'
import MultiTrackMap from './MultiTrackMap.tsx'
import {
formatLiters,
formatHours,
formatNm,
loadAccountStats,
loadLogbookStats,
@@ -12,6 +13,12 @@ import {
type TravelDayStats
} from '../services/statsAggregation.js'
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
import {
loadLogbookEventSeries,
type EventSeriesPoint,
type EventSeriesSummary
} from '../services/eventSeriesAggregation.js'
interface StatsDashboardProps {
logbookId: string
@@ -78,12 +85,26 @@ function TotalsGrid({ totals }: { totals: StatsTotals }) {
value={formatNm(totals.motorDistanceNm)}
unit={t('stats.unit_nm')}
/>
<KpiCard
icon={<Timer size={20} />}
label={t('stats.motor_hours_total')}
value={formatHours(totals.totalMotorHours)}
unit={t('stats.unit_h')}
/>
<KpiCard
icon={<Fuel size={20} />}
label={t('stats.fuel_total')}
value={formatLiters(totals.totalFuelL)}
unit={t('stats.unit_l')}
/>
{totals.fuelPerMotorHourL != null && (
<KpiCard
icon={<Timer size={20} />}
label={t('stats.fuel_per_motor_hour')}
value={formatFuelPerMotorHour(totals.fuelPerMotorHourL)}
unit={`${t('stats.unit_l')}/${t('stats.unit_h')}`}
/>
)}
<KpiCard
icon={<Droplets size={20} />}
label={t('stats.water_total')}
@@ -201,7 +222,62 @@ function PropulsionBreakdown({ totals }: { totals: StatsTotals }) {
)
}
function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
function EventSeriesList({ title, points, emptyLabel }: { title: string; points: EventSeriesPoint[]; emptyLabel: string }) {
if (points.length === 0) {
return (
<div className="stats-event-series-block">
<h4 className="stats-section-subtitle">{title}</h4>
<p className="stats-section-sub">{emptyLabel}</p>
</div>
)
}
return (
<div className="stats-event-series-block">
<h4 className="stats-section-subtitle">{title}</h4>
<ul className="stats-event-series-list">
{points.map((point, idx) => (
<li key={`${point.entryId}-${point.time}-${idx}`} className="stats-event-series-item">
<span className="stats-event-series-when">
{new Date(point.date).toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })}
{' · '}
{point.time}
</span>
<span className="stats-event-series-value">{point.summary}</span>
</li>
))}
</ul>
</div>
)
}
function EventSeriesPanel({ series }: { series: EventSeriesSummary }) {
const { t } = useTranslation()
const motorPoints = series.motor.map((point) => ({
...point,
summary: point.summary === 'start'
? t('logs.live_motor_start')
: t('logs.live_motor_stop')
}))
return (
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.event_series_title')}</h3>
<p className="stats-section-sub">{t('stats.event_series_hint')}</p>
<EventSeriesList title={t('stats.event_series_pressure')} points={series.pressure} emptyLabel={t('stats.event_series_empty')} />
<EventSeriesList title={t('stats.event_series_wind')} points={series.wind} emptyLabel={t('stats.event_series_empty')} />
<EventSeriesList title={t('stats.event_series_motor')} points={motorPoints} emptyLabel={t('stats.event_series_empty')} />
</div>
)
}
function LogbookScopeView({
summary,
eventSeries
}: {
summary: LogbookStatsSummary
eventSeries: EventSeriesSummary | null
}) {
const { t } = useTranslation()
const { travelDays, routePorts, trackSegments, totals } = summary
@@ -247,6 +323,36 @@ function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
/>
</div>
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.daily_motor_hours')}</h3>
<p className="stats-section-sub">
{t('stats.avg_motor_hours')}: {formatHours(totals.avgMotorHoursPerDay)} {t('stats.unit_h')}
{totals.fuelPerMotorHourL != null && (
<>
{' · '}
{t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}
</>
)}
</p>
<DailyBarChart
days={travelDays}
valueFn={(d) => d.motorHours}
barClass="stats-bar--motor-hours"
formatValue={formatHours}
/>
{travelDays.some((d) => d.fuelPerMotorHourL != null) && (
<>
<h4 className="stats-section-subtitle mt-4">{t('stats.daily_fuel_per_motor_hour')}</h4>
<DailyBarChart
days={travelDays}
valueFn={(d) => d.fuelPerMotorHourL ?? 0}
barClass="stats-bar--fuel-per-hour"
formatValue={(v) => formatFuelPerMotorHour(v > 0 ? v : null)}
/>
</>
)}
</div>
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.daily_consumption')}</h3>
<p className="stats-section-sub">
@@ -256,6 +362,9 @@ function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
{totals.fuelPerNmL != null && (
<> · {t('stats.fuel_per_nm')}: {totals.fuelPerNmL} {t('stats.unit_l')}/{t('stats.unit_nm')}</>
)}
{totals.fuelPerMotorHourL != null && (
<> · {t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}</>
)}
</p>
<ConsumptionChart days={travelDays} />
</div>
@@ -264,6 +373,8 @@ function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
<h3 className="stats-section-title">{t('stats.propulsion_title')}</h3>
<PropulsionBreakdown totals={totals} />
</div>
{eventSeries && <EventSeriesPanel series={eventSeries} />}
</>
)
}
@@ -274,18 +385,21 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [logbookStats, setLogbookStats] = useState<LogbookStatsSummary | null>(null)
const [eventSeries, setEventSeries] = useState<EventSeriesSummary | null>(null)
const [accountStats, setAccountStats] = useState<Awaited<ReturnType<typeof loadAccountStats>> | null>(null)
const loadData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [lb, acc] = await Promise.all([
const [lb, acc, series] = await Promise.all([
loadLogbookStats(logbookId, logbookTitle, true),
loadAccountStats(false)
loadAccountStats(false),
loadLogbookEventSeries(logbookId)
])
setLogbookStats(lb)
setAccountStats(acc)
setEventSeries(series)
} catch (err: unknown) {
console.error('Failed to load statistics:', err)
setError(err instanceof Error ? err.message : 'Failed to load statistics.')
@@ -310,7 +424,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
}, [accountStats])
return (
<div className="form-card">
<div className="form-card" data-tour="stats-dashboard">
<div className="form-header">
<BarChart2 size={24} className="form-icon" />
<div>
@@ -348,7 +462,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
<p>{t('stats.loading')}</p>
</div>
) : scope === 'logbook' && logbookStats ? (
<LogbookScopeView summary={logbookStats} />
<LogbookScopeView summary={logbookStats} eventSeries={eventSeries} />
) : scope === 'account' && accountStats ? (
<>
<TotalsGrid totals={accountStats.totals} />
@@ -367,6 +481,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
<th>{t('stats.travel_days')}</th>
<th>{t('stats.total_distance')}</th>
<th>{t('stats.fuel_total')}</th>
<th>{t('stats.motor_hours_total')}</th>
<th>{t('stats.water_total')}</th>
</tr>
</thead>
@@ -377,6 +492,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
<td>{lb.totals.travelDayCount}</td>
<td>{formatNm(lb.totals.totalDistanceNm)} {t('stats.unit_nm')}</td>
<td>{formatLiters(lb.totals.totalFuelL)} {t('stats.unit_l')}</td>
<td>{formatHours(lb.totals.totalMotorHours)} {t('stats.unit_h')}</td>
<td>{formatLiters(lb.totals.totalFreshwaterL)} {t('stats.unit_l')}</td>
</tr>
))}
@@ -397,8 +513,39 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
/>
</div>
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.daily_motor_hours')}</h3>
{accountStats.totals.fuelPerMotorHourL != null && (
<p className="stats-section-sub">
{t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(accountStats.totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}
</p>
)}
<DailyBarChart
days={allAccountDays}
valueFn={(d) => d.motorHours}
barClass="stats-bar--motor-hours"
formatValue={formatHours}
/>
{allAccountDays.some((d) => d.fuelPerMotorHourL != null) && (
<>
<h4 className="stats-section-subtitle mt-4">{t('stats.daily_fuel_per_motor_hour')}</h4>
<DailyBarChart
days={allAccountDays}
valueFn={(d) => d.fuelPerMotorHourL ?? 0}
barClass="stats-bar--fuel-per-hour"
formatValue={(v) => formatFuelPerMotorHour(v > 0 ? v : null)}
/>
</>
)}
</div>
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.daily_consumption')}</h3>
{accountStats.totals.fuelPerMotorHourL != null && (
<p className="stats-section-sub">
{t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(accountStats.totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}
</p>
)}
<ConsumptionChart days={allAccountDays} />
</div>
@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AlertTriangle } from 'lucide-react'
import {
getSyncConflicts,
subscribeSyncConflicts,
type SyncConflict
} from '../services/syncConflicts.js'
import {
resolveSyncConflictKeepLocal,
resolveSyncConflictUseServer
} from '../services/sync.js'
interface SyncConflictBannerProps {
logbookId: string | null
}
export default function SyncConflictBanner({ logbookId }: SyncConflictBannerProps) {
const { t } = useTranslation()
const [items, setItems] = useState<SyncConflict[]>([])
useEffect(() => {
const refresh = () => {
setItems(logbookId ? getSyncConflicts(logbookId) : getSyncConflicts())
}
refresh()
return subscribeSyncConflicts(refresh)
}, [logbookId])
if (items.length === 0) return null
const first = items[0]
return (
<div className="sync-conflict-banner" role="alert">
<AlertTriangle size={20} aria-hidden />
<div className="sync-conflict-banner__body">
<strong>{t('sync.conflict_title')}</strong>
<p>
{t('sync.conflict_message', {
count: items.length,
id: first.payloadId.slice(0, 8)
})}
</p>
<div className="sync-conflict-banner__actions">
<button
type="button"
className="btn secondary"
onClick={() => void resolveSyncConflictUseServer(first)}
>
{t('sync.conflict_use_server')}
</button>
<button
type="button"
className="btn primary"
onClick={() => void resolveSyncConflictKeepLocal(first)}
>
{t('sync.conflict_keep_local')}
</button>
</div>
</div>
</div>
)
}
+103
View File
@@ -0,0 +1,103 @@
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { clampTankLiters } from '../utils/tankCapacity.js'
interface TankLiterInputProps {
id?: string
label: string
value: string
onChange: (value: string) => void
maxLiters?: number
disabled?: boolean
titleTooltip?: string
}
function parseInputLiters(value: string): number {
const trimmed = value.trim().replace(',', '.')
if (!trimmed) return 0
const parsed = Number(trimmed)
return Number.isFinite(parsed) ? parsed : 0
}
export default function TankLiterInput({
id,
label,
value,
onChange,
maxLiters,
disabled = false,
titleTooltip
}: TankLiterInputProps) {
const { t } = useTranslation()
const useSlider = maxLiters != null && maxLiters > 0
const emitValue = useCallback(
(liters: number) => {
const clamped = clampTankLiters(liters, useSlider ? maxLiters : undefined)
const str =
Number.isInteger(clamped) ? String(clamped) : String(Number(clamped.toFixed(1)))
onChange(str)
},
[onChange, maxLiters, useSlider]
)
const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value)
}
const handleNumberBlur = () => {
if (!useSlider) return
emitValue(parseInputLiters(value))
}
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
emitValue(Number(e.target.value))
}
const numericValue = parseInputLiters(value)
const sliderValue = useSlider ? clampTankLiters(numericValue, maxLiters) : 0
return (
<div className="input-group tank-liter-input">
<label htmlFor={id} title={titleTooltip}>{label}</label>
{useSlider && (
<>
<input
type="range"
className="tank-liter-slider"
min={0}
max={maxLiters}
step={1}
value={sliderValue}
onChange={handleSliderChange}
disabled={disabled}
title={titleTooltip}
aria-valuemin={0}
aria-valuemax={maxLiters}
aria-valuenow={sliderValue}
aria-label={label}
/>
<div className="tank-liter-slider-hint" aria-hidden="true">
{t('logs.tank_slider_of_max', {
current: sliderValue,
max: maxLiters
})}
</div>
</>
)}
<input
id={id}
type="number"
className="input-text"
value={value}
onChange={handleNumberChange}
onBlur={handleNumberBlur}
disabled={disabled}
min={0}
max={useSlider ? maxLiters : undefined}
step="any"
title={titleTooltip}
/>
</div>
)
}
+844
View File
@@ -0,0 +1,844 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLiveQuery } from 'dexie-react-hooks'
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
import {
User,
ChevronLeft,
LogOut,
KeyRound,
Copy,
Check,
Plus,
Trash2,
BookOpen,
Anchor,
Gauge,
Sailboat,
Ship,
Timer,
Share2,
Calendar,
Lock,
BarChart2,
Shield,
Smartphone,
RefreshCw,
Wifi,
WifiOff,
CircleCheck,
CircleAlert
} from 'lucide-react'
import AccountDangerZone from './AccountDangerZone.tsx'
import UserProfilePreferences from './UserProfilePreferences.tsx'
import PersonPoolForm from './PersonPoolForm.tsx'
import VesselPoolForm from './VesselPoolForm.tsx'
import ProfileAccordionSection from './ProfileAccordionSection.tsx'
import { useAppTour } from '../context/AppTourContext.tsx'
import BetaBadge from './BetaBadge.tsx'
import { useDialog } from './ModalDialog.tsx'
import {
addPasskey,
fetchUserProfile,
forgetUsername,
getActiveMasterKey,
getKnownUsernames,
hasLocalPin,
removeLocalPin,
removePasskey,
renamePasskey,
rotateRecoveryPhrase,
setLocalPin,
type UserProfile
} from '../services/auth.js'
import {
formatHours,
formatNm,
loadAccountStats,
type AccountStatsSummary
} from '../services/statsAggregation.js'
import { db } from '../services/db.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
interface UserProfilePageProps {
onBack: () => void
onLogout: () => void
}
function formatAccountAge(createdAt: string, locale: string): string {
const created = new Date(createdAt)
if (Number.isNaN(created.getTime())) return createdAt
return created.toLocaleDateString(locale, {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
function KpiCard({
icon,
label,
value,
unit
}: {
icon: React.ReactNode
label: string
value: string
unit?: string
}) {
return (
<div className="stats-kpi-card glass">
<div className="stats-kpi-icon">{icon}</div>
<div className="stats-kpi-body">
<span className="stats-kpi-label">{label}</span>
<span className="stats-kpi-value">
{value}
{unit ? <span className="stats-kpi-unit">{unit}</span> : null}
</span>
</div>
</div>
)
}
function SecurityCheckItem({ ok, label }: { ok: boolean; label: string }) {
return (
<li className={`profile-security-item ${ok ? 'profile-security-item--ok' : 'profile-security-item--warn'}`}>
{ok ? <CircleCheck size={18} aria-hidden="true" /> : <CircleAlert size={18} aria-hidden="true" />}
<span>{label}</span>
</li>
)
}
export default function UserProfilePage({ onBack, onLogout }: UserProfilePageProps) {
const { t, i18n } = useTranslation()
const { showConfirm, showAlert } = useDialog()
const username = localStorage.getItem('active_username') || 'Skipper'
const [profile, setProfile] = useState<UserProfile | null>(null)
const [accountStats, setAccountStats] = useState<AccountStatsSummary | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [copiedUserId, setCopiedUserId] = useState(false)
const [passkeyBusy, setPasskeyBusy] = useState(false)
const [pinBusy, setPinBusy] = useState(false)
const [pinInput, setPinInput] = useState('')
const [pinConfirm, setPinConfirm] = useState('')
const [pinActive, setPinActive] = useState(() => hasLocalPin(username))
const [newPasskeyLabel, setNewPasskeyLabel] = useState('')
const [passkeyLabels, setPasskeyLabels] = useState<Record<string, string>>({})
const [online, setOnline] = useState(navigator.onLine)
const [isKnownDevice, setIsKnownDevice] = useState(() =>
getKnownUsernames().some((u) => u.toLowerCase() === username.toLowerCase())
)
const [recoveryBusy, setRecoveryBusy] = useState(false)
const [pendingRecoveryPhrase, setPendingRecoveryPhrase] = useState<string | null>(null)
const [recoveryCopied, setRecoveryCopied] = useState(false)
const {
pendingCount: pendingSyncCount,
showSpinner,
showPendingWarning,
connStatusClassName
} = useSyncIndicator()
const { isActive: tourActive, currentStepId: tourStepId } = useAppTour()
const fleetSectionTourOpen =
tourActive &&
(tourStepId === 'profile_vessel_pool' || tourStepId === 'profile_crew_pool')
const sharedLogbookCount = useLiveQuery(
() => db.logbooks.filter((lb) => lb.isShared === 1).count(),
[]
) ?? 0
const loadData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const profileData = await fetchUserProfile()
setProfile(profileData)
try {
const stats = await loadAccountStats(false)
setAccountStats(stats)
} catch (statsErr) {
console.error('Failed to load account stats for profile:', statsErr)
setAccountStats(null)
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('profile.load_error'))
} finally {
setLoading(false)
}
}, [t])
useEffect(() => {
void loadData()
}, [loadData])
useEffect(() => {
trackPlausibleEvent(PlausibleEvents.PROFILE_OPENED)
}, [])
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(() => {
if (!profile) return
const labels: Record<string, string> = {}
for (const cred of profile.credentials) {
labels[cred.id] = cred.label ?? ''
}
setPasskeyLabels(labels)
}, [profile])
const statsTotals = accountStats?.totals
const logbookCount =
accountStats?.logbooks.length ?? profile?.serverMeta.ownedLogbookCount ?? 0
const accountAgeLabel = useMemo(() => {
if (!profile?.createdAt) return '—'
return formatAccountAge(profile.createdAt, i18n.language)
}, [profile?.createdAt, i18n.language])
const handleCopyUserId = async () => {
if (!profile?.userId) return
try {
await navigator.clipboard.writeText(profile.userId)
setCopiedUserId(true)
window.setTimeout(() => setCopiedUserId(false), 2000)
} catch {
showAlert(t('profile.copy_failed'))
}
}
const handleAddPasskey = async () => {
setPasskeyBusy(true)
setError(null)
try {
const hadLabel = Boolean(newPasskeyLabel.trim())
await addPasskey(newPasskeyLabel)
setNewPasskeyLabel('')
await loadData()
trackPlausibleEvent(PlausibleEvents.PASSKEY_ADDED, { labeled: hadLabel })
showAlert(t('profile.add_passkey_success'))
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('profile.add_passkey_failed'))
} finally {
setPasskeyBusy(false)
}
}
const handleRenamePasskey = async (credentialId: string) => {
setPasskeyBusy(true)
setError(null)
try {
await renamePasskey(credentialId, passkeyLabels[credentialId] ?? '')
await loadData()
trackPlausibleEvent(PlausibleEvents.PASSKEY_RENAMED)
showAlert(t('profile.passkey_rename_success'))
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('profile.passkey_rename_failed'))
} finally {
setPasskeyBusy(false)
}
}
const handleForgetDevice = async () => {
const confirmed = await showConfirm(
t('profile.device_forget_confirm_desc'),
t('profile.device_forget_confirm_title'),
t('profile.device_forget_confirm_yes'),
t('profile.device_forget_confirm_no')
)
if (!confirmed) return
forgetUsername(username)
setIsKnownDevice(false)
trackPlausibleEvent(PlausibleEvents.DEVICE_FORGOTTEN)
}
const handleRemovePasskey = async (credentialId: string) => {
if (profile && profile.credentials.length <= 1) {
trackPlausibleEvent(PlausibleEvents.LAST_PASSKEY_REMOVE_HINTED)
await showAlert(
t('profile.remove_passkey_last_desc'),
t('profile.remove_passkey_last_title')
)
return
}
const confirmed = await showConfirm(
t('profile.remove_passkey_confirm_desc'),
t('profile.remove_passkey_confirm_title'),
t('profile.remove_passkey_confirm_yes'),
t('profile.remove_passkey_confirm_no')
)
if (!confirmed) return
setPasskeyBusy(true)
setError(null)
try {
await removePasskey(credentialId)
await loadData()
trackPlausibleEvent(PlausibleEvents.PASSKEY_REMOVED)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('profile.remove_passkey_failed'))
} finally {
setPasskeyBusy(false)
}
}
const handleSavePin = async (e: React.FormEvent) => {
e.preventDefault()
if (pinInput.length < 4) {
setError(t('profile.pin_length_error'))
return
}
if (pinInput !== pinConfirm) {
setError(t('profile.pin_mismatch'))
return
}
const masterKey = getActiveMasterKey()
if (!masterKey) {
setError(t('profile.pin_no_session'))
return
}
const pinAction = pinActive ? 'change' : 'set'
setPinBusy(true)
setError(null)
try {
await setLocalPin(pinInput.trim(), username, masterKey)
setPinActive(true)
setPinInput('')
setPinConfirm('')
trackPlausibleEvent(PlausibleEvents.LOCAL_PIN_SET, { action: pinAction })
showAlert(t('profile.pin_saved'))
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('profile.pin_save_failed'))
} finally {
setPinBusy(false)
}
}
const handleRemovePin = async () => {
const confirmed = await showConfirm(
t('profile.remove_pin_confirm_desc'),
t('profile.remove_pin_confirm_title'),
t('profile.remove_pin_confirm_yes'),
t('profile.remove_pin_confirm_no')
)
if (!confirmed) return
removeLocalPin(username)
setPinActive(false)
setPinInput('')
setPinConfirm('')
trackPlausibleEvent(PlausibleEvents.LOCAL_PIN_REMOVED)
}
const handleRotateRecovery = async () => {
const confirmed = await showConfirm(
t('profile.recovery_rotate_confirm_desc'),
t('profile.recovery_rotate_confirm_title'),
t('profile.recovery_rotate_confirm_yes'),
t('profile.recovery_rotate_confirm_no')
)
if (!confirmed) return
if (!getActiveMasterKey()) {
setError(t('profile.recovery_rotate_no_session'))
return
}
setRecoveryBusy(true)
setError(null)
try {
const phrase = await rotateRecoveryPhrase()
setPendingRecoveryPhrase(phrase)
trackPlausibleEvent(PlausibleEvents.RECOVERY_ROTATED)
} catch (err: unknown) {
if (err instanceof Error && err.message === 'NO_ACTIVE_MASTER_KEY') {
setError(t('profile.recovery_rotate_no_session'))
} else {
setError(err instanceof Error ? err.message : t('profile.recovery_rotate_failed'))
}
} finally {
setRecoveryBusy(false)
}
}
const handleCopyRecoveryPhrase = async () => {
if (!pendingRecoveryPhrase) return
try {
await navigator.clipboard.writeText(pendingRecoveryPhrase)
setRecoveryCopied(true)
window.setTimeout(() => setRecoveryCopied(false), 2000)
} catch {
showAlert(t('profile.copy_failed'))
}
}
const handleConfirmRecoverySaved = () => {
setPendingRecoveryPhrase(null)
setRecoveryCopied(false)
}
return (
<div className="dashboard-container">
<header className="dashboard-header dashboard-header--profile">
<div className="header-brand profile-header-brand">
<button className="btn-back profile-back-btn" onClick={onBack} title={t('profile.back')}>
<ChevronLeft size={16} />
<span>{t('profile.back')}</span>
</button>
<div>
<div className="header-brand-title-row">
<h1>{t('profile.title')}</h1>
<BetaBadge />
</div>
<p className="subtitle">{t('profile.subtitle', { name: username })}</p>
</div>
</div>
<div className="header-actions">
<button className="btn-icon logout" onClick={onLogout} title={t('dashboard.logout')}>
<LogOut size={18} />
</button>
</div>
</header>
<main className="profile-main">
{error && <div className="auth-error mb-4">{error}</div>}
{loading ? (
<div className="tab-placeholder">
<User className="header-logo spin" size={48} />
<p>{t('profile.loading')}</p>
</div>
) : pendingRecoveryPhrase ? (
<section className="form-card profile-recovery-card">
<div className="form-header">
<KeyRound size={24} className="form-icon" />
<h2>{t('auth.recovery_title')}</h2>
</div>
<p className="profile-recovery-warning">{t('profile.recovery_rotate_new_warning')}</p>
<div className="phrase-grid">
{pendingRecoveryPhrase.split(' ').map((word, idx) => (
<div key={idx} className="phrase-word">
<span className="word-num">{idx + 1}</span>
{word}
</div>
))}
</div>
<div className="form-actions profile-recovery-actions">
<button type="button" className="btn secondary" onClick={() => void handleCopyRecoveryPhrase()}>
{recoveryCopied ? t('auth.copied') : t('auth.copy_phrase')}
</button>
<button type="button" className="btn primary" onClick={handleConfirmRecoverySaved}>
{t('auth.confirm_recovery')}
</button>
</div>
</section>
) : profile ? (
<>
<ProfileAccordionSection
id="account"
title={t('profile.sections.account')}
icon={<User size={20} aria-hidden="true" />}
defaultOpen
>
<div data-tour="profile-preferences">
<section className="form-card profile-accordion-inner-card">
<div className="form-header">
<User size={24} className="form-icon" />
<h2>{t('profile.identity_title')}</h2>
</div>
<dl className="profile-dl">
<div className="profile-dl-row">
<dt>{t('profile.username')}</dt>
<dd>{profile.username}</dd>
</div>
<div className="profile-dl-row">
<dt>{t('profile.user_id')}</dt>
<dd className="profile-user-id">
<code>{profile.userId}</code>
<button
type="button"
className="btn-icon profile-copy-btn"
onClick={() => void handleCopyUserId()}
title={t('profile.copy_user_id')}
>
{copiedUserId ? <Check size={16} /> : <Copy size={16} />}
</button>
</dd>
</div>
<div className="profile-dl-row">
<dt>{t('profile.account_since')}</dt>
<dd>{accountAgeLabel}</dd>
</div>
<div className="profile-dl-row">
<dt>{t('profile.prf_status')}</dt>
<dd>
{profile.hasPrfEncryption
? t('profile.prf_active')
: t('profile.prf_inactive')}
</dd>
</div>
</dl>
</section>
<UserProfilePreferences userId={profile.userId} />
</div>
</ProfileAccordionSection>
<ProfileAccordionSection
id="fleet"
title={t('profile.sections.fleet')}
icon={<Ship size={20} aria-hidden="true" />}
defaultOpen
forceOpen={fleetSectionTourOpen ? true : undefined}
>
<VesselPoolForm />
<PersonPoolForm />
</ProfileAccordionSection>
<ProfileAccordionSection
id="security"
title={t('profile.sections.security')}
icon={<Shield size={20} aria-hidden="true" />}
>
<section className="member-editor-card glass profile-accordion-inner-card">
<div className="profile-section-header">
<Shield size={20} />
<h3>{t('profile.security_title')}</h3>
</div>
<p className="profile-section-desc">{t('profile.security_desc')}</p>
<ul className="profile-security-list">
<SecurityCheckItem
ok={profile.credentials.length > 0}
label={
profile.credentials.length > 0
? t('profile.security_passkeys_ok')
: t('profile.security_passkeys_missing')
}
/>
<SecurityCheckItem
ok={profile.hasPrfEncryption}
label={
profile.hasPrfEncryption
? t('profile.security_prf_ok')
: t('profile.security_prf_missing')
}
/>
<SecurityCheckItem
ok={pinActive}
label={pinActive ? t('profile.security_pin_ok') : t('profile.security_pin_missing')}
/>
<SecurityCheckItem ok label={t('profile.security_recovery_ok')} />
</ul>
<p className="profile-section-desc profile-recovery-hint">{t('profile.security_recovery_hint')}</p>
<div className="form-actions profile-recovery-actions">
<button
type="button"
className="btn secondary"
onClick={() => void handleRotateRecovery()}
disabled={recoveryBusy || passkeyBusy || pinBusy}
>
{recoveryBusy ? t('profile.processing') : t('profile.recovery_rotate_btn')}
</button>
</div>
</section>
<section className="member-editor-card glass">
<div className="profile-section-header">
<Smartphone size={20} />
<h3>{t('profile.device_title')}</h3>
</div>
<p className="profile-section-desc">{t('profile.device_desc')}</p>
<div className={`profile-device-status ${connStatusClassName(online)}`}>
{online ? (
showSpinner ? (
<>
<RefreshCw size={16} className="spin" aria-hidden="true" />
<span>{t('sync.status_syncing')}</span>
</>
) : showPendingWarning ? (
<>
<RefreshCw size={16} aria-hidden="true" />
<span>{t('profile.device_sync_pending', { count: pendingSyncCount })}</span>
</>
) : (
<>
<Wifi size={16} aria-hidden="true" />
<span>{t('profile.device_sync_ok')}</span>
</>
)
) : (
<>
<WifiOff size={16} aria-hidden="true" />
<span>{t('sync.status_offline')}</span>
</>
)}
</div>
<p className="profile-pin-status">
{isKnownDevice ? t('profile.device_remembered') : t('profile.device_not_remembered')}
</p>
{isKnownDevice && (
<div className="form-actions">
<button
type="button"
className="btn secondary"
onClick={() => void handleForgetDevice()}
>
{t('profile.device_forget_btn')}
</button>
</div>
)}
</section>
<section className="member-editor-card glass">
<div className="profile-section-header">
<Lock size={20} />
<h3>{t('profile.pin_title')}</h3>
</div>
<p className="profile-section-desc">{t('auth.setup_pin_warning')}</p>
<p className="profile-pin-status">
{t('profile.pin_status')}:{' '}
<strong>{pinActive ? t('profile.pin_active') : t('profile.pin_inactive')}</strong>
</p>
<form onSubmit={(e) => void handleSavePin(e)} className="profile-pin-form">
<div className="input-group">
<label htmlFor="profile-pin">{t('auth.pin_label')}</label>
<input
id="profile-pin"
type="password"
inputMode="numeric"
autoComplete="new-password"
className="input-text"
placeholder={t('auth.pin_placeholder')}
value={pinInput}
onChange={(e) => setPinInput(e.target.value)}
disabled={pinBusy}
/>
</div>
<div className="input-group">
<label htmlFor="profile-pin-confirm">{t('profile.pin_confirm_label')}</label>
<input
id="profile-pin-confirm"
type="password"
inputMode="numeric"
autoComplete="new-password"
className="input-text"
placeholder={t('profile.pin_confirm_placeholder')}
value={pinConfirm}
onChange={(e) => setPinConfirm(e.target.value)}
disabled={pinBusy}
/>
</div>
<div className="form-actions">
<button
type="submit"
className="btn primary"
disabled={pinBusy || pinInput.length < 4 || pinConfirm.length < 4}
>
{pinActive ? t('profile.pin_change_btn') : t('profile.pin_set_btn')}
</button>
{pinActive && (
<button
type="button"
className="btn secondary"
onClick={() => void handleRemovePin()}
disabled={pinBusy}
>
{t('profile.pin_remove_btn')}
</button>
)}
</div>
</form>
</section>
<section className="member-editor-card glass">
<div className="profile-section-header">
<KeyRound size={20} />
<h3>{t('profile.passkeys_title')}</h3>
</div>
<p className="profile-section-desc">{t('profile.passkeys_desc')}</p>
{profile.credentials.length === 0 ? (
<p className="profile-empty">{t('profile.passkeys_empty')}</p>
) : (
<ul className="profile-passkey-list">
{profile.credentials.map((cred) => (
<li key={cred.id} className="profile-passkey-item">
<div className="profile-passkey-main">
<span className="profile-passkey-label">
{cred.label || t('profile.passkey_unnamed')}
</span>
<span className="profile-passkey-id">{cred.credentialIdPreview}</span>
{cred.transports.length > 0 && (
<span className="profile-passkey-transports">
{cred.transports.join(', ')}
</span>
)}
<div className="profile-passkey-rename">
<input
type="text"
className="input-text"
value={passkeyLabels[cred.id] ?? ''}
onChange={(e) =>
setPasskeyLabels((prev) => ({ ...prev, [cred.id]: e.target.value }))
}
placeholder={t('profile.passkey_label_placeholder')}
disabled={passkeyBusy}
maxLength={64}
/>
<button
type="button"
className="btn secondary"
onClick={() => void handleRenamePasskey(cred.id)}
disabled={passkeyBusy}
>
{t('profile.passkey_rename_btn')}
</button>
</div>
</div>
<button
type="button"
className="btn-icon danger"
onClick={() => void handleRemovePasskey(cred.id)}
disabled={passkeyBusy}
title={t('profile.remove_passkey_btn')}
>
<Trash2 size={16} />
</button>
</li>
))}
</ul>
)}
<div className="profile-add-passkey">
<div className="input-group">
<label htmlFor="profile-new-passkey-label">{t('profile.passkey_label')}</label>
<input
id="profile-new-passkey-label"
type="text"
className="input-text"
value={newPasskeyLabel}
onChange={(e) => setNewPasskeyLabel(e.target.value)}
placeholder={t('profile.passkey_label_placeholder')}
disabled={passkeyBusy}
maxLength={64}
/>
</div>
</div>
<div className="form-actions mt-4">
<button
type="button"
className="btn primary"
onClick={() => void handleAddPasskey()}
disabled={passkeyBusy}
>
<Plus size={16} />
{passkeyBusy ? t('profile.processing') : t('profile.add_passkey_btn')}
</button>
</div>
</section>
</ProfileAccordionSection>
<ProfileAccordionSection
id="stats"
title={t('profile.sections.stats')}
icon={<BarChart2 size={20} aria-hidden="true" />}
>
<section className="form-card profile-stats-section profile-accordion-inner-card">
<div className="form-header">
<BarChart2 size={24} className="form-icon" />
<div>
<h2>{t('profile.stats_title')}</h2>
<p className="stats-subtitle">{t('profile.stats_subtitle')}</p>
</div>
</div>
{(statsTotals || profile) && (
<div className="stats-kpi-grid profile-stats-kpi-grid">
<KpiCard
icon={<BookOpen size={20} />}
label={t('profile.stats_logbooks')}
value={String(logbookCount)}
/>
{statsTotals && (
<>
<KpiCard
icon={<Anchor size={20} />}
label={t('stats.travel_days')}
value={String(statsTotals.travelDayCount)}
/>
<KpiCard
icon={<Gauge size={20} />}
label={t('stats.total_distance')}
value={formatNm(statsTotals.totalDistanceNm)}
unit={t('stats.unit_nm')}
/>
<KpiCard
icon={<Sailboat size={20} />}
label={t('stats.sail_distance')}
value={formatNm(statsTotals.sailDistanceNm)}
unit={t('stats.unit_nm')}
/>
<KpiCard
icon={<Gauge size={20} />}
label={t('stats.motor_distance')}
value={formatNm(statsTotals.motorDistanceNm)}
unit={t('stats.unit_nm')}
/>
<KpiCard
icon={<Timer size={20} />}
label={t('stats.motor_hours_total')}
value={formatHours(statsTotals.totalMotorHours)}
unit={t('stats.unit_h')}
/>
<KpiCard
icon={<Share2 size={20} />}
label={t('profile.stats_shared_logbooks')}
value={String(sharedLogbookCount)}
/>
</>
)}
<KpiCard
icon={<Calendar size={20} />}
label={t('profile.stats_account_since')}
value={accountAgeLabel}
/>
</div>
)}
</section>
</ProfileAccordionSection>
<ProfileAccordionSection
id="danger"
title={t('profile.sections.danger')}
>
<AccountDangerZone className="profile-accordion-inner-card" />
</ProfileAccordionSection>
</>
) : null}
</main>
</div>
)
}
@@ -0,0 +1,159 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Compass, Palette, Save, Check, Cloud } from 'lucide-react'
import ThemedSelect from './ThemedSelect.tsx'
import PushNotificationSettings from './PushNotificationSettings.tsx'
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
import { notifyAppearanceChanged } from '../services/appearance.js'
import { saveAppearancePrefsToServer } from '../services/appearancePrefs.js'
import { useAppTour } from '../context/AppTourContext.tsx'
import {
getColorSchemePreference,
getOwmApiKey,
getThemePreference,
setColorSchemePreference,
setOwmApiKey,
setThemePreference
} from '../services/userPreferences.js'
interface UserProfilePreferencesProps {
userId: string
}
export default function UserProfilePreferences({ userId }: UserProfilePreferencesProps) {
const { t } = useTranslation()
const { restartTour } = useAppTour()
const [apiKey, setApiKey] = useState(() => getOwmApiKey(userId))
const [theme, setTheme] = useState(() => getThemePreference(userId))
const [colorScheme, setColorScheme] = useState(() => getColorSchemePreference(userId))
const [savingOwm, setSavingOwm] = useState(false)
const [owmSaved, setOwmSaved] = useState(false)
const persistAppearance = (nextTheme: string, nextColorScheme: string) => {
setThemePreference(userId, nextTheme)
setColorSchemePreference(userId, nextColorScheme)
notifyAppearanceChanged()
void saveAppearancePrefsToServer(nextTheme, nextColorScheme).catch((err) => {
console.warn('Failed to save appearance prefs to server:', err)
})
}
const handleThemeChange = (nextTheme: string) => {
setTheme(nextTheme)
persistAppearance(nextTheme, colorScheme)
}
const handleColorSchemeChange = (nextColorScheme: string) => {
setColorScheme(nextColorScheme)
persistAppearance(theme, nextColorScheme)
}
const handleSaveOwm = (e: React.FormEvent) => {
e.preventDefault()
setSavingOwm(true)
setOwmSaved(false)
setOwmApiKey(userId, apiKey)
setSavingOwm(false)
setOwmSaved(true)
window.setTimeout(() => setOwmSaved(false), 3000)
}
return (
<>
<section className="member-editor-card glass">
<div className="profile-section-header">
<Palette size={20} />
<h3>{t('profile.appearance_title')}</h3>
</div>
<p className="profile-section-desc">{t('profile.appearance_desc')}</p>
<div className="input-group">
<label htmlFor="profile-app-theme" className="profile-field-label">
{t('profile.theme_label')}
</label>
<ThemedSelect
id="profile-app-theme"
value={theme}
onChange={handleThemeChange}
options={[
{ value: 'auto', label: t('profile.theme_auto') },
{ value: 'ocean', label: t('profile.theme_ocean') },
{ value: 'material', label: t('profile.theme_material') },
{ value: 'cupertino', label: t('profile.theme_cupertino') }
]}
/>
</div>
<div className="input-group mt-4">
<label htmlFor="profile-color-scheme" className="profile-field-label">
{t('profile.color_scheme_label')}
</label>
<ThemedSelect
id="profile-color-scheme"
value={colorScheme}
onChange={handleColorSchemeChange}
options={[
{ value: 'auto', label: t('profile.color_scheme_auto') },
{ value: 'light', label: t('profile.color_scheme_light') },
{ value: 'dark', label: t('profile.color_scheme_dark') }
]}
/>
</div>
</section>
<section className="member-editor-card glass">
<div className="profile-section-header">
<Compass size={20} />
<h3>{t('profile.tour_title')}</h3>
</div>
<p className="profile-section-desc">{t('profile.tour_desc')}</p>
<div className="form-actions">
<button type="button" className="btn secondary" onClick={() => restartTour()}>
{t('profile.tour_restart')}
</button>
</div>
</section>
<section className="member-editor-card glass">
<div className="profile-section-header">
<Cloud size={20} />
<h3>{t('profile.integrations_title')}</h3>
</div>
<p className="profile-section-desc">{t('profile.owm_help')}</p>
<form onSubmit={handleSaveOwm}>
<div className="input-group">
<label htmlFor="profile-owm-api-key" className="profile-field-label">
{t('profile.owm_key')}
</label>
<input
id="profile-owm-api-key"
name="owm-api-key"
type="password"
className="input-text"
placeholder="e.g. 8b6a7f...d8"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
disabled={savingOwm}
autoComplete="off"
/>
</div>
<div className="form-actions mt-4">
{owmSaved && (
<div className="success-toast">
<Check size={16} />
<span>{t('profile.prefs_saved')}</span>
</div>
)}
<button type="submit" className="btn primary" disabled={savingOwm}>
<Save size={18} />
{savingOwm ? t('profile.prefs_saving') : t('profile.prefs_save')}
</button>
</div>
</form>
</section>
<PushNotificationSettings />
<PwaInstallPrompt variant="inline" />
</>
)
}
+328
View File
@@ -0,0 +1,328 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { Ship, Camera, Trash2, Plus, X } from 'lucide-react'
import type { VesselFormInputs } from '../utils/vesselFormUtils.js'
export interface VesselDataFieldsProps {
inputs: VesselFormInputs
onChange: (next: VesselFormInputs) => void
readOnly?: boolean
saving?: boolean
newSailName: string
onNewSailNameChange: (value: string) => void
onAddSail: () => void
onRemoveSail: (index: number) => void
photoError?: string | null
onPhotoChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onRemovePhoto: () => void
fileInputRef: React.RefObject<HTMLInputElement | null>
}
export default function VesselDataFields({
inputs,
onChange,
readOnly = false,
saving = false,
newSailName,
onNewSailNameChange,
onAddSail,
onRemoveSail,
photoError,
onPhotoChange,
onRemovePhoto,
fileInputRef
}: VesselDataFieldsProps) {
const { t } = useTranslation()
const set = (patch: Partial<VesselFormInputs>) => onChange({ ...inputs, ...patch })
const triggerFileInput = () => {
if (!readOnly) fileInputRef.current?.click()
}
return (
<div className="form-grid">
<div className="vessel-photo-wrapper">
<div
className="vessel-photo-preview"
onClick={triggerFileInput}
style={{ cursor: readOnly ? 'default' : 'pointer' }}
>
{inputs.photo ? (
<img src={inputs.photo} alt={inputs.name || 'Vessel'} className="vessel-photo" />
) : (
<div className="vessel-photo-placeholder">
<Ship size={48} className="placeholder-icon" />
</div>
)}
{!readOnly && (
<div className="vessel-photo-overlay">
<Camera size={24} />
<span>{inputs.photo ? t('vessel.photo_change') : t('vessel.photo_add')}</span>
</div>
)}
</div>
{!readOnly && (
<div className="vessel-photo-actions">
<button type="button" className="btn secondary btn-sm" onClick={triggerFileInput} disabled={saving}>
<Camera size={16} />
{inputs.photo ? t('vessel.photo_change') : t('vessel.photo_add')}
</button>
{inputs.photo && (
<button type="button" className="btn danger btn-sm" onClick={onRemovePhoto} disabled={saving}>
<Trash2 size={16} />
{t('vessel.photo_delete')}
</button>
)}
</div>
)}
<input
type="file"
ref={fileInputRef}
onChange={onPhotoChange}
accept="image/*"
style={{ display: 'none' }}
/>
{photoError && <div className="auth-error mt-2">{photoError}</div>}
</div>
<div className="input-group">
<label>{t('vessel.name')}</label>
<input
type="text"
className="input-text"
value={inputs.name}
onChange={(e) => set({ name: e.target.value })}
disabled={saving || readOnly}
required
/>
</div>
<div className="input-group">
<label>{t('vessel.type')}</label>
<select
className="input-text"
value={inputs.vesselType}
onChange={(e) => set({ vesselType: e.target.value })}
disabled={saving || readOnly}
>
<option value="">{t('vessel.type_unset')}</option>
<option value="sailing">{t('vessel.type_sailing')}</option>
<option value="motor">{t('vessel.type_motor')}</option>
</select>
</div>
<div className="input-group">
<label>{t('vessel.length_m')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={inputs.lengthM}
onChange={(e) => set({ lengthM: e.target.value })}
disabled={saving || readOnly}
placeholder="0.00"
/>
</div>
<div className="input-group">
<label>{t('vessel.draft_m')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={inputs.draftM}
onChange={(e) => set({ draftM: e.target.value })}
disabled={saving || readOnly}
placeholder="0.00"
/>
</div>
<div className="input-group">
<label>{t('vessel.air_draft_m')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={inputs.airDraftM}
onChange={(e) => set({ airDraftM: e.target.value })}
disabled={saving || readOnly}
placeholder="0.00"
/>
</div>
<div className="input-group">
<label>{t('vessel.port')}</label>
<input
type="text"
className="input-text"
value={inputs.homePort}
onChange={(e) => set({ homePort: e.target.value })}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('vessel.owner')}</label>
<input
type="text"
className="input-text"
value={inputs.owner}
onChange={(e) => set({ owner: e.target.value })}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('vessel.charter')}</label>
<input
type="text"
className="input-text"
value={inputs.charterCompany}
onChange={(e) => set({ charterCompany: e.target.value })}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('vessel.registration')}</label>
<input
type="text"
className="input-text"
value={inputs.registrationNumber}
onChange={(e) => set({ registrationNumber: e.target.value })}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('vessel.callsign')}</label>
<input
type="text"
className="input-text"
value={inputs.callSign}
onChange={(e) => set({ callSign: e.target.value })}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('vessel.atis')}</label>
<input
type="text"
className="input-text"
value={inputs.atis}
onChange={(e) => set({ atis: e.target.value })}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('vessel.mmsi')}</label>
<input
type="text"
className="input-text"
value={inputs.mmsi}
onChange={(e) => set({ mmsi: e.target.value })}
disabled={saving || readOnly}
/>
</div>
<div className="vessel-tanks-section">
<h3>{t('vessel.tanks_section')}</h3>
<p className="vessel-tanks-help">{t('vessel.tanks_help')}</p>
<div className="vessel-tanks-grid">
<div className="input-group">
<label>{t('vessel.freshwater_capacity_l')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={inputs.freshwaterCapacityL}
onChange={(e) => set({ freshwaterCapacityL: e.target.value })}
disabled={saving || readOnly}
placeholder="0"
/>
</div>
<div className="input-group">
<label>{t('vessel.fuel_capacity_l')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={inputs.fuelCapacityL}
onChange={(e) => set({ fuelCapacityL: e.target.value })}
disabled={saving || readOnly}
placeholder="0"
/>
</div>
<div className="input-group">
<label>{t('vessel.greywater_capacity_l')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={inputs.greywaterCapacityL}
onChange={(e) => set({ greywaterCapacityL: e.target.value })}
disabled={saving || readOnly}
placeholder="0"
/>
</div>
</div>
</div>
<div className="sails-section">
<h3>{t('vessel.sails_list')}</h3>
<p className="help-text">{t('vessel.sails_help')}</p>
<div className="sails-badges-grid">
{inputs.sails.length === 0 ? (
<span className="no-sails-msg">{t('vessel.no_sails')}</span>
) : (
inputs.sails.map((sail, idx) => (
<span key={idx} className="sail-badge">
{sail}
{!readOnly && (
<button
type="button"
className="remove-btn"
onClick={() => onRemoveSail(idx)}
disabled={saving}
>
<X size={14} />
</button>
)}
</span>
))
)}
</div>
{!readOnly && (
<div className="add-sail-form">
<input
type="text"
className="input-text"
placeholder={t('vessel.sail_name_placeholder')}
value={newSailName}
onChange={(e) => onNewSailNameChange(e.target.value)}
disabled={saving}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
onAddSail()
}
}}
/>
<button
type="button"
className="btn secondary"
onClick={onAddSail}
disabled={saving || !newSailName.trim()}
style={{ width: 'auto' }}
>
<Plus size={16} />
{t('vessel.add_sail')}
</button>
</div>
)}
</div>
</div>
)
}
+65 -2
View File
@@ -7,6 +7,7 @@ import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { Ship, Save, Check, Plus, X, Camera, Trash2 } from 'lucide-react'
import { parseOptionalTankLiters, tankCapacityInputFromStored } from '../utils/tankCapacity.js'
interface VesselFormProps {
logbookId: string
@@ -47,6 +48,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
const [mmsi, setMmsi] = useState('')
const [sails, setSails] = useState<string[]>([])
const [newSailName, setNewSailName] = useState('')
const [freshwaterCapacityL, setFreshwaterCapacityL] = useState('')
const [fuelCapacityL, setFuelCapacityL] = useState('')
const [greywaterCapacityL, setGreywaterCapacityL] = useState('')
const fileInputRef = React.useRef<HTMLInputElement>(null)
const [photo, setPhoto] = useState<string | null>(null)
@@ -78,6 +82,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
setMmsi(preloadedData.mmsi || '')
setSails(preloadedData.sails || [])
setPhoto(preloadedData.photo || null)
setFreshwaterCapacityL(tankCapacityInputFromStored(preloadedData.freshwaterCapacityL))
setFuelCapacityL(tankCapacityInputFromStored(preloadedData.fuelCapacityL))
setGreywaterCapacityL(tankCapacityInputFromStored(preloadedData.greywaterCapacityL))
return
}
@@ -103,6 +110,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
setMmsi(decrypted.mmsi || '')
setSails(decrypted.sails || [])
setPhoto(decrypted.photo || null)
setFreshwaterCapacityL(tankCapacityInputFromStored(decrypted.freshwaterCapacityL))
setFuelCapacityL(tankCapacityInputFromStored(decrypted.fuelCapacityL))
setGreywaterCapacityL(tankCapacityInputFromStored(decrypted.greywaterCapacityL))
}
}
} catch (err: any) {
@@ -201,12 +211,19 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
let parsedLengthM: number | undefined
let parsedDraftM: number | undefined
let parsedAirDraftM: number | undefined
let parsedFreshwaterCapacityL: number | undefined
let parsedFuelCapacityL: number | undefined
let parsedGreywaterCapacityL: number | undefined
try {
parsedLengthM = parseOptionalMetricMeters(lengthM)
parsedDraftM = parseOptionalMetricMeters(draftM)
parsedAirDraftM = parseOptionalMetricMeters(airDraftM)
} catch {
setError(t('vessel.invalid_metric'))
parsedFreshwaterCapacityL = parseOptionalTankLiters(freshwaterCapacityL)
parsedFuelCapacityL = parseOptionalTankLiters(fuelCapacityL)
parsedGreywaterCapacityL = parseOptionalTankLiters(greywaterCapacityL)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : ''
setError(msg === 'invalid_tank_liters' ? t('vessel.invalid_tank_liters') : t('vessel.invalid_metric'))
setSaving(false)
return
}
@@ -217,6 +234,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
lengthM: parsedLengthM,
draftM: parsedDraftM,
airDraftM: parsedAirDraftM,
freshwaterCapacityL: parsedFreshwaterCapacityL,
fuelCapacityL: parsedFuelCapacityL,
greywaterCapacityL: parsedGreywaterCapacityL,
homePort: homePort.trim(),
charterCompany: charterCompany.trim(),
owner: owner.trim(),
@@ -480,6 +500,49 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
/>
</div>
<div className="vessel-tanks-section">
<h3>{t('vessel.tanks_section')}</h3>
<p className="vessel-tanks-help">{t('vessel.tanks_help')}</p>
<div className="vessel-tanks-grid">
<div className="input-group">
<label>{t('vessel.freshwater_capacity_l')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={freshwaterCapacityL}
onChange={(e) => setFreshwaterCapacityL(e.target.value)}
disabled={saving || readOnly}
placeholder="0"
/>
</div>
<div className="input-group">
<label>{t('vessel.fuel_capacity_l')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={fuelCapacityL}
onChange={(e) => setFuelCapacityL(e.target.value)}
disabled={saving || readOnly}
placeholder="0"
/>
</div>
<div className="input-group">
<label>{t('vessel.greywater_capacity_l')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={greywaterCapacityL}
onChange={(e) => setGreywaterCapacityL(e.target.value)}
disabled={saving || readOnly}
placeholder="0"
/>
</div>
</div>
</div>
<div className="sails-section">
<h3>{t('vessel.sails_list')}</h3>
<p className="help-text">{t('vessel.sails_help')}</p>
+244
View File
@@ -0,0 +1,244 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Ship, Plus, Trash2, Edit2, X, Save } from 'lucide-react'
import { useDialog } from './ModalDialog.tsx'
import VesselDataFields from './VesselDataFields.tsx'
import type { VesselFormInputs } from '../utils/vesselFormUtils.js'
import { parseVesselFormInputs, vesselDataToFormInputs } from '../utils/vesselFormUtils.js'
import { emptyVesselData } from '../types/vessel.js'
import { loadVesselPool, saveVessel, deleteVessel, type DecryptedVessel } from '../services/vesselPool.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
export default function VesselPoolForm() {
const { t } = useTranslation()
const { showConfirm } = useDialog()
const [vessels, setVessels] = useState<DecryptedVessel[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [inputs, setInputs] = useState<VesselFormInputs>(vesselDataToFormInputs(emptyVesselData()))
const [newSailName, setNewSailName] = useState('')
const [saving, setSaving] = useState(false)
const [photoError, setPhotoError] = useState<string | null>(null)
const fileRef = useRef<HTMLInputElement>(null)
const reload = useCallback(async () => {
setLoading(true)
setError(null)
try {
setVessels(await loadVesselPool())
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to load')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void reload()
}, [reload])
const openAdd = () => {
setEditingId(null)
setInputs(vesselDataToFormInputs(emptyVesselData()))
setNewSailName('')
setPhotoError(null)
setShowForm(true)
}
const openEdit = (vessel: DecryptedVessel) => {
setEditingId(vessel.payloadId)
setInputs(vesselDataToFormInputs(vessel.data))
setNewSailName('')
setPhotoError(null)
setShowForm(true)
}
const handleAddSail = () => {
const trimmed = newSailName.trim()
if (trimmed && !inputs.sails.includes(trimmed)) {
setInputs((prev) => ({ ...prev, sails: [...prev.sails, trimmed] }))
}
setNewSailName('')
}
const handlePhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setPhotoError(null)
const reader = new FileReader()
reader.onload = (event) => {
const img = new Image()
img.onload = () => {
try {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get canvas context')
let width = img.width
let height = img.height
const MAX_WIDTH = 800
const MAX_HEIGHT = 600
if (width > MAX_WIDTH || height > MAX_HEIGHT) {
const ratio = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height)
width = Math.round(width * ratio)
height = Math.round(height * ratio)
}
canvas.width = width
canvas.height = height
ctx.drawImage(img, 0, 0, width, height)
setInputs((prev) => ({ ...prev, photo: canvas.toDataURL('image/jpeg', 0.7) }))
} catch (err: unknown) {
setPhotoError(err instanceof Error ? err.message : 'Failed to process image')
}
}
img.onerror = () => setPhotoError('Invalid image file')
img.src = event.target?.result as string
}
reader.readAsDataURL(file)
}
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
if (!inputs.name.trim()) return
setSaving(true)
setError(null)
try {
const data = parseVesselFormInputs(inputs)
const id = editingId ?? window.crypto.randomUUID()
await saveVessel(id, data, !editingId)
setShowForm(false)
trackPlausibleEvent(PlausibleEvents.VESSEL_SAVED, { context: 'vessel_pool' })
await reload()
} catch (err: unknown) {
if (err instanceof Error && err.message === 'MAX_VESSELS') {
setError(t('vessel_pool.max_vessels'))
} else if (err instanceof Error && err.message === 'invalid_metric') {
setError(t('vessel.invalid_metric'))
} else if (err instanceof Error && err.message === 'invalid_tank_liters') {
setError(t('vessel.invalid_tank_liters'))
} else {
setError(err instanceof Error ? err.message : 'Failed to save')
}
} finally {
setSaving(false)
}
}
const handleDelete = async (id: string) => {
if (
!(await showConfirm(
t('vessel_pool.delete_confirm'),
t('vessel_pool.title'),
t('logs.confirm_yes'),
t('logs.confirm_no')
))
) {
return
}
try {
await deleteVessel(id)
await reload()
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to delete')
}
}
if (loading) {
return (
<div className="tab-placeholder">
<Ship className="header-logo spin" size={48} />
<p>{t('vessel_pool.loading')}</p>
</div>
)
}
return (
<div data-tour="profile-vessel-pool">
<div className="section-title-bar mb-4">
<h3>{t('vessel_pool.section_title')}</h3>
{!showForm && (
<button type="button" className="btn primary" style={{ width: 'auto', padding: '8px 16px' }} onClick={openAdd}>
<Plus size={16} />
{t('vessel_pool.add_vessel')}
</button>
)}
</div>
<p className="help-text mb-4">{t('vessel_pool.subtitle')}</p>
{error && <div className="auth-error mb-4">{error}</div>}
{vessels.length === 0 ? (
<p className="help-text mb-4">{t('vessel_pool.no_vessels')}</p>
) : (
<div className="crew-grid mb-6">
{vessels.map((v) => (
<div key={v.payloadId} className="crew-member-card glass">
<div className="crew-card-header">
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{v.data.photo ? (
<img src={v.data.photo} alt="" className="crew-card-avatar" />
) : (
<div className="crew-card-avatar-placeholder">
<Ship size={18} />
</div>
)}
<div>
<h4>{v.data.name}</h4>
{v.data.homePort && <p className="help-text">{v.data.homePort}</p>}
</div>
</div>
<div className="card-actions">
<button type="button" className="btn-icon" onClick={() => openEdit(v)}>
<Edit2 size={14} />
</button>
<button
type="button"
className="btn-icon logout"
onClick={() => void handleDelete(v.payloadId)}
>
<Trash2 size={14} />
</button>
</div>
</div>
</div>
))}
</div>
)}
{showForm && (
<form onSubmit={(e) => void handleSave(e)} className="member-editor-card glass">
<div className="editor-header mb-4">
<h3>{editingId ? t('vessel_pool.edit_vessel') : t('vessel_pool.add_vessel')}</h3>
<button type="button" className="btn-icon" onClick={() => setShowForm(false)}>
<X size={16} />
</button>
</div>
<VesselDataFields
inputs={inputs}
onChange={setInputs}
saving={saving}
newSailName={newSailName}
onNewSailNameChange={setNewSailName}
onAddSail={handleAddSail}
onRemoveSail={(idx) =>
setInputs((prev) => ({ ...prev, sails: prev.sails.filter((_, i) => i !== idx) }))
}
photoError={photoError}
onPhotoChange={handlePhotoChange}
onRemovePhoto={() => {
setInputs((prev) => ({ ...prev, photo: null }))
if (fileRef.current) fileRef.current.value = ''
}}
fileInputRef={fileRef}
/>
<div className="form-actions mt-4">
<button type="submit" className="btn primary" disabled={saving || !inputs.name.trim()}>
<Save size={18} />
{saving ? t('vessel.saving') : t('vessel.save')}
</button>
</div>
</form>
)}
</div>
)
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import {
DEMO_EXCLUDED_STEPS,
DEMO_STEP_ORDER,
FULL_STEP_ORDER,
getTourScrollRetryDelays,
getTourTargetRetryDelay,
tourStepOpensEntry
} from './AppTourContext.tsx'
describe('AppTourContext step order', () => {
it('includes profile steps before finish in full tour', () => {
const profileIndex = FULL_STEP_ORDER.indexOf('nav_profile')
const prefsIndex = FULL_STEP_ORDER.indexOf('profile_preferences')
const finishIndex = FULL_STEP_ORDER.indexOf('finish')
expect(profileIndex).toBeGreaterThan(FULL_STEP_ORDER.indexOf('nav_feedback'))
expect(prefsIndex).toBe(profileIndex + 1)
expect(finishIndex).toBe(prefsIndex + 1)
expect(FULL_STEP_ORDER).toContain('profile_vessel_pool')
expect(FULL_STEP_ORDER).toContain('profile_crew_pool')
expect(FULL_STEP_ORDER).toContain('nav_logbook_crew')
expect(FULL_STEP_ORDER.indexOf('profile_vessel_pool')).toBeLessThan(
FULL_STEP_ORDER.indexOf('profile_crew_pool')
)
expect(FULL_STEP_ORDER).toHaveLength(14)
})
it('excludes profile, stats and feedback from demo tour', () => {
for (const step of DEMO_EXCLUDED_STEPS) {
expect(DEMO_STEP_ORDER).not.toContain(step)
}
expect(DEMO_STEP_ORDER).toContain('finish')
expect(DEMO_STEP_ORDER).toHaveLength(FULL_STEP_ORDER.length - DEMO_EXCLUDED_STEPS.length)
})
it('only opens entry editor on entry_track step', () => {
expect(tourStepOpensEntry('entry_open')).toBe(false)
expect(tourStepOpensEntry('entry_list')).toBe(false)
expect(tourStepOpensEntry('entry_track')).toBe(true)
})
it('retries scroll for entry_track while editor mounts', () => {
expect(getTourTargetRetryDelay('entry_track')).toBeGreaterThanOrEqual(400)
expect(getTourScrollRetryDelays('entry_track').length).toBeGreaterThan(1)
})
})
+181 -19
View File
@@ -26,12 +26,22 @@ export type TourStepId =
| 'entry_open'
| 'entry_track'
| 'nav_vessel'
| 'nav_crew'
| 'profile_vessel_pool'
| 'profile_crew_pool'
| 'nav_logbook_crew'
| 'nav_stats'
| 'nav_feedback'
| 'nav_profile'
| 'profile_preferences'
| 'finish'
interface TourNavigation {
setActiveTab: (tab: AppTab) => void
setSelectedEntryId: (entryId: string | null) => void
setFeedbackOpen: (open: boolean) => void
setLogbookActive: (active: boolean) => void
setProfileOpen: (open: boolean) => void
ensureLogbookForTour?: () => Promise<void>
}
interface DemoTourContext {
@@ -44,6 +54,7 @@ interface AppTourContextValue {
currentStepId: TourStepId | null
currentStepIndex: number
totalSteps: number
layoutTick: number
startTour: (options?: { force?: boolean; demoMode?: boolean }) => void
stopTour: () => void
restartTour: () => void
@@ -55,24 +66,90 @@ interface AppTourContextValue {
requestStartAfterLogin: () => void
}
const STEP_ORDER: TourStepId[] = [
export const FULL_STEP_ORDER: TourStepId[] = [
'welcome',
'nav_logs',
'entry_list',
'entry_open',
'entry_track',
'nav_vessel',
'nav_crew',
'profile_vessel_pool',
'profile_crew_pool',
'nav_logbook_crew',
'nav_stats',
'nav_feedback',
'nav_profile',
'profile_preferences',
'finish'
]
/** Public demo has no stats/feedback/profile UI — skip those steps. */
export const DEMO_EXCLUDED_STEPS: TourStepId[] = [
'profile_crew_pool',
'nav_stats',
'nav_feedback',
'nav_profile',
'profile_preferences'
]
export const DEMO_STEP_ORDER: TourStepId[] = FULL_STEP_ORDER.filter(
(id) => !DEMO_EXCLUDED_STEPS.includes(id)
)
const LOGBOOK_TOUR_STEPS = new Set<TourStepId>([
'nav_logs',
'entry_list',
'entry_open',
'entry_track',
'nav_vessel',
'nav_logbook_crew',
'nav_stats',
'nav_feedback'
])
function getStepOrder(demoMode: boolean): TourStepId[] {
return demoMode ? DEMO_STEP_ORDER : FULL_STEP_ORDER
}
const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
nav_logs: '[data-tour="nav-logs"]',
entry_list: '[data-tour="entry-list"]',
entry_open: '[data-tour="entry-first"]',
entry_track: '[data-tour="entry-track"]',
nav_vessel: '[data-tour="nav-vessel"]',
nav_crew: '[data-tour="nav-crew"]'
profile_vessel_pool: '[data-tour="profile-vessel-pool"]',
profile_crew_pool: '[data-tour="profile-crew-pool"]',
nav_logbook_crew: '[data-tour="nav-logbook-crew"]',
nav_stats: '[data-tour="stats-dashboard"]',
nav_feedback: '[data-tour="feedback-form"]',
nav_profile: '[data-tour="nav-profile"]',
profile_preferences: '[data-tour="profile-preferences"]'
}
/** Whether a tour step opens the first log entry editor (not the list card). */
export function tourStepOpensEntry(stepId: TourStepId): boolean {
return stepId === 'entry_track'
}
export function getTourTargetDelay(stepId: TourStepId): number {
if (stepId === 'entry_track') return 400
if (stepId === 'nav_feedback') return 180
if (
stepId === 'nav_profile' ||
stepId === 'profile_preferences' ||
stepId === 'profile_vessel_pool' ||
stepId === 'profile_crew_pool'
) {
return 250
}
return 0
}
/** Extra scroll attempts while async UI (e.g. entry editor) mounts. */
export function getTourScrollRetryDelays(stepId: TourStepId): number[] {
if (stepId === 'entry_track') return [400, 700, 1100, 1600]
const initial = getTourTargetDelay(stepId)
return initial > 0 ? [initial] : [0]
}
const AppTourContext = createContext<AppTourContextValue | null>(null)
@@ -82,11 +159,13 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
const [stepIndex, setStepIndex] = useState(0)
const [pendingAfterLogin, setPendingAfterLogin] = useState(false)
const [isDemoTour, setIsDemoTour] = useState(false)
const [layoutTick, setLayoutTick] = useState(0)
const navigationRef = useRef<TourNavigation | null>(null)
const demoContextRef = useRef<DemoTourContext | null>(null)
const tourModeRef = useRef<{ demoMode: boolean }>({ demoMode: false })
const currentStepId = isActive ? STEP_ORDER[stepIndex] ?? null : null
const stepOrder = getStepOrder(isDemoTour)
const currentStepId = isActive ? stepOrder[stepIndex] ?? null : null
const resolveFirstEntryId = useCallback((): string | null => {
return demoContextRef.current?.firstEntryId ?? getStoredDemoFirstEntryId()
@@ -96,31 +175,77 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
const nav = navigationRef.current
if (!nav) return
if (LOGBOOK_TOUR_STEPS.has(stepId)) {
nav.setProfileOpen(false)
nav.setLogbookActive(true)
}
if (stepId === 'nav_logs' || stepId === 'entry_list' || stepId === 'entry_open' || stepId === 'entry_track') {
nav.setActiveTab('logs')
}
if (stepId === 'entry_open' || stepId === 'entry_track') {
if (stepId === 'entry_list' || stepId === 'entry_open') {
nav.setSelectedEntryId(null)
} else if (tourStepOpensEntry(stepId)) {
const firstEntryId = resolveFirstEntryId()
if (firstEntryId) nav.setSelectedEntryId(firstEntryId)
} else if (LOGBOOK_TOUR_STEPS.has(stepId)) {
nav.setSelectedEntryId(null)
}
if (stepId === 'nav_vessel') {
nav.setSelectedEntryId(null)
nav.setActiveTab('vessel')
}
if (stepId === 'nav_crew') {
if (stepId === 'profile_vessel_pool' || stepId === 'profile_crew_pool') {
nav.setSelectedEntryId(null)
nav.setLogbookActive(false)
nav.setProfileOpen(true)
}
if (stepId === 'nav_logbook_crew') {
nav.setSelectedEntryId(null)
nav.setProfileOpen(false)
nav.setLogbookActive(true)
nav.setActiveTab('crew')
}
if (stepId === 'nav_stats') {
nav.setSelectedEntryId(null)
nav.setActiveTab('stats')
}
if (stepId === 'nav_feedback') {
nav.setSelectedEntryId(null)
nav.setFeedbackOpen(true)
} else {
nav.setFeedbackOpen(false)
}
if (stepId === 'nav_profile') {
nav.setProfileOpen(false)
nav.setLogbookActive(false)
}
if (stepId === 'profile_preferences') {
nav.setLogbookActive(false)
nav.setProfileOpen(true)
}
}, [resolveFirstEntryId])
const scrollToCurrentTarget = useCallback((stepId: TourStepId | null) => {
if (!stepId) return
const selector = TARGET_BY_STEP[stepId]
if (!selector) return
window.requestAnimationFrame(() => {
const el = document.querySelector(selector)
el?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
})
for (const delayMs of getTourScrollRetryDelays(stepId)) {
window.setTimeout(() => {
window.requestAnimationFrame(() => {
const el = document.querySelector(selector)
el?.scrollIntoView({
behavior: stepId === 'entry_track' ? 'instant' : 'smooth',
block: 'center',
inline: 'nearest'
})
})
}, delayMs)
}
}, [])
const startTour = useCallback((options?: { force?: boolean; demoMode?: boolean }) => {
@@ -142,12 +267,21 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
const tourProps = tourModeRef.current.demoMode ? { mode: 'demo' } : undefined
if (outcome === 'completed') {
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED, tourProps)
const nav = navigationRef.current
if (nav && !tourModeRef.current.demoMode) {
nav.setProfileOpen(false)
nav.setLogbookActive(true)
nav.setSelectedEntryId(null)
nav.setActiveTab('stats')
}
} else {
const step = STEP_ORDER[stepIndexAtDismiss] ?? 'welcome'
const step = getStepOrder(tourModeRef.current.demoMode)[stepIndexAtDismiss] ?? 'welcome'
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_SKIPPED, { step, ...tourProps })
}
tourModeRef.current = { demoMode: false }
navigationRef.current?.setFeedbackOpen(false)
navigationRef.current?.setProfileOpen(false)
setIsDemoTour(false)
setIsActive(false)
setStepIndex(0)
@@ -162,12 +296,13 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
}, [dismissTour, stepIndex])
const nextStep = useCallback(() => {
if (stepIndex + 1 >= STEP_ORDER.length) {
const order = getStepOrder(isDemoTour)
if (stepIndex + 1 >= order.length) {
dismissTour('completed', stepIndex)
return
}
setStepIndex(stepIndex + 1)
}, [dismissTour, stepIndex])
}, [dismissTour, isDemoTour, stepIndex])
const prevStep = useCallback(() => {
setStepIndex((current) => Math.max(0, current - 1))
@@ -175,11 +310,28 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
useEffect(() => {
if (!isActive) return
const stepId = STEP_ORDER[stepIndex]
const stepId = getStepOrder(isDemoTour)[stepIndex]
if (!stepId) return
applyStepSideEffects(stepId)
scrollToCurrentTarget(stepId)
}, [isActive, stepIndex, applyStepSideEffects, scrollToCurrentTarget])
let cancelled = false
const run = async () => {
if (LOGBOOK_TOUR_STEPS.has(stepId) && !isDemoTour) {
await navigationRef.current?.ensureLogbookForTour?.()
}
if (cancelled) return
applyStepSideEffects(stepId)
scrollToCurrentTarget(stepId)
setLayoutTick((tick) => tick + 1)
window.setTimeout(() => {
if (!cancelled) setLayoutTick((tick) => tick + 1)
}, 150)
}
void run()
return () => {
cancelled = true
}
}, [isActive, isDemoTour, stepIndex, applyStepSideEffects, scrollToCurrentTarget])
const restartTour = useCallback(() => {
const userId = localStorage.getItem('active_userid')
@@ -220,7 +372,8 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
isDemoTour,
currentStepId,
currentStepIndex: stepIndex,
totalSteps: STEP_ORDER.length,
totalSteps: stepOrder.length,
layoutTick,
startTour,
stopTour,
restartTour,
@@ -244,6 +397,8 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
skipTour,
startTour,
stepIndex,
stepOrder.length,
layoutTick,
stopTour
]
)
@@ -284,3 +439,10 @@ export function getTourTargetSelector(stepId: TourStepId | null): string | null
export function isCenteredTourStep(stepId: TourStepId | null): boolean {
return stepId === 'welcome' || stepId === 'finish'
}
export function getTourTargetRetryDelay(stepId: TourStepId | null): number {
if (stepId === 'entry_track') return 400
if (stepId === 'profile_preferences') return 300
if (stepId === 'nav_profile') return 200
return 120
}
@@ -0,0 +1,122 @@
import {
createContext,
useContext,
useCallback,
useEffect,
useRef,
useMemo,
type ReactNode
} from 'react'
import { useTranslation } from 'react-i18next'
import { useDialog } from '../components/ModalDialog.tsx'
interface UnsavedChangesContextValue {
setDirty: (source: string, dirty: boolean) => void
registerSaveHandler: (source: string, handler: (() => Promise<void>) | null) => void
confirmLeave: () => Promise<boolean>
}
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null)
export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation()
const { showConfirmLeave, showAlert } = useDialog()
const dirtySources = useRef(new Set<string>())
const saveHandlers = useRef(new Map<string, () => Promise<void>>())
const setDirty = useCallback((source: string, dirty: boolean) => {
if (dirty) dirtySources.current.add(source)
else dirtySources.current.delete(source)
}, [])
const registerSaveHandler = useCallback((source: string, handler: (() => Promise<void>) | null) => {
if (handler) saveHandlers.current.set(source, handler)
else saveHandlers.current.delete(source)
}, [])
const confirmLeave = useCallback(async (): Promise<boolean> => {
if (dirtySources.current.size === 0) return true
const canSave = [...dirtySources.current].some((source) => saveHandlers.current.has(source))
const choice = await showConfirmLeave(
t('common.unsaved_changes_message'),
t('common.unsaved_changes_title'),
t('common.unsaved_changes_stay'),
t('common.unsaved_changes_save_leave'),
t('common.unsaved_changes_discard'),
{ showSave: canSave }
)
if (choice === 'stay') return false
if (choice === 'discard') return true
const handlers = [...dirtySources.current]
.map((source) => saveHandlers.current.get(source))
.filter((handler): handler is () => Promise<void> => handler != null)
try {
for (const handler of handlers) {
await handler()
}
return true
} catch (err) {
console.error('Failed to save before leaving:', err)
await showAlert(t('errors.save_failed'))
return false
}
}, [showConfirmLeave, showAlert, t])
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (dirtySources.current.size === 0) return
e.preventDefault()
e.returnValue = ''
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [])
const value = useMemo(
() => ({ setDirty, registerSaveHandler, confirmLeave }),
[setDirty, registerSaveHandler, confirmLeave]
)
return (
<UnsavedChangesContext.Provider value={value}>
{children}
</UnsavedChangesContext.Provider>
)
}
export function useUnsavedChangesContext(): UnsavedChangesContextValue {
const ctx = useContext(UnsavedChangesContext)
if (!ctx) {
throw new Error('useUnsavedChangesContext must be used within UnsavedChangesProvider')
}
return ctx
}
/** Register a form/view as having unsaved changes (cleared automatically on unmount). */
export function useRegisterUnsavedChanges(
source: string,
isDirty: boolean,
onSave?: () => Promise<void>
) {
const { setDirty, registerSaveHandler, confirmLeave } = useUnsavedChangesContext()
useEffect(() => {
setDirty(source, isDirty)
return () => setDirty(source, false)
}, [source, isDirty, setDirty])
useEffect(() => {
if (!onSave) {
registerSaveHandler(source, null)
return
}
registerSaveHandler(source, onSave)
return () => registerSaveHandler(source, null)
}, [source, onSave, registerSaveHandler])
return { confirmLeave }
}
+92 -16
View File
@@ -1,11 +1,19 @@
import { useEffect, useRef } from 'react'
import { useRegisterSW } from 'virtual:pwa-register/react'
import {
forcePwaRecovery,
markReloadAttempt,
recentlyAttemptedReload,
triggerServiceWorkerUpdate
} from '../services/pwaStartup.js'
import { isDeployedVersionNewer } from '../services/pwaVersion.js'
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000
const UPDATE_CHECK_INTERVAL_MS = 15 * 60 * 1000
const UPDATE_SUPPRESS_KEY = 'pwa_update_suppress_until'
const UPDATE_SUPPRESS_MS = 30_000
const UPDATE_DISMISS_SUPPRESS_MS = 60 * 60 * 1000
const UPDATE_RELOAD_FALLBACK_MS = 2000
const UPDATE_DISMISS_SUPPRESS_MS = 15 * 60 * 1000
const UPDATE_RELOAD_FALLBACK_MS = 2_000
const UPDATE_HARD_RECOVERY_MS = 5_000
function isUpdateSuppressed(): boolean {
const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0')
@@ -20,10 +28,16 @@ function clearUpdateSuppression(): void {
sessionStorage.removeItem(UPDATE_SUPPRESS_KEY)
}
function scheduleUpdateChecks(registration: ServiceWorkerRegistration): () => void {
function scheduleUpdateChecks(
registration: ServiceWorkerRegistration,
onOutdated: () => void
): () => void {
const checkForUpdate = () => {
if (isUpdateSuppressed()) return
registration.update().catch(() => {})
void isDeployedVersionNewer().then((outdated) => {
if (outdated) onOutdated()
})
}
const onVisibilityChange = () => {
@@ -32,52 +46,101 @@ function scheduleUpdateChecks(registration: ServiceWorkerRegistration): () => vo
}
}
const onOnline = () => {
checkForUpdate()
}
document.addEventListener('visibilitychange', onVisibilityChange)
const intervalId = window.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS)
window.addEventListener('online', onOnline)
const updateIntervalId = window.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS)
checkForUpdate()
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange)
window.clearInterval(intervalId)
window.removeEventListener('online', onOnline)
window.clearInterval(updateIntervalId)
}
}
function reloadForServiceWorkerTakeover(): void {
if (recentlyAttemptedReload()) return
markReloadAttempt()
clearUpdateSuppression()
window.location.reload()
}
export function usePwaUpdate() {
const cleanupRef = useRef<(() => void) | null>(null)
const reloadFallbackTimerRef = useRef<number | null>(null)
const forceRecoveryTimerRef = useRef<number | null>(null)
const setNeedRefreshRef = useRef<((value: boolean) => void) | null>(null)
const pendingNeedRefreshRef = useRef<boolean | null>(null)
const applyNeedRefresh = (value: boolean) => {
if (setNeedRefreshRef.current) {
setNeedRefreshRef.current(value)
return
}
pendingNeedRefreshRef.current = value
}
const {
needRefresh: [needRefresh, setNeedRefresh],
updateServiceWorker
} = useRegisterSW({
immediate: true,
immediate: !import.meta.env.DEV,
onNeedReload() {
clearUpdateSuppression()
setNeedRefresh(false)
window.location.reload()
if (isUpdateSuppressed()) return
applyNeedRefresh(true)
},
onNeedRefresh() {
if (isUpdateSuppressed()) return
setNeedRefresh(true)
applyNeedRefresh(true)
},
onRegisteredSW(_swUrl: string, registration: ServiceWorkerRegistration | undefined) {
if (!registration) return
if (isUpdateSuppressed() || !registration.waiting) {
setNeedRefresh(false)
applyNeedRefresh(false)
}
cleanupRef.current?.()
cleanupRef.current = scheduleUpdateChecks(registration)
cleanupRef.current = scheduleUpdateChecks(registration, () => {
if (isUpdateSuppressed()) return
applyNeedRefresh(true)
})
}
})
setNeedRefreshRef.current = setNeedRefresh
useEffect(() => {
if (isUpdateSuppressed()) {
setNeedRefresh(false)
} else if (pendingNeedRefreshRef.current !== null) {
const pending = pendingNeedRefreshRef.current
pendingNeedRefreshRef.current = null
setNeedRefresh(pending)
}
void isDeployedVersionNewer().then((outdated) => {
if (outdated) {
setNeedRefresh(true)
}
})
return () => {
cleanupRef.current?.()
cleanupRef.current = null
if (reloadFallbackTimerRef.current !== null) {
window.clearTimeout(reloadFallbackTimerRef.current)
reloadFallbackTimerRef.current = null
}
if (forceRecoveryTimerRef.current !== null) {
window.clearTimeout(forceRecoveryTimerRef.current)
forceRecoveryTimerRef.current = null
}
}
}, [setNeedRefresh])
@@ -86,11 +149,24 @@ export function usePwaUpdate() {
suppressUpdatePrompt()
await updateServiceWorker(true)
await triggerServiceWorkerUpdate()
// vite-plugin-pwa reloads via the "controlling" event; fallback if that does not fire.
window.setTimeout(() => {
window.location.reload()
if (reloadFallbackTimerRef.current !== null) {
window.clearTimeout(reloadFallbackTimerRef.current)
}
if (forceRecoveryTimerRef.current !== null) {
window.clearTimeout(forceRecoveryTimerRef.current)
}
reloadFallbackTimerRef.current = window.setTimeout(() => {
reloadFallbackTimerRef.current = null
reloadForServiceWorkerTakeover()
}, UPDATE_RELOAD_FALLBACK_MS)
forceRecoveryTimerRef.current = window.setTimeout(() => {
forceRecoveryTimerRef.current = null
void forcePwaRecovery()
}, UPDATE_HARD_RECOVERY_MS)
}
const dismissUpdate = () => {
+48
View File
@@ -0,0 +1,48 @@
import { useEffect, useState } from 'react'
import { useLiveQuery } from 'dexie-react-hooks'
import { db } from '../services/db.js'
import { subscribeToSyncState } from '../services/sync.js'
export type SyncConnStatusVariant = 'offline' | 'syncing' | 'pending' | 'online'
/** Maps sync/online state to conn-status CSS modifier classes. */
export function syncConnStatusClassName(
online: boolean,
showSpinner: boolean,
pendingCount: number
): string {
if (!online) return 'conn-status offline'
if (showSpinner) return 'conn-status syncing'
if (pendingCount > 0) return 'conn-status warning'
return 'conn-status online'
}
/** Sync queue depth and whether a sync pass is running (for header indicators). */
export function useSyncIndicator(logbookId?: string | null) {
const [isSyncing, setIsSyncing] = useState(false)
const pendingCount =
useLiveQuery(
() =>
logbookId
? db.syncQueue.where({ logbookId }).count()
: db.syncQueue.count(),
[logbookId]
) ?? 0
useEffect(() => {
return subscribeToSyncState(setIsSyncing)
}, [])
const showSpinner = isSyncing
const showPendingWarning = pendingCount > 0 && !isSyncing
return {
isSyncing,
pendingCount,
showSpinner,
showPendingWarning,
connStatusClassName: (online: boolean) =>
syncConnStatusClassName(online, showSpinner, pendingCount)
}
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import deJson from './locales/de.json'
import enJson from './locales/en.json'
const resources = {
de: { translation: deJson.translation },
en: { translation: enJson.translation }
}
describe('course dial i18n keys', () => {
it.each([
'logs.event_course_section',
'logs.course_tab_mgk',
'logs.course_tab_rwk',
'logs.course_dial_hint',
'logs.course_step_fine',
'logs.wind_mode_cardinal'
])('resolves %s in de and en bundles', async (key) => {
const { default: i18n } = await import('i18next')
await i18n.init({ lng: 'de', resources, defaultNS: 'translation' })
expect(i18n.t(key)).not.toBe(key)
await i18n.changeLanguage('en')
expect(i18n.t(key)).not.toBe(key)
})
})
+25 -7
View File
@@ -1,25 +1,43 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import enTranslation from './locales/en.json'
import deTranslation from './locales/de.json'
import enJson from './locales/en.json'
import deJson from './locales/de.json'
import daJson from './locales/da.json'
import svJson from './locales/sv.json'
import nbJson from './locales/nb.json'
import { initSeo } from '../utils/seo.js'
import { SUPPORTED_LANGUAGES } from '../utils/i18nLanguages.js'
/** JSON files wrap strings in `translation` — register that namespace explicitly. */
const resources = {
en: { translation: enJson.translation },
de: { translation: deJson.translation },
da: { translation: daJson.translation },
sv: { translation: svJson.translation },
nb: { translation: nbJson.translation }
}
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: enTranslation,
de: deTranslation
},
resources,
defaultNS: 'translation',
fallbackLng: 'en',
supportedLngs: [...SUPPORTED_LANGUAGES],
nonExplicitSupportedLngs: true,
load: 'languageOnly',
interpolation: {
escapeValue: false // React already escapes values (prevents XSS)
},
detection: {
order: ['localStorage', 'navigator'],
order: ['querystring', 'localStorage', 'navigator'],
lookupQuerystring: 'lng',
caches: ['localStorage']
}
})
initSeo(i18n)
export default i18n
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import deJson from '../i18n/locales/de.json'
import enJson from '../i18n/locales/en.json'
import daJson from '../i18n/locales/da.json'
import svJson from '../i18n/locales/sv.json'
import nbJson from '../i18n/locales/nb.json'
function collectKeys(obj: Record<string, unknown>, prefix = ''): string[] {
const keys: string[] = []
for (const [key, value] of Object.entries(obj)) {
const path = prefix ? `${prefix}.${key}` : key
if (value && typeof value === 'object' && !Array.isArray(value)) {
keys.push(...collectKeys(value as Record<string, unknown>, path))
} else {
keys.push(path)
}
}
return keys.sort()
}
const bundles = {
de: deJson.translation,
en: enJson.translation,
da: daJson.translation,
sv: svJson.translation,
nb: nbJson.translation
} as const
describe('i18n locale key parity', () => {
const masterKeys = collectKeys(bundles.de)
it.each(Object.keys(bundles).filter((lang) => lang !== 'de'))(
'%s has the same keys as de',
(lang) => {
const keys = collectKeys(bundles[lang as keyof typeof bundles])
expect(keys).toEqual(masterKeys)
}
)
})
+990
View File
@@ -0,0 +1,990 @@
{
"translation": {
"app": {
"name": "Kapteins Daagbok",
"tagline": "Privat yacht-logbog",
"beta": "Beta",
"beta_hint": "Betaversion - funktioner kan stadig ændres"
},
"languages": {
"de": "Deutsch",
"en": "English",
"da": "Dansk",
"sv": "Svenska",
"nb": "Norsk"
},
"dialog": {
"ok": "OK",
"yes": "Ja",
"no": "Nej"
},
"errors": {
"load_failed": "Data kunne ikke indlæses.",
"save_failed": "Ændringer kunne ikke gemmes.",
"delete_failed": "Sletning mislykkedes.",
"export_failed": "Eksport mislykkedes."
},
"common": {
"unsaved_changes_title": "Ikke gemte ændringer",
"unsaved_changes_message": "Du har ændringer, der ikke er gemt. Vil du virkelig forlade siden? Dine ændringer vil gå tabt.",
"unsaved_changes_stay": "Bliv her",
"unsaved_changes_save_leave": "Gem og forlad",
"unsaved_changes_discard": "Kassér",
"unsaved_changes_leave": "Forladelse"
},
"nav": {
"dashboard": "Dashboard",
"vessel": "Skibsdata",
"crew": "Crew",
"deviation": "Tabel over distraktioner",
"logs": "Indlæg i logbogen",
"stats": "Statistik",
"settings": "Indstillinger"
},
"auth": {
"welcome": "Velkommen til Kapteins Daagbok.",
"tagline": "Din sikre, E2E-krypterede maritime logbog.",
"register": "Registrer dig med Passkey.",
"login": "Log ind med Passkey.",
"login_as": "Log ind som {{name}}",
"quick_login": "Hurtigt login",
"forget_account": "Glemt konto på denne enhed",
"not_user": "Ikke {{name}}?",
"recovery_title": "Din genoprettelsesnøgle",
"recovery_warning": "VIGTIGT: Skriv disse 12 ord ned. Hvis du mister din Passkey og disse ord, kan dine data ikke gendannes.",
"confirm_recovery": "Jeg har skrevet ordene ned",
"status_logged_in": "Logget ind",
"status_logged_out": "Aflyst",
"copied": "Kopieret!",
"copy_phrase": "Kopieringstast",
"enter_recovery": "Indtast genoprettelsesnøgle",
"recovery_fallback_warning": "Din Passkey er blevet godkendt, men din enhed understøtter ikke hardwarebaseret nøgleafledning. Indtast din genoprettelsesnøgle på 12 ord for at dekryptere din logbog.",
"recovery_placeholder": "Indtast din genoprettelsesnøgle, som består af 12 ord adskilt af mellemrum...",
"back": "Tilbage",
"decrypting": "Dekryptering...",
"decrypt_logbook": "Afkodning af logbog",
"error_incorrect_recovery": "Forkert genoprettelsesnøgle. Dekryptering mislykkedes.",
"error_decryption_failed": "Dekryptering mislykkedes. Tjek venligst din genoprettelsesnøgle.",
"or_register": "eller registrer dig",
"explore_demo": "Udforsk demoen uden en konto",
"username_placeholder": "Brugernavn / skippernavn",
"processing": "Behandling...",
"help": "Hjælp",
"setup_pin_title": "Opsæt lokal PIN-kode (valgfrit)",
"setup_pin_warning": "Da din enhed ikke understøtter direkte Passkey-nøgleafledning, ville du ellers være nødt til at indtaste din 12-ordsnøgle, hver gang du logger ind på denne enhed. Opsæt en lokal PIN-kode for at undgå dette.",
"pin_placeholder": "E.G. 123456",
"pin_label": "Lokal PIN-kode (4-8 cifre)",
"save_pin": "Gem PIN-kode og fortsæt",
"skip_pin": "Spring over og brug gendannelse",
"enter_pin_title": "Afkodning med PIN-kode",
"enter_pin_warning": "Indtast din lokale PIN-kode for at låse op for dekrypteringsnøglen på denne enhed.",
"enter_pin_placeholder": "Indtast din pinkode...",
"decrypt_with_pin": "Afkodning",
"use_recovery_instead": "Brug genoprettelsesnøgler i stedet",
"error_incorrect_pin": "Forkert PIN-kode. Dekryptering mislykkedes.",
"error_invalid_host": "Passkeys virker ikke via 127.0.0.1. Åbn appen via localhost.",
"use_localhost_link": "Skift til localhost",
"error_passkey_cancelled": "Passkey-login blev annulleret eller udløb. Prøv igen.",
"error_invalid_rp_id": "Passkey-domæne matcher ikke (RP ID). Brug http://localhost:5173 med RP_ID=localhost i .env til lokal udvikling.",
"error_session_incomplete": "Login ufuldstændig. Log ind med passkey igen."
},
"pwa": {
"title": "Installer app",
"generic_benefit": "Installer Kapteins Daagbok på din enhed for at få hurtigere adgang, offline-brug og permanent datalagring.",
"ios_instructions": "På iPad/iPhone: Føj appen til startskærmen, så dine logbogsdata forbliver beskyttet, og appen starter som en indbygget app.",
"ios_step_share": "Tryk på aktiesymbolet i Safari-linjen",
"ios_step_add": "Vælg \"Gå til startskærm\"",
"install_now": "Installer nu",
"installing": "Installation...",
"later": "Senere",
"never": "Vis ikke mere",
"platform_ios": "Installation via Safari.",
"platform_android": "Installation via browseren",
"platform_desktop": "Installation som desktop-app",
"settings_section": "Installation af app",
"update_title": "Opdatering tilgængelig",
"update_desc": "En ny version af Kapteins Daagbok er klar. Opdater venligst for at få de seneste ændringer.",
"update_now": "Opdater nu",
"update_reloading": "Indlæser...",
"storage_persist_hint": "Browseren kan slette offline-data. Tillad permanent lagring, så din logbog forbliver beskyttet."
},
"sync": {
"status_synced": "Synkroniseret",
"status_syncing": "Synkroniser...",
"status_offline": "Offline-cache",
"status_unsynced": "Usynkroniserede ændringer",
"conflict_title": "Synkroniseringskonflikt",
"conflict_message": "{{count}} ændring(er) kunne ikke synkroniseres (post {{id}}…). Vælg hvilken version der skal gælde.",
"conflict_use_server": "Brug serverversion",
"conflict_keep_local": "Behold min version"
},
"vessel": {
"title": "Skibets stamdata",
"name": "Yacht-navn",
"type": "Yacht-type",
"type_unset": "- ikke specificeret -",
"type_sailing": "Sejlbåd",
"type_motor": "Motorbåd",
"length_m": "Længde (m)",
"draft_m": "Dybgang (m)",
"air_draft_m": "Højde (m)",
"invalid_metric": "Ugyldig numerisk værdi - indtast venligst meter som et decimaltal (f.eks. 12,5).",
"port": "Hjemmehavn",
"owner": "Ejer",
"charter": "Charterselskab",
"registration": "Nummerplade/registreringsnummer",
"callsign": "Radiokaldesignal",
"atis": "ATIS nr.",
"mmsi": "MMSI-nr.",
"save": "Gem skibsdata",
"saving": "Vil blive reddet...",
"saved": "Skibsdata er gemt med succes!",
"loading": "Skibsdata er indlæst...",
"sails_list": "Sejl (eksisterende sejl)",
"sails_help": "Indtast de sejl, der er tilgængelige på din båd her (f.eks. storsejl, genua, fok).",
"add_sail": "Tilføj sejl",
"sail_name_placeholder": "z. f.eks. storsejl",
"no_sails": "Ingen sejl opbevaret.",
"photo_add": "Tilføj foto",
"photo_change": "Skift foto",
"photo_delete": "Slet foto",
"tanks_section": "Tanke (kapacitet)",
"tanks_help": "Valgfrit i liter - muliggør slider i journalen for kendte tankstørrelser.",
"freshwater_capacity_l": "Drikkevand (liter)",
"fuel_capacity_l": "Brændstof (liter)",
"greywater_capacity_l": "Gråt vand (liter)",
"invalid_tank_liters": "Ugyldig numerisk værdi - indtast venligst liter som et tal (f.eks. 200)."
},
"logs": {
"title": "Logbogsdagbog",
"new_entry": "Ny rejsedag",
"travel_details": "Detaljer om rejsen",
"add_event": "Tilføj ny logbogspost",
"add_event_btn": "Tilføj begivenhed",
"edit_event": "Rediger begivenhed",
"save_event_btn": "Gem ændring",
"cancel_event_edit": "Annuller",
"delete_event": "Slet begivenhed",
"sign_cleared_skipper_re_sign_title": "Skippers underskrift fjernet",
"sign_cleared_skipper_re_sign": "Hændelsesloggen er blevet ændret. Skipperens underskrift er blevet fjernet. Godkend venligst igen.",
"date": "dato",
"day_of_travel": "Rejsedag",
"travel_day_number": "Rejsedag {{number}}",
"departure": "Starthavn (rejse fra)",
"destination": "Destinationsport (til)",
"route": "Rejse fra/til",
"freshwater": "Ferskvand (liter)",
"fuel": "Treibstoff / Brændstof (liter)",
"greywater": "Gråt vand (liter)",
"greywater_level": "Fyldningsniveau",
"tank_slider_of_max": "{{current}} / {{max}} L",
"tank_capacity_tooltip": "Hvis tankkapaciteten (liter) er gemt i skibsdataene, kan du indtaste fyldningsniveauerne her ved hjælp af skyderen.",
"morning": "Stå op om morgenen",
"refilled": "Genopfyldt",
"evening": "Stand om aftenen",
"consumption": "Dagligt forbrug",
"signatures": "Underskrifter / frigivelse",
"sign_skipper": "Skippers underskrift",
"sign_crew": "Crew-signatur",
"sign_hint": "Tegn med finger, pen eller mus",
"sign_clear": "Sletning",
"sign_export_image": "[Underskrift]",
"sign_with_passkey": "Frigør med Passkey.",
"sign_passkey_signing": "Der anmodes om Passkey...",
"sign_passkey_signed": "Udgivet af {{username}}",
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
"sign_attribution_export": "{{username}} ({{date}})",
"sign_passkey_clear": "Fjern Passkey-frigivelse",
"sign_mode_passkey": "Passkey",
"sign_mode_classic": "Klassisk",
"sign_passkey_failed": "Passkey Frigivelse mislykkedes",
"sign_passkey_cancelled": "Passkey Frigivelse annulleret",
"sign_invalid": "Signatur ugyldig - indholdet er blevet ændret",
"sign_badge_skipper": "Skipper",
"sign_badge_skipper_invalid": "Ugyldig",
"sign_badge_skipper_title_valid": "Skipper har udgivet",
"sign_badge_skipper_title_invalid": "Skippers signatur er ugyldig - indholdet er blevet ændret",
"sign_classic_or_passkey": "Valgfrit: klassisk underskrift eller Passkey-frigivelse ovenfor",
"sign_crew_passkey_hint": "Crew-medlemmer med skriveadgang kan frigive via Passkey.",
"sign_offline_hint": "Passkey-Godkendelse kræver internet - klassisk underskrift mulig offline",
"sign_lock_notice": "Efter underskrivelsen kan der ikke foretages ændringer i logbogen (undtagen fotos), uden at skipper og crew skal skrive under igen.",
"sign_lock_active": "Denne post er underskrevet. Ændringer i logbogen (undtagen fotos) fjerner automatisk skipperens og crews underskrifter.",
"sign_lock_warning_title": "Bekræft underskrift",
"sign_lock_warning": "Efter underskrivelsen er det ikke længere muligt at foretage ændringer i logbogen (undtagen fotos), uden at skipper og crew skal skrive under igen.\n\nVil du gerne fortsætte?",
"sign_proceed": "Tegn",
"sign_cancel": "Annuller",
"sign_cleared_re_sign_title": "Underskrifter fjernet",
"sign_cleared_re_sign": "Logbogsoptegnelsen er blevet ændret. Skipperens og crews underskrifter er blevet fjernet. Underskriv venligst igen.",
"no_entries": "Ingen logbogsposter fundet for denne yacht. Opret din første rejsedag!",
"back_to_list": "Tilbage til tidsskriftslisten",
"save": "Gem logbogsside",
"saving": "Vil blive reddet...",
"saved": "Logbogsside gemt med succes!",
"loading": "Dagbogen er ved at blive indlæst.",
"view_mode_label": "Visning",
"view_list": "Liste",
"live_mode": "Live",
"live_title": "Live-journal",
"live_loading": "Live-journal indlæses...",
"live_retry": "Prøv igen",
"live_load_error": "Live-journal kunne ikke indlæses.",
"live_action_error": "Indtastning kunne ikke gemmes.",
"live_open_editor": "Fuld editor",
"live_actions_label": "Hurtighandlinger",
"live_stream_label": "Hændelseslog",
"live_stream_title": "Journal",
"live_no_events": "Ingen indtastninger endnu — tryk på en handling.",
"live_motor_start": "Motor Start",
"live_motor_stop": "Motor Stop",
"live_cast_off": "Afsejling",
"live_moor": "Anløb",
"live_sails_btn": "Sejl",
"live_sails_pick": "Vælg sejl",
"live_sails_pick_hint": "Tryk på flere sejl (tryk igen for at fravælge), og indtast derefter.",
"live_sails_selected": "Valgt: {{sails}}",
"live_sails_confirm": "Indtast",
"live_sails_confirm_count": "Indtast ({{count}})",
"live_sails": "Sejl: {{sails}}",
"live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS ikke tilgængelig. Indtast bredde- og længdegrad manuelt, eller prøv igen med GPS-knappen.",
"live_fix_gps_loading": "Henter GPS-position…",
"live_fix_invalid": "Indtast gyldige koordinater (bredde 90…90, længde 180…180).",
"live_fix_lat_placeholder": "Bredde (Lat)",
"live_fix_lng_placeholder": "Længde (Lng)",
"live_photo_btn": "Foto (kamera)",
"live_photo_capture_btn": "Tag billede",
"live_photo_save_btn": "Gem",
"live_photo_retake_btn": "Tag igen",
"live_photo_capture_failed": "Optagelse mislykkedes. Prøv igen.",
"live_photo_open_camera_btn": "Åbn kamera",
"live_photo_native_hint": "Tag et foto med enhedens kamera og gem det her bagefter.",
"live_photo_camera_starting": "Starter kamera…",
"live_photo_camera_denied": "Kameraadgang nægtet eller utilgængelig.",
"live_photo_camera_unavailable": "Kamera understøttes ikke i denne browser.",
"live_photo_error": "Foto kunne ikke gemmes.",
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto taget",
"live_undo_photo_hint": "Foto gemt",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Indtast tekst…",
"live_comment_confirm": "Indtast",
"live_gps_error": "GPS-position kunne ikke bestemmes.",
"live_gps_start_hint": "Begynd altid dagens rejse med en position.",
"live_event_generic": "Hændelse",
"live_weather_btn": "Vejr",
"live_weather_owm_btn": "Hent OpenWeatherMap-vejr",
"live_weather_owm_loading": "Henter vejr…",
"live_weather_fix_required": "Log først en GPS-fix (Fix-knap) for at hente OpenWeatherMap-vejr. Positionen må højst være 6 timer gammel.",
"live_weather_fix_stale": "Den seneste GPS-fix er ældre end 6 timer. Log en ny fix, før du henter vejr.",
"live_wind_btn": "Vind",
"live_temp_btn": "T °C",
"live_pressure_btn": "Lufttryk",
"live_precip_btn": "Nedbør",
"live_sea_state_btn": "Søgang",
"live_visibility_btn": "Sigtbarhed",
"live_course_btn": "Kurs",
"live_fuel_btn": "Diesel",
"live_water_btn": "Vand",
"live_wind_entry": "Vind {{value}}",
"live_temp_entry": "Temperatur {{temp}} °C",
"live_pressure_entry": "Lufttryk {{value}} hPa",
"live_precip_entry": "Nedbør {{value}}",
"live_sea_state_entry": "Søgang {{value}}",
"live_visibility_entry": "Sigtbarhed {{value}}",
"live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Vand +{{liters}} L",
"live_auto_position": "Auto-position",
"live_undo_hint": "Indtastning gemt",
"live_undo_btn": "Fortryd",
"live_pressure_placeholder": "f.eks. 1013",
"live_temp_placeholder": "f.eks. 18",
"live_precip_placeholder": "f.eks. let regn",
"live_sea_state_placeholder": "f.eks. 3",
"live_visibility_placeholder": "f.eks. 10 km",
"live_course_placeholder": "f.eks. 245",
"live_fuel_placeholder": "Optankede liter",
"live_water_placeholder": "Optankede liter",
"live_sog_btn": "SOG",
"live_stw_btn": "STW",
"live_sog_entry": "SOG {{speed}} kn",
"live_stw_entry": "STW {{speed}} kn",
"live_sog_placeholder": "f.eks. 5,2",
"live_stw_placeholder": "f.eks. 4,8",
"live_sog_hint": "Fart over grund (kn) — GPS-værdi forudfyldes, hvis tilgængelig.",
"delete_entry": "Slet tag",
"delete_confirm": "Er du sikker på, at du vil slette denne rejsedag permanent?",
"carry_over_tanks_title": "Overføre data fra den foregående dag?",
"carry_over_tanks_confirm": "Overtage starthavn, ferskvand, brændstof og gråvand fra den sidste dag på turen?\n\nStarthavn: {{departure}}\nFerskvand: {{fw}} L\nBrændstof: {{fuel}} L\nGråt vand: {{greywater}} L",
"carry_over_tanks_yes": "Tag over",
"carry_over_tanks_no": "Start med 0",
"event_title": "Kronologisk hændelseslog",
"no_events": "Der er endnu ikke indtastet nogen begivenheder for denne rejsedag.",
"event_time": "Tidspunkt på dagen",
"event_mgk": "MgK-kursus",
"event_rwk": "RwK-kursus",
"event_course_section": "Kursus",
"course_dial_hint": "Drej ringen eller indtast grader",
"course_dial_step_label": "Trinstørrelse",
"course_step_fine": "1°",
"course_step_medium": "5°",
"course_step_coarse": "10°",
"course_tab_mgk": "MgK",
"course_tab_rwk": "rwK",
"course_invalid": "Ugyldigt kursus (0-360)",
"course_placeholder_degrees": "z. B. 180",
"course_placeholder_cardinal": "z. E.G. NW",
"compass_n": "N",
"compass_e": "O",
"compass_s": "S",
"compass_w": "W",
"wind_mode_cardinal": "Kardinal",
"wind_mode_degrees": "Som grad",
"event_wind_direction": "Vindretning",
"event_wind_strength": "Vindstyrke",
"event_sea_state": "Havets tilstand",
"event_visibility": "Sigtbarhed",
"event_visibility_placeholder": "f.eks. 10 km",
"weather_slider_unset": "—",
"weather_slider_pressure": "{{value}} hPa",
"weather_slider_sea_state": "Trin {{value}}",
"weather_slider_heel": "{{value}}°",
"event_weather": "Vejret",
"event_log": "Log (sm)",
"event_gps": "GPS-position",
"event_location": "Sted/havn",
"event_location_placeholder": "z. f.eks. Kiel",
"event_remarks": "Bemærkninger / hændelser",
"gps_btn": "Hent GPS-koordinater",
"weather_btn": "OpenWeatherMap Kald vejret op",
"event_wind_pressure": "Lufttryk (hPa)",
"event_heel": "Krængning (°)",
"event_sails": "Sejlhåndtering/motor",
"motor_propulsion": "Kørsel med maskine",
"sails_picker_show_more": "Vis alle sejl",
"sails_picker_show_less": "Vis mindre",
"motor_hours": "Maskintimer (i alt)",
"fuel_per_motor_hour": "Forbrug pr. maskintime",
"event_distance": "Afstand (nm)",
"export_csv": "Download CSV.",
"share_csv": "CSV andel",
"export_pdf": "Download PDF.",
"exporting_pdf": "PDF er genereret...",
"photos_title": "Vedhæftede billeder (E2E-krypteret)",
"photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)",
"photo_caption_placeholder": "f.eks. at sætte sejl nær indsejlingen til havnen",
"photo_btn": "Tag foto / upload",
"photo_processing": "Er ved at blive behandlet...",
"no_photos": "Der er endnu ingen billeder knyttet til denne rejsedag.",
"photo_delete_confirm": "Er du sikker på, at du vil slette dette foto permanent?",
"confirm_yes": "Ja",
"confirm_no": "Nej",
"track_upload_title": "GPS-spor (fil)",
"track_upload_points": "Point",
"gps_tracking_btn_gpx": "Download sporfilen",
"gps_track_upload_help": "Træk en GPX-, KML- eller GeoJSON-fil hertil, eller klik for at vælge",
"gps_track_upload_btn": "Upload GPS-spor",
"gps_track_delete": "Slet sporfilen",
"gps_track_delete_confirm": "Er du sikker på, at du vil slette denne sporfil permanent?",
"track_distance": "GPS-rute (sm)",
"track_speed_max": "Maks. Hastighed (kn)",
"track_speed_avg": "Ø Hastighed (kn)",
"track_map_title": "GPS-spor på OpenSeaMap",
"track_map_start": "Start",
"track_map_end": "Mål",
"track_map_speed_slow": "langsomt",
"track_map_speed_fast": "hurtigt",
"track_map_error": "Kortet kunne ikke indlæses.",
"exporting": "Eksport...",
"share_unsupported": "Deling understøttes ikke på denne enhed. Filen er blevet downloadet i stedet.",
"invite_crew": "Inviter crewen",
"invite_link_copied": "Invitationslink kopieret til udklipsholderen!",
"invite_link_desc": "Del dette link med Crew-medlemmer for at give dem skriveadgang til denne logbog.",
"collaborators_list": "Medlemmer / crew",
"revoke": "Fjerne",
"revoke_confirm": "Er du sikker på, at du vil tilbagekalde dette Crew-medlems adgang?",
"invite_role": "Rolle",
"invite_expires": "Linket er gyldigt i 48 timer",
"nmea_import_title": "Import NMEA log",
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
"nmea_import_btn": "Import NMEA",
"nmea_file_label": "NMEA file",
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
"nmea_mode_label": "Generate journal entries",
"nmea_mode_interval": "By time interval",
"nmea_mode_change": "On significant change",
"nmea_mode_both": "Both (merge)",
"nmea_interval_label": "Interval (minutes)",
"nmea_import_track": "Import GPS track from NMEA",
"nmea_preview": "Preview",
"nmea_preview_hint": "{{count}} suggested journal entries",
"nmea_select_all": "Select all",
"nmea_select_none": "Select none",
"nmea_source_interval": "Interval",
"nmea_source_change": "Event",
"nmea_apply": "Apply to journal",
"nmea_back": "Back",
"nmea_cancel": "Cancel",
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
"nmea_archive_keep": "Archive",
"nmea_archive_discard": "Discard",
"nmea_archive_stored": "NMEA archived: {{name}}",
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
"nmea_error_parse": "Could not read NMEA file.",
"nmea_error_read": "Could not read file.",
"nmea_error_no_file": "Please choose an NMEA file first.",
"nmea_error_no_selection": "Please select at least one journal entry.",
"nmea_remark_interval": "NMEA interval",
"nmea_remark_uncertain": "uncertain",
"nmea_remark_depth": "Depth {{depth}} m",
"nmea_change_course": "Course change {{from}}° → {{to}}°",
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
"nmea_change_depth": "Depth {{from}} → {{to}} m",
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
"nmea_change_engine_stop": "Engine off",
"nmea_change_autopilot_on": "Autopilot on",
"nmea_change_autopilot_off": "Autopilot off",
"nmea_change_gps_lost": "GPS fix lost",
"nmea_change_gps_regained": "GPS fix restored",
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop",
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries."
},
"dashboard": {
"title": "Dine logbøger",
"subtitle": "Vælg en logbog, eller opret en ny til at styre dine rejser.",
"create_btn": "Opret logbog",
"new_logbook_placeholder": "Navn på logbog eller yacht",
"logout": "Log ud",
"logged_in_as": "Logget ind som {{name}}",
"delete_confirm": "Er du sikker på, at du vil slette denne logbog permanent? Alle lokale data og serverkopier vil blive destrueret.\n\nTip: Lav en sikkerhedskopi (.daagbok.json) på forhånd under Indstillinger → Sikkerhedskopiering og gendannelse, hvis du vil beholde dataene senere.",
"no_logbooks": "Ingen logbøger fundet. Opret din første logbog for at komme i gang!",
"loading": "Logbøgerne er fyldt op...",
"status_synced": "Synkroniseret",
"status_local": "Kun lokal cache",
"delete_btn": "Slet logbog",
"section_owned": "Mine logbøger",
"section_shared": "Fælles logbøger",
"section_shared_hint": "Du er blevet inviteret som Crew-medlem. Skipperprofil og indstillinger tilhører ejeren.",
"role_owner": "Egen logbog",
"role_owner_hint": "Du er ejer og skipper af denne logbog",
"role_crew": "Adgang for crew",
"role_crew_hint": "Inviteret logbog - du kan arbejde som crew og underskrive den",
"role_read": "Læs kun",
"role_read_hint": "Opdelt logbog - kun visning, ingen redigering",
"open_profile": "Åben profil af {{name}}",
"open_logbook": "Åbn logbog „{{title}}“",
"edit_title": "Omdøb logbog",
"edit_placeholder": "Nyt navn på logbogen",
"edit_success": "Logbog omdøbt med succes",
"edit_btn": "Omdøb",
"filter_label": "Filtrer logbøger",
"filter_placeholder": "Navn, årstal, dato, crew eller skib …",
"filter_clear": "Nulstil filter",
"filter_results": "{{count}} Hits",
"filter_no_results": "Ingen logbøger matcher din søgning. Prøv med et andet navn eller et andet år.",
"sort_label": "Sortere",
"sort_by_label": "Sorter efter",
"sort_by_name": "Navn",
"sort_by_date": "dato",
"sort_dir_label": "Sekvens",
"sort_asc": "Stigende",
"sort_desc": "Nedadgående",
"sort_name_asc": "Navn A til Z",
"sort_name_desc": "Navn Z til A",
"sort_date_asc": "Ældste først",
"sort_date_desc": "Nyeste først"
},
"profile": {
"title": "Brugerprofil",
"subtitle": "Konto, Passkeys og statistik for {{name}}.",
"back": "Tilbage til instrumentbrættet",
"loading": "Profilen er ved at blive indlæst...",
"load_error": "Profilen kunne ikke indlæses.",
"copy_failed": "Kopiering mislykkedes.",
"processing": "Er ved at blive behandlet...",
"identity_title": "Konto-identitet",
"username": "Brugernavn",
"user_id": "Bruger-ID",
"copy_user_id": "Kopier bruger-ID",
"account_since": "Konto siden",
"prf_status": "Passkey nøgleafledning (PRF)",
"prf_active": "Aktiv",
"prf_inactive": "Ikke sat op",
"passkeys_title": "Passkeys",
"passkeys_desc": "Registrer en separat Passkey på hver enhed. På den måde kan du logge ind, selv når du har skiftet platform.",
"passkeys_empty": "Ingen Passkeys fundet.",
"add_passkey_btn": "Tilføj ny Passkey.",
"add_passkey_success": "Passkey tilføjet med succes.",
"add_passkey_failed": "Passkey kunne ikke tilføjes.",
"remove_passkey_btn": "Fjern Passkey",
"remove_passkey_last_title": "Sidste Passkey.",
"remove_passkey_last_desc": "Den eneste Passkey kan ikke fjernes uden at miste adgangen til din konto. Hvis du vil slette kontoen helt, skal du bruge farezonen nederst på denne side.",
"remove_passkey_failed": "Passkey kunne ikke fjernes.",
"remove_passkey_confirm_title": "Fjern Passkey?",
"remove_passkey_confirm_desc": "Denne enhed kan så ikke længere logge ind med denne Passkey.",
"remove_passkey_confirm_yes": "Fjerne",
"remove_passkey_confirm_no": "Annuller",
"pin_title": "Lokal PIN-kode",
"pin_status": "Status",
"pin_active": "Aktiv på denne enhed",
"pin_inactive": "Ikke sat op",
"pin_confirm_label": "Bekræft PIN-kode",
"pin_confirm_placeholder": "Indtast PIN-kode igen",
"pin_set_btn": "Opsæt PIN-kode",
"pin_change_btn": "Skift PIN-kode",
"pin_remove_btn": "Fjern PIN-kode",
"pin_saved": "PIN-kode gemt.",
"pin_save_failed": "PIN-koden kunne ikke gemmes.",
"pin_mismatch": "PIN-koderne stemmer ikke overens.",
"pin_length_error": "PIN-koden skal bestå af mindst 4 tegn.",
"pin_no_session": "Sessionen er udløbet - tilmeld dig venligst igen.",
"remove_pin_confirm_title": "Fjerne PIN-kode?",
"remove_pin_confirm_desc": "Du skal logge ind igen på denne enhed med Passkey eller genoprettelsesnøglen.",
"remove_pin_confirm_yes": "Fjern PIN-kode",
"remove_pin_confirm_no": "Annuller",
"security_title": "Tjekliste for sikkerhed",
"security_desc": "Oversigt over de vigtigste beskyttelsesmekanismer for din konto.",
"security_passkeys_ok": "Mindst én Passkey registreret",
"security_passkeys_missing": "Nej Passkey registreret",
"security_prf_ok": "PRF-nøgleafledning aktiv",
"security_prf_missing": "PRF ikke sat op",
"security_pin_ok": "Lokal PIN-kode på denne enhed",
"security_pin_missing": "Ingen lokal PIN-kode",
"security_recovery_ok": "Opsætning af genoprettelsesnøgle",
"security_recovery_hint": "De 12 ord blev vist under registreringen. Opbevar dem offline og adskilt fra enheden. Du kan oprette en ny nøgle nedenfor - den gamle bliver så ugyldig.",
"recovery_rotate_btn": "Opret en ny genoprettelsesnøgle",
"recovery_rotate_confirm_title": "Opret en ny genoprettelsesnøgle?",
"recovery_rotate_confirm_desc": "Den tidligere nøgle på 12 ord bliver ugyldig med det samme. Sørg for at opbevare den nye nøgle sikkert, før du fortsætter.",
"recovery_rotate_confirm_yes": "Opret ny nøgle",
"recovery_rotate_confirm_no": "Annuller",
"recovery_rotate_new_warning": "VIGTIGT: Skriv disse 12 ord ned, og opbevar dem offline. Den tidligere genoprettelsesnøgle er nu ugyldig.",
"recovery_rotate_failed": "Genoprettelsesnøglen kunne ikke oprettes.",
"recovery_rotate_no_session": "Krypteringssessionen er udløbet - log ud og log ind igen, og prøv så igen.",
"device_title": "Denne enhed",
"device_desc": "Lokal cache, synkroniseringsstatus og hurtig login i denne browser.",
"device_sync_pending": "{{count}} ventende synkroniseringsposter",
"device_sync_ok": "Alle lokale ændringer synkroniseres",
"device_remembered": "Konto til hurtigt login gemt på denne enhed",
"device_not_remembered": "Kontoen er ikke på listen over hurtige login",
"device_forget_btn": "Glemt konto på denne enhed",
"device_forget_confirm_title": "Fjerne hurtig login?",
"device_forget_confirm_desc": "Kontoen forsvinder fra listen over hurtige login på denne enhed. Din session og dine lokale logbøger bevares.",
"device_forget_confirm_yes": "Fjerne",
"device_forget_confirm_no": "Annuller",
"passkey_label": "Navn på ny Passkey (valgfrit)",
"passkey_label_placeholder": "z. f.eks. MacBook, iPhone.",
"passkey_rename_btn": "Gem navn",
"passkey_rename_success": "Passkey navn gemt.",
"passkey_rename_failed": "Passkey-Navnet kunne ikke gemmes.",
"passkey_unnamed": "Uden titel Passkey.",
"stats_title": "Statistik",
"stats_subtitle": "Om alle dine logbøger på denne enhed",
"stats_logbooks": "Logbøger",
"stats_account_since": "Konto siden",
"stats_shared_logbooks": "Fælles logbøger",
"appearance_title": "App og visualisering",
"appearance_desc": "Designet og farveskemaet gælder for hele appen på denne enhed.",
"theme_label": "Appens designstil",
"theme_auto": "Automatisk (OS-registrering)",
"theme_ocean": "Ocean (glasmorfisme)",
"theme_material": "Materiale (Android)",
"theme_cupertino": "Cupertino (iOS)",
"color_scheme_label": "Lys eller mørk tilstand",
"color_scheme_auto": "Automatisk (system)",
"color_scheme_light": "Lys",
"color_scheme_dark": "Mørk",
"integrations_title": "Integrationer",
"owm_key": "OpenWeatherMap API-nøgle",
"owm_help": "Valgfrit: egen OpenWeatherMap API-nøgle. Hvis der ikke er nogen indtastning, bruges nøglen på serversiden fra operatørkonfigurationen.",
"prefs_save": "Gemme",
"prefs_saving": "Vil blive reddet...",
"prefs_saved": "Gemt",
"tour_title": "App-tur",
"tour_desc": "Lad dig guide gennem de vigtigste områder i appen igen.",
"tour_restart": "Start turen igen",
"push_title": "Push-meddelelser",
"push_desc": "Som logbogsejer får du besked, når inviterede Crew-medlemmer synkroniserer ændringer. Intet indhold overføres i ren tekst.",
"push_enable": "Giv os besked om ændringer i crewen",
"push_active": "Push-meddelelser er aktive på denne enhed.",
"push_unsupported": "Push-meddelelser understøttes ikke i denne browser.",
"push_denied_hint": "Notifikationer er blokeret. Tillad dem i browserens eller enhedens indstillinger.",
"push_ios_install_hint": "På iPhone/iPad: Føj app til startskærmen (iOS 16.4+) for at bruge push.",
"push_error": "Push-meddelelser kunne ikke aktiveres.",
"sections": {
"account": "Konto og indstillinger",
"fleet": "Flåde og besætning",
"security": "Sikkerhed og enhed",
"stats": "Statistik",
"danger": "Farezone"
}
},
"vessel_pool": {
"title": "Skibsflåde",
"section_title": "Dine skibe",
"subtitle": "Hold alle skibe til dine logbøger her. Vælg aktivt skib per logbog fra listen.",
"loading": "Indlæser skibsflåde…",
"add_vessel": "Tilføj skib",
"edit_vessel": "Rediger skib",
"no_vessels": "Ingen skibe i puljen endnu.",
"delete_confirm": "Fjerne dette skib fra flåden?",
"max_vessels": "Højst 20 skibe i puljen."
},
"logbook_vessel": {
"title": "Skib for denne logbog",
"subtitle": "Vælg skib for denne logbog. Rejsedage bruger sejl- og tankdata fra valgt skib.",
"active_vessel": "Skib for denne logbog",
"no_vessels_in_pool": "Intet skib i flåden tilføj i brugerprofilen først.",
"no_vessel": "Intet skib valgt",
"unnamed": "Uden navn",
"save": "Gem skib",
"saved": "Logbog-skib gemt.",
"selection_only_hint": "Du ser skibet ejeren har valgt (delt logbog).",
"manage_in_profile": "Administrer skibe i brugerprofilen"
},
"person_pool": {
"title": "Stamm-Crew og skippere",
"subtitle": "Administrer din personpulje her skippere og crew til alle logbøger. Vælg aktiv crew per logbog og rejsedag fra puljen.",
"loading": "Indlæser personpulje…",
"skippers_section": "Skippere",
"crew_section": "Stamm-Crew",
"add_skipper": "Tilføj skipper",
"add_crew": "Tilføj Crew-medlem",
"edit_skipper": "Rediger skipper",
"no_skippers": "Ingen skipper i puljen endnu.",
"no_crew": "Ingen Crew-medlemmer i puljen endnu.",
"delete_confirm": "Fjern denne person fra puljen?"
},
"logbook_crew": {
"title": "Crew for denne logbog",
"subtitle": "Vælg skipper og crew for denne logbog. Nye rejsedage arver valget som standard.",
"loading": "Indlæser crew…",
"active_skipper": "Skipper for denne logbog",
"active_crew": "Crew for denne logbog",
"no_skippers_in_pool": "Ingen skipper i puljen tilføj i brugerprofilen først.",
"no_crew_in_pool": "Ingen crew i puljen tilføj i brugerprofilen først.",
"no_skipper": "Ingen skipper valgt",
"unnamed": "Uden navn",
"save": "Gem crew",
"saved": "Logbog-Crew gemt.",
"selection_only_hint": "Du ser den crew ejeren har valgt (delt logbog)."
},
"entry_crew": {
"title": "Crew på denne rejsedag",
"subtitle": "Kan afvige fra logbogstandard. Følgende dage arver fra foregående dag.",
"day_skipper": "Skipper denne dag",
"day_crew": "Crew denne dag",
"no_skipper": "Ingen skipper valgt",
"no_crew": "Ingen crew valgt"
},
"crew": {
"title": "Skipper- og Crew-profiler",
"skipper_section": "Skipper-profil",
"skipper_read_only_hint": "Skipperprofilen kan kun redigeres af logbogens ejer.",
"crew_section": "Crew-liste",
"add_crew": "Tilføj Crew-medlem",
"edit_crew": "Rediger Crew-medlem",
"no_crew": "Ingen Crew-medlemmer tilføjet endnu.",
"max_crew": "Det maksimale antal på 12 Crew-medlemmer i puljen er nået.",
"name": "Navn",
"address": "adresse",
"birthdate": "Fødselsdag",
"phone": "Telefonnummer",
"nationality": "Nationalitet",
"passport": "Pas/ID-nummer",
"bloodtype": "Blodgruppe",
"allergies": "Allergier",
"diseases": "Eksisterende tilstande/sygdomme",
"save": "Gem skipper-data",
"save_member": "Gem medlem",
"saved": "Skipperprofilen er blevet gemt!",
"loading": "Crew-filerne er indlæst.",
"delete_confirm": "Er du sikker på, at du vil fjerne dette Crew-medlem?"
},
"deviation": {
"title": "Tabel over kompasafvigelser",
"subtitle": "Indtast den magnetiske kompasafbøjning (afbøjning) for kurser (MgK) fra 000° til 360° i trin på 10°.",
"heading": "MgK",
"deviation": "Distraktion",
"save": "Gem kalibreringsgitter",
"saving": "Vil blive reddet...",
"saved": "Kalibreringsgitteret er gemt med succes!",
"loading": "Kalibreringstabellen er indlæst..."
},
"settings": {
"title": "Indstillinger for logbog",
"subtitle": "Del, tag backup og samarbejd om denne logbog.",
"select_logbook_hint": "Vælg en logbog for at redigere dens indstillinger.",
"no_key": "Ingen OpenWeatherMap API-nøgle tilgængelig. Gem din egen nøgle i brugerprofilen, eller kontakt operatøren.",
"weather_success": "Vejrdata hentet med succes!",
"weather_error": "Hentning af vejrdata mislykkedes. Tjek API-nøglen og forbindelsen.",
"weather_date_mismatch": "Vejrdata kan kun hentes for i dag ({{today}}). Dette logbogsindlæg er dateret {{date}}.",
"gps_error": "Indtast en placering, eller find GPS-koordinaterne.",
"share_title": "Del logbog (skrivebeskyttet)",
"share_desc": "Aktivér denne mulighed for at oprette et offentligt, skrivebeskyttet link. Alle med linket kan se dine rejser, yachtprofiler og crew. Krypteringsnøglerne overføres aldrig til serveren (de forbliver i hash-delen af URL'en).",
"share_privacy_warning": "Anbefaling: Del kun dette link privat (f.eks. via e-mail eller messenger), ikke på sociale medier.",
"share_enable": "Aktivér offentligt link",
"share_copied": "Link kopieret!",
"share_copy_btn": "Kopier link",
"link_qr_hint": "Scan QR-koden med din telefon",
"link_qr_alt": "QR-kode til linket",
"danger_zone_title": "Farezone",
"danger_zone_desc": "Når du sletter din konto, slettes alle dine Passkey'er, logbøger, skibsdata, Crew-profiler, rejseindlæg og E2E-nøgler uigenkaldeligt. Denne handling kan ikke fortrydes.",
"delete_account_btn": "Slet konto uigenkaldeligt",
"delete_account_confirm_title": "Slette konto?",
"delete_account_confirm_desc": "Er du helt sikker på, at du vil slette din konto uigenkaldeligt og alle tilknyttede logbøger og E2E-krypterede data?",
"delete_account_confirm_yes": "Ja, slet konto og alle data",
"delete_account_confirm_no": "Annuller",
"delete_account_failed": "Kontoen kunne ikke slettes. Prøv venligst igen.",
"delete_backup_hint": "Tip: Lav sikkerhedskopier af dine logbøger (.daagbok.json) i indstillingerne for hver logbog, før du sletter dem.",
"deleting_account": "Kontoen vil blive slettet...",
"invite_push_prompt_title": "Aktivere push-meddelelser?",
"invite_push_prompt_message": "Så snart inviterede Crew-medlemmer synkroniserer ændringer, kan du blive informeret via push. Intet logbogsindhold sendes i almindelig tekst.",
"invite_push_prompt_ios_message": "Så snart Crew-medlemmerne synkroniserer ændringer, kan du blive informeret via push. På iPhone/iPad: Føj appen til startskærmen (iOS 16.4+), og aktiver derefter push i brugerprofilen.",
"invite_push_prompt_enable": "Aktiver nu",
"invite_push_prompt_later": "Senere",
"invite_push_prompt_success": "Push-meddelelser er aktive på denne enhed.",
"backup_title": "Sikkerhedskopiering og gendannelse",
"backup_desc": "Komplet krypteret backup af denne logbog (poster, fotos, GPS-spor, crew, skib). Beskyttet med backup-passphrase - til gendannelse til denne eller en ny konto.",
"backup_export_title": "Opret backup",
"backup_export_desc": "Downloader alle lokale data som .daagbok.json. Hold filen og adgangssætningen adskilt og sikker.",
"backup_restore_title": "Gendan sikkerhedskopi",
"backup_restore_desc": "Gendanner en sikkerhedskopi til din nuværende konto - selv efter registrering af en ny konto.",
"backup_passphrase": "Backup-passphrase",
"backup_passphrase_placeholder": "Mindst 8 tegn",
"backup_passphrase_confirm": "Bekræft adgangssætning",
"backup_passphrase_short": "Backup-passphrasen skal være mindst 8 tegn lang.",
"backup_passphrase_mismatch": "Passphrases matcher ikke.",
"backup_wrong_passphrase": "Passphrase forkert eller backup beskadiget.",
"backup_export_btn": "Download backup",
"backup_exporting": "Sikkerhedskopien er oprettet...",
"backup_export_success": "Backup oprettet ({{count}} rejsedage).",
"backup_file_label": "Backup-fil (.daagbok.json)",
"backup_preview_btn": "Tjek indhold",
"backup_previewing": "Tjek...",
"backup_restore_btn": "Gendan",
"backup_restoring": "Vil blive restaureret...",
"backup_restore_success": "Logbog \"{{title}}\" er blevet gendannet.",
"backup_restore_cancelled": "Genopretning aflyst.",
"backup_invalid_json": "Filen er ikke en gyldig JSON-fil.",
"backup_invalid_format": "Ukendt eller forældet backup-format.",
"backup_not_owner": "Kun logbogens ejer kan oprette sikkerhedskopier.",
"backup_not_authenticated": "Log ind for at gendanne en sikkerhedskopi.",
"backup_id_conflict": "Der findes allerede en logbog med dette ID.",
"backup_overwrite_confirm": "Den eksisterende logbog med samme ID erstattes. Fortsætter du?",
"backup_new_id_confirm": "Importere backup'en som en ny logbog med et nyt ID?",
"backup_stat_entries": "{{count}} Rejsedage",
"backup_stat_photos": "{{count}} Fotos",
"backup_stat_crew": "{{count}} Crew-poster",
"backup_stat_tracks": "{{count}} GPS-spor",
"backup_exported_at": "Eksporteret: {{date}}"
},
"disclaimer": {
"title": "Vigtige bemærkninger",
"intro": "Læs venligst følgende instruktioner, før du bruger Kapteins Daagbok.",
"e2e_title": "Ende-til-ende-kryptering",
"e2e_body": "Dine logbogsdata er krypteret fra ende til anden. Kun du - eller personer med din nøgle - kan læse indholdet. Kun krypterede data gemmes på serveren.",
"pwa_title": "Progressiv web-app (PWA)",
"pwa_body": "Kapteins Daagbok kører som en progressiv webapp i din browser og kan installeres på din enhed - på samme måde som en native app, men uden en app store.",
"storage_title": "Lokal lagring og synkronisering",
"storage_body": "Dine data gemmes lokalt på din enhed (IndexedDB). Ændringer synkroniseres med serveren, når en internetforbindelse er aktiv. Du kan arbejde videre uden forbindelse; synkroniseringen finder sted senere.",
"free_title": "Gratis og uden reklamer",
"free_body": "Kapteins Daagbok er gratis og indeholder ingen reklamer.",
"liability_title": "Ansvarsfraskrivelse",
"liability_body": "Brug af appen sker på egen risiko. Vi påtager os intet ansvar for skader, der opstår som følge af brugen af appen - herunder forkerte eller ufuldstændige logbogsindførsler, tab af data eller tekniske fejl.",
"warranty_title": "Ingen garanti",
"warranty_body": "Der gives ingen garanti for tjenestens funktion, korrekthed eller tilgængelighed. Driften kan til enhver tid blive afbrudt, begrænset eller annulleret.",
"copyright": "© 2026 KnorrLabs, Markus F.J. Busche",
"accept": "Accepter og fortsæt",
"close": "Luk",
"button_title": "Noter og ansvarsfraskrivelse"
},
"feedback": {
"button_title": "Send feedback",
"title": "Feedback",
"intro": "Del fejl, ideer eller generel feedback. Din besked vil blive sendt til projektteamet via en sikker meddelelseskanal.",
"category_label": "Kategori",
"category_general": "Generelt",
"category_bug": "Rapporter fejl",
"category_feature": "Anmodning om funktion",
"category_translation": "Oversættelsesfejl",
"contact_label": "E-mail (valgfrit)",
"contact_placeholder": "deine@email.beispiel",
"message_label": "Besked",
"message_placeholder": "Beskriv din feedback...",
"send": "Send",
"sending": "Vil blive sendt...",
"cancel": "Annuller",
"success": "Tusind tak skal du have! Din feedback er blevet sendt.",
"error_send": "Feedback kunne ikke sendes. Prøv venligst igen senere.",
"error_invalid_email": "Indtast venligst en gyldig e-mailadresse.",
"error_not_configured": "Feedback er ikke tilgængelig på denne server.",
"error_rate_limited": "For mange tilbagemeldinger på kort tid. Vent venligst et par minutter.",
"error_spam": "Denne besked kunne ikke sendes. Vær venlig at omformulere den."
},
"demo": {
"logbook_title": "Demo-logbog Østersøen",
"badge": "Demo",
"public_banner": "Skrivebeskyttet demo-visning",
"cta_register": "Opret konto",
"back_to_login": "Til registreringen"
},
"invitation": {
"error_invalid_key": "Invitationslinket er kryptografisk ugyldigt (nøglen er forkert).",
"error_missing_key": "Invitationslinket indeholder ikke en dekrypteringsnøgle (#key=...). Brug venligst det fulde link fra ejeren.",
"error_expired": "Denne invitation er udløbet (gyldig i 48 timer).",
"error_invalid_token": "Invitationstokenet er ugyldigt.",
"error_load_failed": "Invitationsoplysningerne kunne ikke indlæses.",
"error_incomplete_session": "Session ufuldstændig - log venligst ind igen (bruger-ID mangler).",
"error_accept_failed": "Tiltrædelse mislykkedes.",
"error_login_failed": "Passkey Login mislykkedes.",
"error_username_missing": "Brugernavnet kunne ikke bestemmes - log venligst ind igen.",
"error_register_failed": "Registrering mislykkedes.",
"loading_joining": "At slutte sig til...",
"loading_checking": "Invitation vil blive tjekket...",
"loading_unlocking": "Logbogen er låst op og synkroniseret...",
"loading_retrieving_key": "Download krypteringsnøgle...",
"error_title": "Fejl i invitation",
"back_to_start": "Tilbage til start",
"title": "Invitation til logbog",
"invited_by": "Invitation fra",
"vessel_logbook": "Skib / Logbog",
"signed_in_preparing": "Registreret som {{username}}. Tilslutning er ved at blive forberedt...",
"join_again": "Deltag igen",
"login_or_register_hint": "Log ind eller opret en konto for at deltage i logbogen.",
"or_sign_up": "ELLER REGISTRER DIG IGEN",
"register_crew_account": "Opret en ny crew-konto",
"username_label": "Brugernavn",
"create_passkey": "Opret Passkey.",
"switch_language_en": "Engelsk",
"switch_language_de": "Tysk"
},
"stats": {
"title": "Statistik",
"subtitle": "Overblik over ruter, forbrug og kørselstype",
"scope_label": "Evalueringsområde",
"scope_logbook": "Denne logbog",
"scope_account": "Alle logbøger",
"loading": "Statistikkerne er beregnet...",
"no_data": "Ingen rejsedage tilgængelige endnu.",
"total_distance": "Samlet afstand",
"travel_days": "Rejsedage",
"sail_distance": "Under sejl",
"motor_distance": "Kørsel med maskine",
"motor_hours_total": "Samlet antal maskintimer",
"daily_motor_hours": "Maskintimer pr. rejsedag",
"avg_motor_hours": "Ø maskintimer pr. rejsedag",
"unknown_propulsion": "Ukendt",
"fuel_total": "Brændstof i alt",
"water_total": "Vand i alt",
"daily_etmal": "Daglige tider",
"daily_consumption": "Dagligt forbrug",
"route_overview": "Rute",
"route_map_title": "Oversigt over ruter",
"propulsion_title": "Sejl vs. maskine",
"propulsion_hint": "Opdelingen er baseret på logbogshændelser pr. rejsedag, ikke på GPS-segmenter.",
"avg_distance": "Ø pr. rejsedag",
"avg_fuel": "Ø Brændstof",
"avg_water": "Ø Vand",
"fuel_per_nm": "Brændstof pr. sm",
"fuel_per_motor_hour": "Brændstof pr. maskintime",
"daily_fuel_per_motor_hour": "Brændstofforbrug pr. maskintime pr. rejsedag",
"fuel_legend": "Brændstof",
"water_legend": "Vand",
"unit_nm": "sm",
"unit_h": "h",
"unit_l": "L",
"day_label": "Dag {{day}}",
"account_logbooks": "Et overblik over logbøger",
"col_logbook": "Logbog",
"event_series_title": "Hændelsesforløb",
"event_series_hint": "Kronologiske værdier fra hændelsesloggen.",
"event_series_pressure": "Lufttryk",
"event_series_wind": "Vind",
"event_series_motor": "Motor",
"event_series_empty": "Ingen indtastninger endnu."
},
"tour": {
"skip": "Spring turen over",
"back": "Tilbage",
"next": "Yderligere",
"finish": "Klar",
"progress": "Trin {{current}} fra {{total}}.",
"steps": {
"welcome": {
"title": "Velkommen om bord!",
"body": "Vi har lavet en demo-logbog med tre dages rejse i Kielerfjorden til dig. Du kan til enhver tid slette prøveposterne, hvis du vil starte din egen logbog. Denne korte rundvisning viser dig de vigtigste funktioner."
},
"welcome_public": {
"title": "Velkommen om bord!",
"body": "Udforsk vores demo-logbog med tre dages rejse i Kielerfjorden uden konto. Turen viser logbogsposter samt valg af skib og besætning for denne logbog. Flåde og stamm-besætning vedligeholder du senere i brugerprofilen."
},
"nav_logs": {
"title": "Indlæg i logbogen",
"body": "Det er her, du styrer dine rejsedage - afgang, destination, vejr, brændstofniveau og GPS-spor."
},
"entry_list": {
"title": "Dine rejsedage",
"body": "Hvert kort repræsenterer en rejsedag. Tryk på en post for at se eller redigere detaljer."
},
"entry_open": {
"title": "Åben rejsedag",
"body": "Sådan ser et udfyldt logbogsnotat ud - med begivenheder, tankniveauer og meget mere."
},
"entry_track": {
"title": "GPS-spor",
"body": "Upload GPX-filer, eller se allerede gemte ruter på kortet - inklusive afstand og hastighed."
},
"nav_vessel": {
"title": "Skib for logbog",
"body": "Vælg skib fra flåden for denne logbog. Administrer skibe i brugerprofilen under Flåde og besætning."
},
"profile_vessel_pool": {
"title": "Skibsflåde",
"body": "I brugerprofilen opretter du alle dine skibe charter, eget skib osv. Vælg derefter det rigtige skib per logbog."
},
"profile_crew_pool": {
"title": "Stamm-Crew og skippere",
"body": "I brugerprofilen vedligeholder du en personpulje flere skippere (f.eks. charter) og crew til alle logbøger."
},
"nav_logbook_crew": {
"title": "Crew per logbog",
"body": "Vælg skipper og crew fra puljen til denne logbog. Rejsedage arver valget som standard."
},
"nav_stats": {
"title": "Statistik-dashboard",
"body": "Her kan du se kørselsafstande, brændstofforbrug, rutekort og kørselsandele - automatisk beregnet ud fra dine logbogsnotater."
},
"nav_feedback": {
"title": "Send feedback",
"body": "Du kan bruge denne formular til at sende fejl, ideer eller generel feedback direkte til projektteamet - også efter rundvisningen, når som helst ved hjælp af ikonet øverst til højre."
},
"nav_profile": {
"title": "Din brugerprofil",
"body": "Du kan få adgang til din personlige profil via skipperknappen øverst - uanset den aktuelle logbog."
},
"profile_preferences": {
"title": "Regnskab og præsentation",
"body": "Her kan du administrere din kontoidentitet, tema og lys/mørk tilstand. Du kan til enhver tid genstarte app-turen. Passkeys og sikkerhedsindstillinger findes længere nede i profilen."
},
"finish": {
"title": "Okay!",
"body": "Du vil blive ført direkte til statistikdashboardet. Du kan til enhver tid genstarte turen i din brugerprofil. Hav en god tur!"
}
}
},
"seo": {
"title": "Kapteins Daagbok - Gratis digital yachtlogbog (reklamefri)",
"description": "Gratis, reklamefri digital yachtlogbog med end-to-end-kryptering og Passkey-login. Dokumenter sikkert rejsedage, GPS-spor, Crew- og skibsdata - også offline som PWA.",
"keywords": "Yachtlogbog, skibslogbog, logbog om bord, sejlads, Passkey, E2E-kryptering, GPS-spor, maritim logbog, gratis, reklamefri, gratis, uden reklame",
"ogImageAlt": "Kapteins Daagbok Logo"
}
}
}
+607 -88
View File
@@ -2,12 +2,40 @@
"translation": {
"app": {
"name": "Kapteins Daagbok",
"tagline": "Privates Yacht-Logbuch"
"tagline": "Privates Yacht-Logbuch",
"beta": "Beta",
"beta_hint": "Beta-Version — Funktionen können sich noch ändern"
},
"languages": {
"de": "Deutsch",
"en": "English",
"da": "Dansk",
"sv": "Svenska",
"nb": "Norsk"
},
"dialog": {
"ok": "OK",
"yes": "Ja",
"no": "Nein"
},
"errors": {
"load_failed": "Daten konnten nicht geladen werden.",
"save_failed": "Änderungen konnten nicht gespeichert werden.",
"delete_failed": "Löschen fehlgeschlagen.",
"export_failed": "Export fehlgeschlagen."
},
"common": {
"unsaved_changes_title": "Ungespeicherte Änderungen",
"unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.",
"unsaved_changes_stay": "Bleiben",
"unsaved_changes_save_leave": "Speichern & verlassen",
"unsaved_changes_discard": "Verwerfen",
"unsaved_changes_leave": "Verlassen"
},
"nav": {
"dashboard": "Dashboard",
"vessel": "Schiffsdaten",
"crew": "Crew-Liste",
"crew": "Crew",
"deviation": "Ablenkungstabelle",
"logs": "Logbucheinträge",
"stats": "Statistik",
@@ -15,50 +43,55 @@
},
"auth": {
"welcome": "Willkommen bei Kapteins Daagbok",
"tagline": "Sicheres, E2E-verschlüsseltes maritimes Logbuch.",
"tagline": "Dein sicheres, E2E-verschlüsseltes maritimes Logbuch.",
"register": "Mit Passkey registrieren",
"login": "Mit Passkey anmelden",
"login_as": "Anmelden als {{name}}",
"quick_login": "Schnell-Login",
"forget_account": "Account auf diesem Gerät vergessen",
"not_user": "Nicht {{name}}?",
"recovery_title": "Ihr Wiederherstellungsschlüssel",
"recovery_warning": "WICHTIG: Schreiben Sie diese 12 Wörter auf. Wenn Sie Ihren Passkey und diese Wörter verlieren, können Ihre Daten nicht wiederhergestellt werden.",
"recovery_title": "Dein Wiederherstellungsschlüssel",
"recovery_warning": "WICHTIG: Schreib diese 12 Wörter auf. Wenn du deinen Passkey und diese Wörter verlierst, können deine Daten nicht wiederhergestellt werden.",
"confirm_recovery": "Ich habe die Wörter aufgeschrieben",
"status_logged_in": "Angemeldet",
"status_logged_out": "Abgemeldet",
"copied": "Kopiert!",
"copy_phrase": "Schlüssel kopieren",
"enter_recovery": "Wiederherstellungsschlüssel eingeben",
"recovery_fallback_warning": "Ihr Passkey wurde erfolgreich authentifiziert, aber Ihr Gerät unterstützt keine hardwarebasierte Schlüsselableitung. Geben Sie Ihren 12-Wörter-Wiederherstellungsschlüssel ein, um Ihr Logbuch zu entschlüsseln.",
"recovery_placeholder": "Geben Sie Ihren aus 12 Wörtern bestehenden Wiederherstellungsschlüssel getrennt durch Leerzeichen ein...",
"recovery_fallback_warning": "Dein Passkey wurde erfolgreich authentifiziert, aber dein Gerät unterstützt keine hardwarebasierte Schlüsselableitung. Gib deinen 12-Wörter-Wiederherstellungsschlüssel ein, um dein Logbuch zu entschlüsseln.",
"recovery_placeholder": "Gib deinen aus 12 Wörtern bestehenden Wiederherstellungsschlüssel getrennt durch Leerzeichen ein...",
"back": "Zurück",
"decrypting": "Entschlüsselung...",
"decrypt_logbook": "Logbuch entschlüsseln",
"error_incorrect_recovery": "Falscher Wiederherstellungsschlüssel. Entschlüsselung fehlgeschlagen.",
"error_decryption_failed": "Entschlüsselung fehlgeschlagen. Bitte überprüfen Sie Ihren Wiederherstellungsschlüssel.",
"error_decryption_failed": "Entschlüsselung fehlgeschlagen. Bitte überprüfe deinen Wiederherstellungsschlüssel.",
"or_register": "oder Registrieren",
"explore_demo": "Demo ohne Account erkunden",
"username_placeholder": "Benutzername / Skippername",
"processing": "Verarbeitung...",
"help": "Hilfe",
"setup_pin_title": "Lokale PIN einrichten (Optional)",
"setup_pin_warning": "Da Ihr Gerät keine direkte Passkey-Schlüsselableitung unterstützt, müssten Sie andernfalls bei jedem Login auf diesem Gerät Ihren 12-Wörter-Schlüssel eingeben. Richten Sie eine lokale PIN ein, um das zu vermeiden.",
"setup_pin_warning": "Da dein Gerät keine direkte Passkey-Schlüsselableitung unterstützt, müsstest du andernfalls bei jedem Login auf diesem Gerät deinen 12-Wörter-Schlüssel eingeben. Richte eine lokale PIN ein, um das zu vermeiden.",
"pin_placeholder": "Z.B. 123456",
"pin_label": "Lokaler PIN-Code (4-8 Ziffern)",
"save_pin": "PIN speichern & Fortfahren",
"skip_pin": "Überspringen & recovery verwenden",
"enter_pin_title": "Mit PIN entschlüsseln",
"enter_pin_warning": "Geben Sie Ihre lokale PIN ein, um den Entschlüsselungsschlüssel auf diesem Gerät freizuschalten.",
"enter_pin_placeholder": "Geben Sie Ihre PIN ein...",
"enter_pin_warning": "Gib deine lokale PIN ein, um den Entschlüsselungsschlüssel auf diesem Gerät freizuschalten.",
"enter_pin_placeholder": "Gib deine PIN ein...",
"decrypt_with_pin": "Entschlüsseln",
"use_recovery_instead": "Stattdessen Wiederherstellungsschlüssel verwenden",
"error_incorrect_pin": "Falsche PIN. Entschlüsselung fehlgeschlagen."
"error_incorrect_pin": "Falsche PIN. Entschlüsselung fehlgeschlagen.",
"error_invalid_host": "Passkeys funktionieren nicht über 127.0.0.1. Bitte die App über localhost öffnen.",
"use_localhost_link": "Zu localhost wechseln",
"error_passkey_cancelled": "Passkey-Anmeldung abgebrochen oder abgelaufen. Bitte erneut versuchen.",
"error_invalid_rp_id": "Passkey-Domain passt nicht (RP ID). Lokal nur http://localhost:5173 mit RP_ID=localhost in .env verwenden.",
"error_session_incomplete": "Anmeldung unvollständig. Bitte erneut mit Passkey anmelden."
},
"pwa": {
"title": "App installieren",
"generic_benefit": "Installieren Sie Kapteins Daagbok auf Ihrem Gerät für schnelleren Zugriff, Offline-Nutzung und dauerhafte Datenspeicherung.",
"ios_instructions": "Auf dem iPad/iPhone: Fügen Sie die App zum Home-Bildschirm hinzu, damit Ihre Logbuchdaten geschützt bleiben und die App wie eine native App startet.",
"generic_benefit": "Installiere Kapteins Daagbok auf deinem Gerät für schnelleren Zugriff, Offline-Nutzung und dauerhafte Datenspeicherung.",
"ios_instructions": "Auf dem iPad/iPhone: Füge die App zum Home-Bildschirm hinzu, damit deine Logbuchdaten geschützt bleiben und die App wie eine native App startet.",
"ios_step_share": "Teilen-Symbol in der Safari-Leiste antippen",
"ios_step_add": "„Zum Home-Bildschirm“ wählen",
"install_now": "Jetzt installieren",
@@ -72,12 +105,18 @@
"update_title": "Update verfügbar",
"update_desc": "Eine neue Version von Kapteins Daagbok ist bereit. Bitte aktualisieren, um die neuesten Änderungen zu erhalten.",
"update_now": "Jetzt aktualisieren",
"update_reloading": "Wird geladen…"
"update_reloading": "Wird geladen…",
"storage_persist_hint": "Der Browser kann Offline-Daten löschen. Erlaube dauerhafte Speicherung, damit dein Logbuch geschützt bleibt (in den Browser-Einstellungen oder beim nächsten Hinweis)."
},
"sync": {
"status_synced": "Synchronisiert",
"status_syncing": "Synchronisiere…",
"status_offline": "Offline-Cache",
"status_unsynced": "Unsynchronisierte Änderungen"
"status_unsynced": "Unsynchronisierte Änderungen",
"conflict_title": "Synchronisationskonflikt",
"conflict_message": "{{count}} Änderung(en) konnten nicht synchronisiert werden (Eintrag {{id}}…). Bitte wähle, welche Version gelten soll.",
"conflict_use_server": "Server-Version übernehmen",
"conflict_keep_local": "Meine Version behalten"
},
"vessel": {
"title": "Schiffs-Stammdaten",
@@ -102,26 +141,44 @@
"saved": "Schiffsdaten erfolgreich gespeichert!",
"loading": "Schiffsdaten werden geladen...",
"sails_list": "Besegelung (vorhandene Segel)",
"sails_help": "Tragen Sie hier die Segel ein, die an Eurem Schiff zur Verfügung stehen (z. B. Großsegel, Genua, Fock).",
"sails_help": "Trag hier die Segel ein, die an deinem Schiff zur Verfügung stehen (z. B. Großsegel, Genua, Fock).",
"add_sail": "Segel hinzufügen",
"sail_name_placeholder": "z. B. Großsegel",
"no_sails": "Keine Segel hinterlegt.",
"photo_add": "Foto hinzufügen",
"photo_change": "Foto ändern",
"photo_delete": "Foto löschen"
"photo_delete": "Foto löschen",
"tanks_section": "Tanks (Fassungsvermögen)",
"tanks_help": "Optional in Liter — ermöglicht Slider im Journal bei bekannten Tankgrößen.",
"freshwater_capacity_l": "Trinkwasser (Liter)",
"fuel_capacity_l": "Treibstoff (Liter)",
"greywater_capacity_l": "Grauwasser (Liter)",
"invalid_tank_liters": "Ungültiger Zahlenwert — bitte Liter als Zahl eingeben (z. B. 200)."
},
"logs": {
"title": "Logbuch-Journal",
"new_entry": "Neuer Reisetag",
"travel_details": "Reisedetails",
"add_event": "Neuen Logbucheintrag hinzufügen",
"add_event_btn": "Ereignis hinzufügen",
"edit_event": "Ereignis bearbeiten",
"save_event_btn": "Änderung speichern",
"cancel_event_edit": "Abbrechen",
"delete_event": "Ereignis löschen",
"sign_cleared_skipper_re_sign_title": "Skipper-Unterschrift entfernt",
"sign_cleared_skipper_re_sign": "Das Ereignisprotokoll wurde geändert. Die Skipper-Unterschrift wurde entfernt. Bitte erneut freigeben.",
"date": "Datum",
"day_of_travel": "Tag der Reise / Reisetag",
"day_of_travel": "Reisetag",
"travel_day_number": "Reisetag {{number}}",
"departure": "Start-Hafen (Reise von)",
"destination": "Ziel-Hafen (nach)",
"route": "Reise von/nach",
"freshwater": "Frischwasser (Liter)",
"fuel": "Treibstoff / Fuel (Liter)",
"greywater": "Grauwasser (Liter)",
"greywater_level": "Füllstand",
"tank_slider_of_max": "{{current}} / {{max}} L",
"tank_capacity_tooltip": "Wenn in den Schiffsdaten die Tank-Fassungsvermögen (Liter) hinterlegt sind, kannst du Füllstände hier per Slider eingeben.",
"morning": "Stand morgens",
"refilled": "Nachgefüllt",
"evening": "Stand abends",
@@ -136,6 +193,7 @@
"sign_passkey_signing": "Passkey wird angefordert…",
"sign_passkey_signed": "Freigegeben von {{username}}",
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
"sign_attribution_export": "{{username}} ({{date}})",
"sign_passkey_clear": "Passkey-Freigabe entfernen",
"sign_mode_passkey": "Passkey",
"sign_mode_classic": "Klassisch",
@@ -152,21 +210,113 @@
"sign_lock_notice": "Nach der Unterschrift sind Änderungen am Logbucheintrag (außer Fotos) nicht möglich, ohne dass Skipper und Crew erneut unterschreiben müssen.",
"sign_lock_active": "Dieser Eintrag ist unterschrieben. Änderungen am Logbuch (außer Fotos) entfernen Skipper- und Crew-Unterschrift automatisch.",
"sign_lock_warning_title": "Unterschrift bestätigen",
"sign_lock_warning": "Nach dem Unterschreiben sind Änderungen am Logbucheintrag (außer Fotos) nicht mehr möglich, ohne dass Skipper und Crew erneut unterschreiben müssen.\n\nMöchten Sie fortfahren?",
"sign_lock_warning": "Nach dem Unterschreiben sind Änderungen am Logbucheintrag (außer Fotos) nicht mehr möglich, ohne dass Skipper und Crew erneut unterschreiben müssen.\n\nMöchtest du fortfahren?",
"sign_proceed": "Unterschreiben",
"sign_cancel": "Abbrechen",
"sign_cleared_re_sign_title": "Unterschriften entfernt",
"sign_cleared_re_sign": "Der Logbucheintrag wurde geändert. Skipper- und Crew-Unterschrift wurden entfernt. Bitte erneut unterschreiben.",
"no_entries": "Keine Logbucheinträge für diese Yacht gefunden. Erstellen Sie Ihren ersten Reisetag!",
"no_entries": "Keine Logbucheinträge für diese Yacht gefunden. Erstelle deinen ersten Reisetag!",
"back_to_list": "Zurück zur Journal-Liste",
"save": "Logbuchseite speichern",
"saving": "Wird gespeichert...",
"saved": "Logbuchseite erfolgreich gespeichert!",
"loading": "Journal wird geladen...",
"view_mode_label": "Ansicht",
"view_list": "Liste",
"live_mode": "Live",
"live_title": "Live-Journal",
"live_loading": "Live-Journal wird geladen...",
"live_retry": "Erneut versuchen",
"live_load_error": "Live-Journal konnte nicht geladen werden.",
"live_action_error": "Eintrag konnte nicht gespeichert werden.",
"live_open_editor": "Vollständiger Editor",
"live_actions_label": "Schnellaktionen",
"live_stream_label": "Ereignisprotokoll",
"live_stream_title": "Journal",
"live_no_events": "Noch keine Einträge — tippe auf eine Aktion.",
"live_motor_start": "Motor Start",
"live_motor_stop": "Motor Stop",
"live_cast_off": "Ablegen",
"live_moor": "Anlegen",
"live_sails_btn": "Segel",
"live_sails_pick": "Segel auswählen",
"live_sails_pick_hint": "Mehrere Segel antippen (erneut antippen zum Abwählen), dann Eintragen.",
"live_sails_selected": "Auswahl: {{sails}}",
"live_sails_confirm": "Eintragen",
"live_sails_confirm_count": "Eintragen ({{count}})",
"live_sails": "Segel: {{sails}}",
"live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS nicht verfügbar. Breiten- und Längengrad manuell eingeben oder erneut per GPS-Knopf versuchen.",
"live_fix_gps_loading": "GPS-Position wird ermittelt…",
"live_fix_invalid": "Bitte gültige Koordinaten eingeben (Breite 90…90, Länge 180…180).",
"live_fix_lat_placeholder": "Breite (Lat)",
"live_fix_lng_placeholder": "Länge (Lng)",
"live_photo_btn": "Foto (Kamera)",
"live_photo_capture_btn": "Aufnehmen",
"live_photo_save_btn": "Speichern",
"live_photo_retake_btn": "Neu aufnehmen",
"live_photo_capture_failed": "Aufnahme fehlgeschlagen. Bitte erneut versuchen.",
"live_photo_open_camera_btn": "Kamera öffnen",
"live_photo_native_hint": "Foto mit der Gerätekamera aufnehmen und anschließend hier speichern.",
"live_photo_camera_starting": "Kamera wird gestartet…",
"live_photo_camera_denied": "Kamerazugriff verweigert oder nicht verfügbar.",
"live_photo_camera_unavailable": "Kamera wird von diesem Browser nicht unterstützt.",
"live_photo_error": "Foto konnte nicht gespeichert werden.",
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto aufgenommen",
"live_undo_photo_hint": "Foto gespeichert",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Freitext eingeben…",
"live_comment_confirm": "Eintragen",
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
"live_gps_start_hint": "Beginne deine Tagesreise immer mit einem Standort.",
"live_event_generic": "Ereignis",
"live_weather_btn": "Wetter",
"live_weather_owm_btn": "OpenWeatherMap Wetter abrufen",
"live_weather_owm_loading": "Wetter wird geladen…",
"live_weather_fix_required": "Für Wetter von OpenWeatherMap zuerst einen GPS-Fix eintragen (Schaltfläche „Fix“). Die Position darf höchstens 6 Stunden alt sein.",
"live_weather_fix_stale": "Der letzte GPS-Fix ist älter als 6 Stunden. Bitte erneut einen Fix loggen, bevor du Wetter abrufst.",
"live_wind_btn": "Wind",
"live_temp_btn": "T °C",
"live_pressure_btn": "Luftdruck",
"live_precip_btn": "Niederschlag",
"live_sea_state_btn": "Seegang",
"live_visibility_btn": "Sichtweite",
"live_course_btn": "Kurs",
"live_fuel_btn": "Diesel",
"live_water_btn": "Wasser",
"live_wind_entry": "Wind {{value}}",
"live_temp_entry": "Temperatur {{temp}} °C",
"live_pressure_entry": "Luftdruck {{value}} hPa",
"live_precip_entry": "Niederschlag {{value}}",
"live_sea_state_entry": "Seegang {{value}}",
"live_visibility_entry": "Sichtweite {{value}}",
"live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Wasser +{{liters}} L",
"live_auto_position": "Auto-Position",
"live_undo_hint": "Eintrag gespeichert",
"live_undo_btn": "Rückgängig",
"live_pressure_placeholder": "z. B. 1013",
"live_temp_placeholder": "z. B. 18",
"live_precip_placeholder": "z. B. leichter Regen",
"live_sea_state_placeholder": "z. B. 3",
"live_visibility_placeholder": "z. B. 10 km",
"live_course_placeholder": "z. B. 245",
"live_fuel_placeholder": "Nachgefüllte Liter",
"live_water_placeholder": "Nachgefüllte Liter",
"live_sog_btn": "SOG",
"live_stw_btn": "STW",
"live_sog_entry": "SOG {{speed}} kn",
"live_stw_entry": "STW {{speed}} kn",
"live_sog_placeholder": "z. B. 5,2",
"live_stw_placeholder": "z. B. 4,8",
"live_sog_hint": "Fahrt über Grund (kn) — GPS-Wert wird vorgefüllt, wenn verfügbar.",
"delete_entry": "Tag löschen",
"delete_confirm": "Sind Sie sicher, dass Sie diesen Reisetag unwiderruflich löschen möchten?",
"delete_confirm": "Bist du sicher, dass du diesen Reisetag unwiderruflich löschen möchtest?",
"carry_over_tanks_title": "Daten vom Vortag übernehmen?",
"carry_over_tanks_confirm": "Start-Hafen, Frischwasser- und Kraftstoff-Morgenstände vom letzten Reisetag übernehmen?\n\nStart-Hafen: {{departure}}\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L",
"carry_over_tanks_confirm": "Start-Hafen, Frischwasser-, Kraftstoff- und Grauwasser-Startstände vom letzten Reisetag übernehmen?\n\nStart-Hafen: {{departure}}\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L\nGrauwasser: {{greywater}} L",
"carry_over_tanks_yes": "Übernehmen",
"carry_over_tanks_no": "Mit 0 starten",
"event_title": "Chronologisches Ereignisprotokoll",
@@ -174,9 +324,32 @@
"event_time": "Uhrzeit",
"event_mgk": "MgK Kurs",
"event_rwk": "RwK Kurs",
"event_course_section": "Kurs",
"course_dial_hint": "Am Ring drehen oder Grad eingeben",
"course_dial_step_label": "Schrittweite",
"course_step_fine": "1°",
"course_step_medium": "5°",
"course_step_coarse": "10°",
"course_tab_mgk": "MgK",
"course_tab_rwk": "rwK",
"course_invalid": "Ungültiger Kurs (0360)",
"course_placeholder_degrees": "z. B. 180",
"course_placeholder_cardinal": "z. B. NW",
"compass_n": "N",
"compass_e": "O",
"compass_s": "S",
"compass_w": "W",
"wind_mode_cardinal": "Kardinal",
"wind_mode_degrees": "Als Grad",
"event_wind_direction": "Wind-Richtung",
"event_wind_strength": "Windstärke",
"event_sea_state": "Seegang",
"event_visibility": "Sichtweite",
"event_visibility_placeholder": "z. B. 10 km",
"weather_slider_unset": "—",
"weather_slider_pressure": "{{value}} hPa",
"weather_slider_sea_state": "Stufe {{value}}",
"weather_slider_heel": "{{value}}°",
"event_weather": "Wetter",
"event_log": "Logge (sm)",
"event_gps": "GPS-Position",
@@ -189,6 +362,10 @@
"event_heel": "Krängung (°)",
"event_sails": "Segelführung / Motor",
"motor_propulsion": "Maschinenfahrt",
"sails_picker_show_more": "Alle Segel anzeigen",
"sails_picker_show_less": "Weniger anzeigen",
"motor_hours": "Maschinenstunden (gesamt)",
"fuel_per_motor_hour": "Verbrauch pro Maschinenstunde",
"event_distance": "Distanz (sm)",
"export_csv": "CSV herunterladen",
"share_csv": "CSV teilen",
@@ -200,16 +377,16 @@
"photo_btn": "Foto aufnehmen / Hochladen",
"photo_processing": "Wird verarbeitet...",
"no_photos": "Noch keine Fotos an diesen Reisetag angehängt.",
"photo_delete_confirm": "Sind Sie sicher, dass Sie dieses Foto unwiderruflich löschen möchten?",
"photo_delete_confirm": "Bist du sicher, dass du dieses Foto unwiderruflich löschen möchtest?",
"confirm_yes": "Ja",
"confirm_no": "Nein",
"track_upload_title": "GPS-Track (Datei)",
"track_upload_points": "Punkte",
"gps_tracking_btn_gpx": "Track-Datei herunterladen",
"gps_track_upload_help": "Ziehen Sie eine GPX-, KML- oder GeoJSON-Datei hierher oder klicken Sie zum Auswählen",
"gps_track_upload_help": "Zieh eine GPX-, KML- oder GeoJSON-Datei hierher oder klicke zum Auswählen",
"gps_track_upload_btn": "GPS-Track hochladen",
"gps_track_delete": "Track-Datei löschen",
"gps_track_delete_confirm": "Sind Sie sicher, dass Sie diese Track-Datei dauerhaft löschen möchten?",
"gps_track_delete_confirm": "Bist du sicher, dass du diese Track-Datei dauerhaft löschen möchtest?",
"track_distance": "GPS-Strecke (sm)",
"track_speed_max": "Max. Geschwindigkeit (kn)",
"track_speed_avg": "Ø Geschwindigkeit (kn)",
@@ -218,48 +395,304 @@
"track_map_end": "Ziel",
"track_map_speed_slow": "langsam",
"track_map_speed_fast": "schnell",
"nmea_import_title": "NMEA-Protokoll importieren",
"nmea_import_intro": "Lade eine .nmea-Datei vom Bord-Logger. Die App schlägt Journal-Einträge vor — du entscheidest, was übernommen wird.",
"nmea_import_btn": "NMEA importieren",
"nmea_file_label": "NMEA-Datei",
"nmea_stats": "{{lines}} Sätze erkannt · Typen: {{types}}",
"nmea_warn_no_position": "Keine Positions-Sätze gefunden — Track und GPS-Felder können leer bleiben.",
"nmea_warn_duplicate_file": "Diese NMEA-Datei wurde bereits importiert. Ein erneuter Import derselben Datei fügt doppelte Journal-Einträge hinzu.",
"nmea_mode_label": "Journal-Einträge erzeugen",
"nmea_mode_interval": "Nach Zeitintervall",
"nmea_mode_change": "Bei signifikanter Änderung",
"nmea_mode_both": "Beides (zusammenführen)",
"nmea_interval_label": "Intervall (Minuten)",
"nmea_import_track": "GPS-Track aus NMEA übernehmen",
"nmea_preview": "Vorschau",
"nmea_preview_hint": "{{count}} vorgeschlagene Journal-Einträge",
"nmea_select_all": "Alle auswählen",
"nmea_select_none": "Keine auswählen",
"nmea_source_interval": "Intervall",
"nmea_source_change": "Ereignis",
"nmea_apply": "In Journal übernehmen",
"nmea_back": "Zurück",
"nmea_cancel": "Abbrechen",
"nmea_archive_question": "Rohprotokoll lokal archivieren? (Nur auf diesem Gerät, nicht synchronisiert.)",
"nmea_archive_keep": "Archivieren",
"nmea_archive_discard": "Verwerfen",
"nmea_archive_stored": "NMEA archiviert: {{name}}",
"nmea_archive_delete_confirm": "Archiviertes NMEA-Protokoll von diesem Gerät löschen?",
"nmea_error_no_samples": "Keine verwertbaren NMEA-Sätze in der Datei.",
"nmea_error_parse": "NMEA-Datei konnte nicht gelesen werden.",
"nmea_error_read": "Datei konnte nicht gelesen werden.",
"nmea_error_no_file": "Bitte zuerst eine NMEA-Datei wählen.",
"nmea_error_no_selection": "Bitte mindestens einen Journal-Eintrag auswählen.",
"nmea_remark_interval": "NMEA Intervall",
"nmea_remark_uncertain": "unsicher",
"nmea_remark_depth": "Tiefe {{depth}} m",
"nmea_change_course": "Kursänderung {{from}}° → {{to}}°",
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
"nmea_change_pressure": "Luftdruck {{from}} → {{to}} hPa",
"nmea_change_depth": "Tiefe {{from}} → {{to}} m",
"nmea_change_engine_start": "Motor an ({{rpm}} U/min)",
"nmea_change_engine_stop": "Motor aus",
"nmea_change_autopilot_on": "Autopilot ein",
"nmea_change_autopilot_off": "Autopilot aus",
"nmea_change_gps_lost": "GPS-Fix verloren",
"nmea_change_gps_regained": "GPS-Fix wiederhergestellt",
"nmea_change_water_temp": "Wassertemp. {{from}} → {{to}} °C",
"nmea_change_departure": "Abfahrt / Fahrtbeginn",
"nmea_change_anchor": "Ankern / Stop",
"nmea_change_speed": "Geschw. {{from}} → {{to}} kn",
"track_map_error": "Karte konnte nicht geladen werden.",
"exporting": "Exportiere...",
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.",
"invite_crew": "Crew einladen",
"invite_link_copied": "Einladungslink in die Zwischenablage kopiert!",
"invite_link_desc": "Teilen Sie diesen Link mit Crewmitgliedern, um ihnen Schreibrechte für dieses Logbuch zu gewähren.",
"invite_link_desc": "Teile diesen Link mit Crewmitgliedern, um ihnen Schreibrechte für dieses Logbuch zu gewähren.",
"collaborators_list": "Mitglieder / Crew",
"revoke": "Entfernen",
"revoke_confirm": "Sind Sie sicher, dass Sie diesem Crewmitglied den Zugriff entziehen möchten?",
"revoke_confirm": "Bist du sicher, dass du diesem Crewmitglied den Zugriff entziehen möchtest?",
"invite_role": "Rolle",
"invite_expires": "Link ist 48 Stunden lang gültig"
},
"dashboard": {
"title": "Ihre Logbücher",
"subtitle": "Wählen Sie ein Logbuch aus oder erstellen Sie ein neues, um Ihre Reisen zu verwalten.",
"title": "Deine Logbücher",
"subtitle": "Wähle ein Logbuch aus oder erstelle ein neues, um deine 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-Kopien werden vernichtet.\n\nTipp: Erstellen Sie vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok.json), falls Sie die Daten später behalten möchten.",
"no_logbooks": "Keine Logbücher gefunden. Erstellen Sie Ihr erstes Logbuch, um zu beginnen!",
"logged_in_as": "Angemeldet als {{name}}",
"delete_confirm": "Bist du sicher, dass du dieses Logbuch unwiderruflich löschen möchtest? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstelle vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok.json), falls du die Daten später behalten möchtest.",
"no_logbooks": "Keine Logbücher gefunden. Erstelle dein erstes Logbuch, um zu beginnen!",
"loading": "Logbücher werden geladen...",
"status_synced": "Synchronisiert",
"status_local": "Nur lokaler Cache",
"delete_btn": "Logbuch löschen",
"section_owned": "Meine Logbücher",
"section_shared": "Geteilte Logbücher",
"section_shared_hint": "Sie wurden als Crew-Mitglied eingeladen. Skipper-Profil und Einstellungen gehören dem Eigner.",
"section_shared_hint": "Du wurdest als Crew-Mitglied eingeladen. Skipper-Profil und Einstellungen gehören dem Eigner.",
"role_owner": "Eigenes Logbuch",
"role_owner_hint": "Sie sind Eigner und Skipper dieses Logbuchs",
"role_owner_hint": "Du bist Eigner und Skipper dieses Logbuchs",
"role_crew": "Crew-Zugang",
"role_crew_hint": "Eingeladenes Logbuch — Sie können als Crew mitarbeiten und signieren",
"role_crew_hint": "Eingeladenes Logbuch — du kannst als Crew mitarbeiten und signieren",
"role_read": "Nur Lesen",
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung"
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung",
"open_profile": "Profil von {{name}} öffnen",
"open_logbook": "Logbuch „{{title}}“ öffnen",
"edit_title": "Logbuch umbenennen",
"edit_placeholder": "Neuer Name des Logbuchs",
"edit_success": "Logbuch erfolgreich umbenannt",
"edit_btn": "Umbenennen",
"filter_label": "Logbücher filtern",
"filter_placeholder": "Name, Jahr, Datum, Crew oder Schiff …",
"filter_clear": "Filter zurücksetzen",
"filter_results": "{{count}} Treffer",
"filter_no_results": "Keine Logbücher passen zu deiner Suche. Probiere einen anderen Namen oder ein anderes Jahr.",
"sort_label": "Sortieren",
"sort_by_label": "Sortieren nach",
"sort_by_name": "Name",
"sort_by_date": "Datum",
"sort_dir_label": "Reihenfolge",
"sort_asc": "Aufsteigend",
"sort_desc": "Absteigend",
"sort_name_asc": "Name A bis Z",
"sort_name_desc": "Name Z bis A",
"sort_date_asc": "Älteste zuerst",
"sort_date_desc": "Neueste zuerst"
},
"profile": {
"title": "Benutzerprofil",
"subtitle": "Konto, Passkeys und Statistiken für {{name}}",
"back": "Zurück zum Dashboard",
"loading": "Profil wird geladen…",
"load_error": "Profil konnte nicht geladen werden.",
"copy_failed": "Kopieren fehlgeschlagen.",
"processing": "Wird verarbeitet…",
"identity_title": "Konto-Identität",
"username": "Benutzername",
"user_id": "Benutzer-ID",
"copy_user_id": "Benutzer-ID kopieren",
"account_since": "Konto seit",
"prf_status": "Passkey-Schlüsselableitung (PRF)",
"prf_active": "Aktiv",
"prf_inactive": "Nicht eingerichtet",
"passkeys_title": "Passkeys",
"passkeys_desc": "Registriere auf jedem Gerät einen eigenen Passkey. So kannst du dich auch nach einem Plattformwechsel anmelden.",
"passkeys_empty": "Keine Passkeys gefunden.",
"add_passkey_btn": "Neuen Passkey hinzufügen",
"add_passkey_success": "Passkey erfolgreich hinzugefügt.",
"add_passkey_failed": "Passkey konnte nicht hinzugefügt werden.",
"remove_passkey_btn": "Passkey entfernen",
"remove_passkey_last_title": "Letzter Passkey",
"remove_passkey_last_desc": "Der einzige Passkey kann nicht entfernt werden, ohne den Zugang zu deinem Konto zu verlieren. Um das Konto vollständig zu löschen, nutze die Gefahrenzone am Ende dieser Seite.",
"remove_passkey_failed": "Passkey konnte nicht entfernt werden.",
"remove_passkey_confirm_title": "Passkey entfernen?",
"remove_passkey_confirm_desc": "Dieses Gerät kann sich danach nicht mehr mit diesem Passkey anmelden.",
"remove_passkey_confirm_yes": "Entfernen",
"remove_passkey_confirm_no": "Abbrechen",
"pin_title": "Lokaler PIN",
"pin_status": "Status",
"pin_active": "Aktiv auf diesem Gerät",
"pin_inactive": "Nicht eingerichtet",
"pin_confirm_label": "PIN bestätigen",
"pin_confirm_placeholder": "PIN erneut eingeben",
"pin_set_btn": "PIN einrichten",
"pin_change_btn": "PIN ändern",
"pin_remove_btn": "PIN entfernen",
"pin_saved": "PIN gespeichert.",
"pin_save_failed": "PIN konnte nicht gespeichert werden.",
"pin_mismatch": "Die PIN-Eingaben stimmen nicht überein.",
"pin_length_error": "Die PIN muss mindestens 4 Zeichen haben.",
"pin_no_session": "Sitzung abgelaufen — bitte erneut anmelden.",
"remove_pin_confirm_title": "PIN entfernen?",
"remove_pin_confirm_desc": "Du musst dich auf diesem Gerät wieder mit Passkey oder Wiederherstellungsschlüssel anmelden.",
"remove_pin_confirm_yes": "PIN entfernen",
"remove_pin_confirm_no": "Abbrechen",
"security_title": "Sicherheits-Checkliste",
"security_desc": "Überblick über die wichtigsten Schutzmechanismen deines Kontos.",
"security_passkeys_ok": "Mindestens ein Passkey registriert",
"security_passkeys_missing": "Kein Passkey registriert",
"security_prf_ok": "PRF-Schlüsselableitung aktiv",
"security_prf_missing": "PRF nicht eingerichtet",
"security_pin_ok": "Lokaler PIN auf diesem Gerät",
"security_pin_missing": "Kein lokaler PIN",
"security_recovery_ok": "Wiederherstellungsschlüssel eingerichtet",
"security_recovery_hint": "Die 12 Wörter wurden bei der Registrierung angezeigt. Bewahre sie offline und getrennt vom Gerät auf. Du kannst unten einen neuen Schlüssel erstellen — der alte wird dann ungültig.",
"recovery_rotate_btn": "Neuen Wiederherstellungsschlüssel erstellen",
"recovery_rotate_confirm_title": "Neuen Wiederherstellungsschlüssel erstellen?",
"recovery_rotate_confirm_desc": "Der bisherige 12-Wörter-Schlüssel wird sofort ungültig. Stelle sicher, dass du den neuen Schlüssel sicher aufbewahrst, bevor du fortfährst.",
"recovery_rotate_confirm_yes": "Neuen Schlüssel erstellen",
"recovery_rotate_confirm_no": "Abbrechen",
"recovery_rotate_new_warning": "WICHTIG: Schreib diese 12 Wörter auf und bewahre sie offline auf. Der bisherige Wiederherstellungsschlüssel ist ab sofort ungültig.",
"recovery_rotate_failed": "Wiederherstellungsschlüssel konnte nicht erstellt werden.",
"recovery_rotate_no_session": "Verschlüsselungssitzung abgelaufen — bitte abmelden und erneut anmelden, dann erneut versuchen.",
"device_title": "Dieses Gerät",
"device_desc": "Lokaler Cache, Sync-Status und Schnell-Login auf diesem Browser.",
"device_sync_pending": "{{count}} ausstehende Sync-Einträge",
"device_sync_ok": "Alle lokalen Änderungen synchronisiert",
"device_remembered": "Account für Schnell-Login auf diesem Gerät gespeichert",
"device_not_remembered": "Account nicht in der Schnell-Login-Liste",
"device_forget_btn": "Account auf diesem Gerät vergessen",
"device_forget_confirm_title": "Schnell-Login entfernen?",
"device_forget_confirm_desc": "Der Account verschwindet aus der Schnell-Login-Liste auf diesem Gerät. Deine Session und lokalen Logbücher bleiben erhalten.",
"device_forget_confirm_yes": "Entfernen",
"device_forget_confirm_no": "Abbrechen",
"passkey_label": "Name für neuen Passkey (optional)",
"passkey_label_placeholder": "z. B. MacBook, iPhone",
"passkey_rename_btn": "Name speichern",
"passkey_rename_success": "Passkey-Name gespeichert.",
"passkey_rename_failed": "Passkey-Name konnte nicht gespeichert werden.",
"passkey_unnamed": "Unbenannter Passkey",
"stats_title": "Statistiken",
"stats_subtitle": "Über alle deine Logbücher auf diesem Gerät",
"stats_logbooks": "Logbücher",
"stats_account_since": "Konto seit",
"stats_shared_logbooks": "Geteilte Logbücher",
"appearance_title": "App & Darstellung",
"appearance_desc": "Design und Farbschema gelten für die gesamte App auf diesem Gerät.",
"theme_label": "Design-Stil der App",
"theme_auto": "Automatisch (OS-Erkennung)",
"theme_ocean": "Ocean (Glassmorphismus)",
"theme_material": "Material (Android)",
"theme_cupertino": "Cupertino (iOS)",
"color_scheme_label": "Hell- oder Dunkelmodus",
"color_scheme_auto": "Automatisch (System)",
"color_scheme_light": "Hell",
"color_scheme_dark": "Dunkel",
"integrations_title": "Integrationen",
"owm_key": "OpenWeatherMap API-Schlüssel",
"owm_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.",
"prefs_save": "Speichern",
"prefs_saving": "Wird gespeichert…",
"prefs_saved": "Gespeichert",
"tour_title": "App-Tour",
"tour_desc": "Lass dich erneut durch die wichtigsten Bereiche der App führen.",
"tour_restart": "Tour erneut starten",
"push_title": "Push-Benachrichtigungen",
"push_desc": "Als Logbuch-Eigner wirst du benachrichtigt, wenn eingeladene Crewmitglieder Änderungen synchronisieren. Es werden keine Inhalte im Klartext übermittelt.",
"push_enable": "Bei Crew-Änderungen benachrichtigen",
"push_active": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
"push_unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.",
"push_denied_hint": "Benachrichtigungen sind blockiert. Erlaube sie in den Browser- oder Geräteeinstellungen.",
"push_ios_install_hint": "Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), um Push zu nutzen.",
"push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden.",
"sections": {
"account": "Konto & Einstellungen",
"fleet": "Flotte & Crew",
"security": "Sicherheit & Gerät",
"stats": "Statistik",
"danger": "Gefahrenzone"
}
},
"vessel_pool": {
"title": "Schiffsflotte",
"section_title": "Deine Schiffe",
"subtitle": "Pflege hier alle Schiffe für deine Logbücher. Pro Logbuch wählst du das aktive Schiff aus dieser Liste.",
"loading": "Schiffsflotte wird geladen…",
"add_vessel": "Schiff hinzufügen",
"edit_vessel": "Schiff bearbeiten",
"no_vessels": "Noch keine Schiffe im Pool.",
"delete_confirm": "Dieses Schiff wirklich aus der Flotte entfernen?",
"max_vessels": "Maximale Anzahl von 20 Schiffen im Pool erreicht."
},
"logbook_vessel": {
"title": "Schiff für dieses Logbuch",
"subtitle": "Wähle das Schiff für dieses Logbuch. Reisetage nutzen Segel- und Tankdaten des gewählten Schiffs.",
"active_vessel": "Schiff für dieses Logbuch",
"no_vessels_in_pool": "Kein Schiff in der Flotte zuerst im Benutzerprofil anlegen.",
"no_vessel": "Kein Schiff gewählt",
"unnamed": "Unbenannt",
"save": "Schiff speichern",
"saved": "Schiff für das Logbuch gespeichert.",
"selection_only_hint": "Du siehst das vom Eigner gewählte Schiff (geteiltes Logbuch).",
"manage_in_profile": "Schiffe im Benutzerprofil verwalten"
},
"person_pool": {
"title": "Stammcrew & Skipper",
"subtitle": "Lege hier deinen Personen-Pool an Skipper und Crew für alle Logbücher. Aus diesem Pool wählst du pro Logbuch und Reisetag die aktive Crew.",
"loading": "Personen-Pool wird geladen…",
"skippers_section": "Stammskipper",
"crew_section": "Stammcrew",
"add_skipper": "Skipper hinzufügen",
"add_crew": "Crew-Mitglied hinzufügen",
"edit_skipper": "Skipper bearbeiten",
"no_skippers": "Noch kein Skipper im Pool.",
"no_crew": "Noch keine Crew-Mitglieder im Pool.",
"delete_confirm": "Diese Person wirklich aus dem Pool entfernen?"
},
"logbook_crew": {
"title": "Crew für dieses Logbuch",
"subtitle": "Wähle Skipper und Crew für dieses Logbuch. Neue Reisetage übernehmen diese Auswahl standardmäßig.",
"loading": "Crew wird geladen…",
"active_skipper": "Skipper für dieses Logbuch",
"active_crew": "Crew für dieses Logbuch",
"no_skippers_in_pool": "Kein Skipper im Pool zuerst im Benutzerprofil anlegen.",
"no_crew_in_pool": "Keine Crew im Pool zuerst im Benutzerprofil anlegen.",
"no_skipper": "Kein Skipper gewählt",
"unnamed": "Unbenannt",
"save": "Crew speichern",
"saved": "Crew für das Logbuch gespeichert.",
"selection_only_hint": "Du siehst die vom Eigner festgelegte Crew (geteiltes Logbuch)."
},
"entry_crew": {
"title": "Crew an diesem Reisetag",
"subtitle": "Kann vom Logbuch-Standard abweichen. Folge-Reisetage übernehmen den Vortag.",
"day_skipper": "Skipper an diesem Tag",
"day_crew": "Crew an diesem Tag",
"no_skipper": "Kein Skipper gewählt",
"no_crew": "Keine Crew gewählt"
},
"crew": {
"title": "Skipper- & Crew-Profile",
"skipper_section": "Skipper-Profil",
"skipper_read_only_hint": "Das Skipper-Profil kann nur vom Logbuch-Eigner bearbeitet werden.",
"crew_section": "Crew-Liste",
"add_crew": "Crew-Mitglied hinzufügen",
"edit_crew": "Crew-Mitglied bearbeiten",
"no_crew": "Noch keine Crew-Mitglieder hinzugefügt.",
"max_crew": "Maximale Anzahl von 5 Crew-Mitgliedern erreicht.",
"max_crew": "Maximale Anzahl von 12 Crew-Mitgliedern im Pool erreicht.",
"name": "Name",
"address": "Anschrift",
"birthdate": "Geburtstag",
@@ -273,11 +706,11 @@
"save_member": "Mitglied speichern",
"saved": "Skipper-Profil erfolgreich gespeichert!",
"loading": "Crew-Dateien werden geladen...",
"delete_confirm": "Sind Sie sicher, dass Sie dieses Crew-Mitglied entfernen möchten?"
"delete_confirm": "Bist du sicher, dass du dieses Crew-Mitglied entfernen möchtest?"
},
"deviation": {
"title": "Ablenkungstabelle (Compass Deviation)",
"subtitle": "Tragen Sie die Magnetkompass-Ablenkung (Abl.) für Kurse (MgK) von 000° bis 360° in 10°-Schritten ein.",
"subtitle": "Trag die Magnetkompass-Ablenkung (Abl.) für Kurse (MgK) von 000° bis 360° in 10°-Schritten ein.",
"heading": "MgK",
"deviation": "Ablenkung",
"save": "Kalibrierungsgitter speichern",
@@ -286,53 +719,44 @@
"loading": "Kalibrierungstabelle wird geladen..."
},
"settings": {
"title": "Systemeinstellungen",
"subtitle": "Konfigurieren Sie externe Integrationen und Anmeldedaten.",
"owm_title": "Wetter-Integration",
"owm_key": "OpenWeatherMap API-Schlüssel",
"save": "Konfiguration speichern",
"saving": "Wird gespeichert...",
"saved": "Einstellungen erfolgreich gespeichert!",
"key_help": "Ein API-Schlüssel wird benötigt, um Wetterparameter und Seebedingungen automatisch anhand von GPS-Koordinaten abzurufen.",
"no_key": "Bitte hinterlegen Sie Ihren OpenWeatherMap API-Schlüssel in den Einstellungen, um Wetterdaten abzurufen.",
"title": "Logbuch-Einstellungen",
"subtitle": "Teilen, Backup und Zusammenarbeit für dieses Logbuch.",
"select_logbook_hint": "Wähle ein Logbuch aus, um dessen Einstellungen zu bearbeiten.",
"no_key": "Kein OpenWeatherMap-API-Schlüssel verfügbar. Hinterlege einen eigenen Schlüssel im Benutzerprofil oder kontaktiere den Betreiber.",
"weather_success": "Wetterdaten erfolgreich abgerufen!",
"weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfen Sie den API-Schlüssel und die Verbindung.",
"weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfe den API-Schlüssel und die Verbindung.",
"weather_date_mismatch": "Wetterdaten können nur für den heutigen Tag ({{today}}) abgerufen werden. Dieser Logbucheintrag ist auf den {{date}} datiert.",
"gps_error": "Bitte geben Sie einen Ort an oder ermitteln Sie 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)",
"color_scheme_title": "Erscheinungsbild",
"color_scheme_label": "Hell- oder Dunkelmodus (Standard: Systemeinstellung)",
"color_scheme_auto": "Automatisch (System)",
"color_scheme_light": "Hell",
"color_scheme_dark": "Dunkel",
"gps_error": "Bitte gib einen Ort an oder ermittle die GPS-Koordinaten.",
"share_title": "Logbuch teilen (Schreibgeschützt)",
"share_desc": "Aktivieren Sie diese Option, um einen öffentlichen, schreibgeschützten Link zu erstellen. Jeder mit dem Link kann Ihre Reisen, Yacht-Profile und Besatzung ansehen. Die Verschlüsselungsschlüssel werden niemals an den Server übertragen (sie bleiben im Hash-Teil der URL).",
"share_desc": "Aktiviere diese Option, um einen öffentlichen, schreibgeschützten Link zu erstellen. Jeder mit dem Link kann deine Reisen, Yacht-Profile und Besatzung ansehen. Die Verschlüsselungsschlüssel werden niemals an den Server übertragen (sie bleiben im Hash-Teil der URL).",
"share_privacy_warning": "Empfehlung: Teile diesen Link nur privat (z. B. per E-Mail oder Messenger), nicht in sozialen Medien.",
"share_enable": "Öffentlichen Link aktivieren",
"share_copied": "Link kopiert!",
"share_copy_btn": "Link kopieren",
"link_qr_hint": "QR-Code zum Scannen mit dem Smartphone",
"link_qr_alt": "QR-Code für den Link",
"danger_zone_title": "Gefahrenzone",
"danger_zone_desc": "Durch das Löschen Ihres Kontos werden alle Ihre Passkeys, Logbücher, Schiffsdaten, Crew-Profile, Reiseeinträge und E2E-Schlüssel unwiderruflich gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"danger_zone_desc": "Durch das Löschen deines Kontos werden alle deine Passkeys, Logbücher, Schiffsdaten, Crew-Profile, Reiseeinträge und E2E-Schlüssel unwiderruflich gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"delete_account_btn": "Konto unwiderruflich löschen",
"delete_account_confirm_title": "Konto löschen?",
"delete_account_confirm_desc": "Sind Sie absolut sicher, dass Sie Ihr Konto und alle zugehörigen Logbücher und E2E-verschlüsselten Daten unwiderruflich löschen möchten?",
"delete_account_confirm_desc": "Bist du absolut sicher, dass du dein Konto und alle zugehörigen Logbücher und E2E-verschlüsselten Daten unwiderruflich löschen möchtest?",
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
"delete_account_confirm_no": "Abbrechen",
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.",
"delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok.json) in den Einstellungen jedes Logbuchs.",
"deleting_account": "Konto wird gelöscht…",
"tour_title": "App-Tour",
"tour_desc": "Lassen Sie sich erneut durch die wichtigsten Bereiche der App führen.",
"tour_restart": "Tour erneut starten",
"invite_push_prompt_title": "Push-Benachrichtigungen aktivieren?",
"invite_push_prompt_message": "Sobald eingeladene Crewmitglieder Änderungen synchronisieren, kannst du per Push informiert werden. Es werden keine Logbuch-Inhalte im Klartext gesendet.",
"invite_push_prompt_ios_message": "Sobald Crewmitglieder Änderungen synchronisieren, kannst du per Push informiert werden. Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), dann Push im Benutzerprofil aktivieren.",
"invite_push_prompt_enable": "Jetzt aktivieren",
"invite_push_prompt_later": "Später",
"invite_push_prompt_success": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
"backup_title": "Backup & Wiederherstellung",
"backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.",
"backup_export_title": "Backup erstellen",
"backup_export_desc": "Lädt alle lokalen Daten als .daagbok.json herunter. Bewahren Sie Datei und Passphrase getrennt und sicher auf.",
"backup_export_desc": "Lädt alle lokalen Daten als .daagbok.json herunter. Bewahre Datei und Passphrase getrennt und sicher auf.",
"backup_restore_title": "Backup wiederherstellen",
"backup_restore_desc": "Stellt ein Backup in Ihrem aktuellen Account wieder her — auch nach Registrierung eines neuen Accounts.",
"backup_restore_desc": "Stellt ein Backup in deinem aktuellen Account wieder her — auch nach Registrierung eines neuen Accounts.",
"backup_passphrase": "Backup-Passphrase",
"backup_passphrase_placeholder": "Mindestens 8 Zeichen",
"backup_passphrase_confirm": "Passphrase bestätigen",
@@ -352,7 +776,7 @@
"backup_invalid_json": "Die Datei ist keine gültige JSON-Datei.",
"backup_invalid_format": "Unbekanntes oder veraltetes Backup-Format.",
"backup_not_owner": "Nur der Logbuch-Eigner kann Backups erstellen.",
"backup_not_authenticated": "Bitte melden Sie sich an, um ein Backup wiederherzustellen.",
"backup_not_authenticated": "Bitte melde dich an, um ein Backup wiederherzustellen.",
"backup_id_conflict": "Ein Logbuch mit dieser ID existiert bereits.",
"backup_overwrite_confirm": "Das vorhandene Logbuch mit gleicher ID wird ersetzt. Fortfahren?",
"backup_new_id_confirm": "Das Backup als neues Logbuch mit neuer ID importieren?",
@@ -364,13 +788,13 @@
},
"disclaimer": {
"title": "Wichtige Hinweise",
"intro": "Bitte lesen Sie die folgenden Hinweise, bevor Sie Kapteins Daagbok nutzen.",
"intro": "Bitte lies die folgenden Hinweise, bevor du Kapteins Daagbok nutzt.",
"e2e_title": "Ende-zu-Ende-Verschlüsselung",
"e2e_body": "Ihre Logbuchdaten werden Ende-zu-Ende verschlüsselt. Nur Sie bzw. Personen mit Ihrem Schlüssel können die Inhalte lesen. Auf dem Server werden ausschließlich verschlüsselte Daten gespeichert.",
"e2e_body": "Deine Logbuchdaten werden Ende-zu-Ende verschlüsselt. Nur du bzw. Personen mit deinem Schlüssel können die Inhalte lesen. Auf dem Server werden ausschließlich verschlüsselte Daten gespeichert.",
"pwa_title": "Progressive Web App (PWA)",
"pwa_body": "Kapteins Daagbok läuft als Progressive Web App in Ihrem Browser und kann auf Ihrem Gerät installiert werden ähnlich wie eine native App, ohne App-Store.",
"pwa_body": "Kapteins Daagbok läuft als Progressive Web App in deinem Browser und kann auf deinem Gerät installiert werden ähnlich wie eine native App, ohne App-Store.",
"storage_title": "Lokale Speicherung & Synchronisation",
"storage_body": "Ihre Daten werden lokal auf Ihrem Gerät zwischengespeichert (IndexedDB). Bei aktiver Internetverbindung werden Änderungen mit dem Server synchronisiert. Ohne Verbindung können Sie weiterarbeiten; die Synchronisation erfolgt später.",
"storage_body": "Deine Daten werden lokal auf deinem Gerät zwischengespeichert (IndexedDB). Bei aktiver Internetverbindung werden Änderungen mit dem Server synchronisiert. Ohne Verbindung kannst du weiterarbeiten; die Synchronisation erfolgt später.",
"free_title": "Kostenlos & werbefrei",
"free_body": "Kapteins Daagbok ist kostenlos und enthält keine Werbung.",
"liability_title": "Haftungsausschluss",
@@ -382,6 +806,29 @@
"close": "Schließen",
"button_title": "Hinweise & Haftungsausschluss"
},
"feedback": {
"button_title": "Feedback senden",
"title": "Feedback",
"intro": "Teile Fehler, Ideen oder allgemeines Feedback. Deine Nachricht wird über einen sicheren Benachrichtigungskanal an das Projektteam gesendet.",
"category_label": "Kategorie",
"category_general": "Allgemein",
"category_bug": "Fehler melden",
"category_feature": "Feature-Wunsch",
"category_translation": "Übersetzungsfehler",
"contact_label": "E-Mail (optional)",
"contact_placeholder": "deine@email.beispiel",
"message_label": "Nachricht",
"message_placeholder": "Beschreib dein Feedback…",
"send": "Senden",
"sending": "Wird gesendet…",
"cancel": "Abbrechen",
"success": "Vielen Dank! Dein Feedback wurde gesendet.",
"error_send": "Feedback konnte nicht gesendet werden. Bitte versuche es später erneut.",
"error_invalid_email": "Bitte gib eine gültige E-Mail-Adresse ein.",
"error_not_configured": "Feedback ist auf diesem Server nicht verfügbar.",
"error_rate_limited": "Zu viele Feedback-Nachrichten in kurzer Zeit. Bitte warte einige Minuten.",
"error_spam": "Diese Nachricht konnte nicht gesendet werden. Bitte formuliere sie anders."
},
"demo": {
"logbook_title": "Demo-Logbuch Ostsee",
"badge": "Demo",
@@ -389,6 +836,36 @@
"cta_register": "Account erstellen",
"back_to_login": "Zur Anmeldung"
},
"invitation": {
"error_invalid_key": "Der Einladungslink ist kryptografisch ungültig (Schlüssel fehlerhaft).",
"error_missing_key": "Der Einladungslink enthält keinen Entschlüsselungsschlüssel (#key=...). Bitte den vollständigen Link vom Eigner verwenden.",
"error_expired": "Diese Einladung ist abgelaufen (48 Stunden gültig).",
"error_invalid_token": "Einladungstoken ungültig.",
"error_load_failed": "Einladungsdetails konnten nicht geladen werden.",
"error_incomplete_session": "Sitzung unvollständig — bitte erneut anmelden (Benutzer-ID fehlt).",
"error_accept_failed": "Beitritt fehlgeschlagen.",
"error_login_failed": "Passkey-Anmeldung fehlgeschlagen.",
"error_username_missing": "Benutzername konnte nicht ermittelt werden — bitte erneut anmelden.",
"error_register_failed": "Registrierung fehlgeschlagen.",
"loading_joining": "Beitritt...",
"loading_checking": "Einladung wird geprüft...",
"loading_unlocking": "Logbuch wird freigeschaltet und synchronisiert...",
"loading_retrieving_key": "Lade Verschlüsselungsschlüssel...",
"error_title": "Einladungsfehler",
"back_to_start": "Zurück zum Start",
"title": "Logbuch-Einladung",
"invited_by": "Einladung von",
"vessel_logbook": "Schiff / Logbuch",
"signed_in_preparing": "Angemeldet als {{username}}. Beitritt wird vorbereitet...",
"join_again": "Erneut beitreten",
"login_or_register_hint": "Melde dich an oder registriere ein Konto, um dem Logbuch beizutreten.",
"or_sign_up": "ODER NEU REGISTRIEREN",
"register_crew_account": "Neues Crew-Konto erstellen",
"username_label": "Benutzername",
"create_passkey": "Passkey erstellen",
"switch_language_en": "English",
"switch_language_de": "Deutsch"
},
"stats": {
"title": "Statistik",
"subtitle": "Strecken, Verbrauch und Antriebsart auf einen Blick",
@@ -401,6 +878,9 @@
"travel_days": "Reisetage",
"sail_distance": "Unter Segel",
"motor_distance": "Maschinenfahrt",
"motor_hours_total": "Maschinenstunden gesamt",
"daily_motor_hours": "Maschinenstunden pro Reisetag",
"avg_motor_hours": "Ø Maschinenstunden pro Reisetag",
"unknown_propulsion": "Unbekannt",
"fuel_total": "Kraftstoff gesamt",
"water_total": "Wasser gesamt",
@@ -414,13 +894,22 @@
"avg_fuel": "Ø Kraftstoff",
"avg_water": "Ø Wasser",
"fuel_per_nm": "Kraftstoff pro sm",
"fuel_per_motor_hour": "Kraftstoff pro Maschinenstunde",
"daily_fuel_per_motor_hour": "Kraftstoffverbrauch pro Maschinenstunde je Reisetag",
"fuel_legend": "Kraftstoff",
"water_legend": "Wasser",
"unit_nm": "sm",
"unit_h": "h",
"unit_l": "L",
"day_label": "Tag {{day}}",
"account_logbooks": "Logbücher im Überblick",
"col_logbook": "Logbuch"
"col_logbook": "Logbuch",
"event_series_title": "Ereignis-Verläufe",
"event_series_hint": "Chronologische Werte aus dem Ereignisprotokoll.",
"event_series_pressure": "Luftdruck",
"event_series_wind": "Wind",
"event_series_motor": "Motor",
"event_series_empty": "Keine Einträge vorhanden."
},
"tour": {
"skip": "Tour überspringen",
@@ -431,19 +920,19 @@
"steps": {
"welcome": {
"title": "Willkommen an Bord!",
"body": "Wir haben ein Demo-Logbuch mit drei Reisetagen in der Kieler Förde für Sie angelegt. Diese kurze Tour zeigt Ihnen die wichtigsten Funktionen."
"body": "Wir haben ein Demo-Logbuch mit drei Reisetagen in der Kieler Förde für dich angelegt. Die Beispieleinträge kannst du jederzeit löschen, wenn du mit dem eigenen Logbuch starten möchtest. Diese kurze Tour zeigt dir die wichtigsten Funktionen."
},
"welcome_public": {
"title": "Willkommen an Bord!",
"body": "Erkunden Sie unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde ganz ohne Account. Diese kurze Tour zeigt Ihnen Schiffsdaten, Crew und Logbucheinträge."
"body": "Erkunde unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde ganz ohne Account. Die Tour zeigt dir Logbucheinträge, die Schiff- und Crew-Auswahl für dieses Logbuch. Flotte und Stammcrew pflegst du später im Benutzerprofil."
},
"nav_logs": {
"title": "Logbucheinträge",
"body": "Hier verwalten Sie Ihre Reisetage Abfahrt, Ziel, Wetter, Tankstände und GPS-Tracks."
"body": "Hier verwaltest du deine Reisetage Abfahrt, Ziel, Wetter, Tankstände und GPS-Tracks."
},
"entry_list": {
"title": "Ihre Reisetage",
"body": "Jede Karte steht für einen Reisetag. Tippen Sie auf einen Eintrag, um Details zu sehen oder zu bearbeiten."
"title": "Deine Reisetage",
"body": "Jede Karte steht für einen Reisetag. Tippe auf einen Eintrag, um Details zu sehen oder zu bearbeiten."
},
"entry_open": {
"title": "Reisetag öffnen",
@@ -451,21 +940,51 @@
},
"entry_track": {
"title": "GPS-Track",
"body": "Laden Sie GPX-Dateien hoch oder sehen Sie bereits gespeicherte Routen auf der Karte inklusive Distanz und Geschwindigkeit."
"body": "Lade GPX-Dateien hoch oder sieh bereits gespeicherte Routen auf der Karte inklusive Distanz und Geschwindigkeit."
},
"nav_vessel": {
"title": "Schiffsdaten",
"body": "Hinterlegen Sie Name, Maße und technische Daten Ihrer Yacht einmal ausfüllen, für alle Reisetage verfügbar."
"title": "Schiff fürs Logbuch",
"body": "Wähle aus deiner Schiffsflotte das Schiff für dieses Logbuch. Schiffe pflegst du im Benutzerprofil unter Flotte & Crew."
},
"nav_crew": {
"title": "Crew-Liste",
"body": "Verwalten Sie Besatzungsmitglieder und weisen Sie sie später Reisetagen zu."
"profile_vessel_pool": {
"title": "Schiffsflotte",
"body": "Im Benutzerprofil legst du alle deine Schiffe an Charteryachten, eigenes Boot usw. Pro Logbuch wählst du dann das passende Schiff."
},
"profile_crew_pool": {
"title": "Stammcrew & Skipper",
"body": "Im Benutzerprofil pflegst du deinen Personen-Pool mehrere Skipper (z. B. Charter) und Crew-Mitglieder für alle Logbücher."
},
"nav_logbook_crew": {
"title": "Crew pro Logbuch",
"body": "Wähle aus dem Pool, wer auf diesem Logbuch als Skipper und Crew gilt. Reisetage übernehmen diese Auswahl standardmäßig."
},
"nav_stats": {
"title": "Statistik-Dashboard",
"body": "Hier siehst du Reisedistanzen, Verbrauch, Routenkarten und Antriebsanteile automatisch aus deinen Logbucheinträgen berechnet."
},
"nav_feedback": {
"title": "Feedback senden",
"body": "Über dieses Formular kannst du Fehler, Ideen oder allgemeines Feedback direkt an das Projektteam schicken auch nach der Tour jederzeit über das Symbol oben rechts."
},
"nav_profile": {
"title": "Dein Benutzerprofil",
"body": "Über den Skipper-Button oben erreichst du dein persönliches Profil unabhängig vom aktuellen Logbuch."
},
"profile_preferences": {
"title": "Konto & Darstellung",
"body": "Hier verwaltest du deine Konto-Identität, Theme und Hell/Dunkel-Modus. Die App-Tour kannst du jederzeit erneut starten. Passkeys und Sicherheitseinstellungen findest du weiter unten im Profil."
},
"finish": {
"title": "Alles klar!",
"body": "Sie können die Tour jederzeit unter Einstellungen erneut starten. Gute Fahrt!"
"body": "Du landest gleich im Statistik-Dashboard. Die Tour kannst du jederzeit im Benutzerprofil erneut starten. Gute Fahrt!"
}
}
},
"seo": {
"title": "Kapteins Daagbok Kostenloses digitales Yacht-Logbuch (werbefrei)",
"description": "Kostenloses, werbefreies digitales Yacht-Logbuch mit End-to-End-Verschlüsselung und Passkey-Anmeldung. Reisetage, GPS-Tracks, Crew und Schiffsdaten sicher dokumentieren auch offline als PWA.",
"keywords": "Yacht-Logbuch, Schiffstagebuch, Bordlogbuch, Segeln, Passkey, E2E-Verschlüsselung, GPS-Track, maritimes Logbuch, kostenlos, werbefrei, gratis, ohne Werbung",
"ogImageAlt": "Kapteins Daagbok Logo"
}
}
}
+561 -42
View File
@@ -2,12 +2,40 @@
"translation": {
"app": {
"name": "Kapteins Daagbok",
"tagline": "Private Yacht Logbook"
"tagline": "Private Yacht Logbook",
"beta": "Beta",
"beta_hint": "Beta release — features may still change"
},
"languages": {
"de": "Deutsch",
"en": "English",
"da": "Dansk",
"sv": "Svenska",
"nb": "Norsk"
},
"dialog": {
"ok": "OK",
"yes": "Yes",
"no": "No"
},
"errors": {
"load_failed": "Could not load data.",
"save_failed": "Could not save changes.",
"delete_failed": "Could not delete.",
"export_failed": "Export failed."
},
"common": {
"unsaved_changes_title": "Unsaved changes",
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.",
"unsaved_changes_stay": "Stay",
"unsaved_changes_save_leave": "Save & leave",
"unsaved_changes_discard": "Discard",
"unsaved_changes_leave": "Leave"
},
"nav": {
"dashboard": "Dashboard",
"vessel": "Vessel Profile",
"crew": "Crew List",
"crew": "Crew",
"deviation": "Deviation Table",
"logs": "Logbook Entries",
"stats": "Statistics",
@@ -53,7 +81,12 @@
"enter_pin_placeholder": "Enter your PIN...",
"decrypt_with_pin": "Decrypt",
"use_recovery_instead": "Use recovery phrase instead",
"error_incorrect_pin": "Incorrect PIN. Decryption failed."
"error_incorrect_pin": "Incorrect PIN. Decryption failed.",
"error_invalid_host": "Passkeys do not work on 127.0.0.1. Please open the app via localhost.",
"use_localhost_link": "Switch to localhost",
"error_passkey_cancelled": "Passkey sign-in was cancelled or timed out. Please try again.",
"error_invalid_rp_id": "Passkey domain mismatch (RP ID). For local dev use http://localhost:5173 with RP_ID=localhost in .env.",
"error_session_incomplete": "Sign-in incomplete. Please sign in with your passkey again."
},
"pwa": {
"title": "Install app",
@@ -72,12 +105,18 @@
"update_title": "Update available",
"update_desc": "A new version of Kapteins Daagbok is ready. Reload to get the latest changes.",
"update_now": "Reload now",
"update_reloading": "Reloading…"
"update_reloading": "Reloading…",
"storage_persist_hint": "Your browser may delete offline data. Allow persistent storage to keep your logbook safe (browser settings or when prompted)."
},
"sync": {
"status_synced": "Synced",
"status_syncing": "Syncing…",
"status_offline": "Offline Cache",
"status_unsynced": "Unsynced changes"
"status_unsynced": "Unsynced changes",
"conflict_title": "Sync conflict",
"conflict_message": "{{count}} change(s) could not be synced (entry {{id}}…). Choose which version to keep.",
"conflict_use_server": "Use server version",
"conflict_keep_local": "Keep my version"
},
"vessel": {
"title": "Vessel Master Data",
@@ -108,20 +147,38 @@
"no_sails": "No sails defined.",
"photo_add": "Add Photo",
"photo_change": "Change Photo",
"photo_delete": "Delete Photo"
"photo_delete": "Delete Photo",
"tanks_section": "Tanks (capacity)",
"tanks_help": "Optional, in liters — enables sliders in the journal when tank sizes are known.",
"freshwater_capacity_l": "Freshwater (liters)",
"fuel_capacity_l": "Fuel (liters)",
"greywater_capacity_l": "Greywater (liters)",
"invalid_tank_liters": "Invalid number — please enter capacity in liters (e.g. 200)."
},
"logs": {
"title": "Logbook Journal",
"new_entry": "New Travel Day",
"travel_details": "Travel Details",
"add_event": "Add Event Log Record",
"add_event_btn": "Add Event Entry",
"edit_event": "Edit event",
"save_event_btn": "Save changes",
"cancel_event_edit": "Cancel",
"delete_event": "Delete event",
"sign_cleared_skipper_re_sign_title": "Skipper signature removed",
"sign_cleared_skipper_re_sign": "The event log was changed. The skipper signature was removed. Please sign again.",
"date": "Date",
"day_of_travel": "Day of Travel",
"day_of_travel": "Travel day",
"travel_day_number": "Travel day {{number}}",
"departure": "Departure Port (von)",
"destination": "Destination Port (nach)",
"route": "Route / Journey",
"freshwater": "Freshwater (Liters)",
"fuel": "Fuel (Liters)",
"greywater": "Greywater (Liters)",
"greywater_level": "Fill level",
"tank_slider_of_max": "{{current}} / {{max}} L",
"tank_capacity_tooltip": "If tank capacities (liters) are set in vessel master data, you can enter fill levels here using sliders.",
"morning": "Morning Level",
"refilled": "Refilled",
"evening": "Evening Level",
@@ -136,6 +193,7 @@
"sign_passkey_signing": "Requesting Passkey…",
"sign_passkey_signed": "Signed by {{username}}",
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
"sign_attribution_export": "{{username}} ({{date}})",
"sign_passkey_clear": "Remove Passkey signature",
"sign_mode_passkey": "Passkey",
"sign_mode_classic": "Classic",
@@ -163,10 +221,102 @@
"saving": "Saving...",
"saved": "Logbook page saved successfully!",
"loading": "Loading journal...",
"view_mode_label": "View",
"view_list": "List",
"live_mode": "Live",
"live_title": "Live Journal",
"live_loading": "Loading live journal...",
"live_retry": "Try again",
"live_load_error": "Could not load live journal.",
"live_action_error": "Could not save entry.",
"live_open_editor": "Full editor",
"live_actions_label": "Quick actions",
"live_stream_label": "Event log",
"live_stream_title": "Journal",
"live_no_events": "No entries yet — tap an action.",
"live_motor_start": "Engine Start",
"live_motor_stop": "Engine Stop",
"live_cast_off": "Cast off",
"live_moor": "Moor",
"live_sails_btn": "Sails",
"live_sails_pick": "Select sails",
"live_sails_pick_hint": "Tap multiple sails (tap again to deselect), then log.",
"live_sails_selected": "Selected: {{sails}}",
"live_sails_confirm": "Log entry",
"live_sails_confirm_count": "Log entry ({{count}})",
"live_sails": "Sails: {{sails}}",
"live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS unavailable. Enter latitude and longitude manually, or try again with the GPS button.",
"live_fix_gps_loading": "Getting GPS position…",
"live_fix_invalid": "Please enter valid coordinates (latitude 90…90, longitude 180…180).",
"live_fix_lat_placeholder": "Latitude (Lat)",
"live_fix_lng_placeholder": "Longitude (Lng)",
"live_photo_btn": "Photo (camera)",
"live_photo_capture_btn": "Capture",
"live_photo_save_btn": "Save",
"live_photo_retake_btn": "Retake",
"live_photo_capture_failed": "Capture failed. Please try again.",
"live_photo_open_camera_btn": "Open camera",
"live_photo_native_hint": "Take a photo with your device camera, then save it here.",
"live_photo_camera_starting": "Starting camera…",
"live_photo_camera_denied": "Camera access denied or unavailable.",
"live_photo_camera_unavailable": "Camera is not supported in this browser.",
"live_photo_error": "Could not save photo.",
"live_photo_entry": "Photo: {{caption}}",
"live_photo_entry_plain": "Photo captured",
"live_undo_photo_hint": "Photo saved",
"live_comment_btn": "Comment",
"live_comment_placeholder": "Enter text…",
"live_comment_confirm": "Log entry",
"live_gps_error": "Could not determine GPS position.",
"live_gps_start_hint": "Always start your day's voyage with a position fix.",
"live_event_generic": "Event",
"live_weather_btn": "Weather",
"live_weather_owm_btn": "Fetch OpenWeatherMap weather",
"live_weather_owm_loading": "Loading weather…",
"live_weather_fix_required": "Log a GPS fix first (Fix button) to fetch OpenWeatherMap weather. The position must be at most 6 hours old.",
"live_weather_fix_stale": "The last GPS fix is older than 6 hours. Log a new fix before fetching weather.",
"live_wind_btn": "Wind",
"live_temp_btn": "Temp °C",
"live_pressure_btn": "Pressure",
"live_precip_btn": "Precipitation",
"live_sea_state_btn": "Sea state",
"live_visibility_btn": "Visibility",
"live_course_btn": "Course",
"live_fuel_btn": "Fuel",
"live_water_btn": "Water",
"live_wind_entry": "Wind {{value}}",
"live_temp_entry": "Temperature {{temp}} °C",
"live_pressure_entry": "Pressure {{value}} hPa",
"live_precip_entry": "Precipitation {{value}}",
"live_sea_state_entry": "Sea state {{value}}",
"live_visibility_entry": "Visibility {{value}}",
"live_course_entry": "Course {{course}}",
"live_fuel_entry": "Fuel +{{liters}} L",
"live_water_entry": "Water +{{liters}} L",
"live_auto_position": "Auto position",
"live_undo_hint": "Entry saved",
"live_undo_btn": "Undo",
"live_pressure_placeholder": "e.g. 1013",
"live_temp_placeholder": "e.g. 18",
"live_precip_placeholder": "e.g. light rain",
"live_sea_state_placeholder": "e.g. 3",
"live_visibility_placeholder": "e.g. 10 km",
"live_course_placeholder": "e.g. 245",
"live_fuel_placeholder": "Liters refilled",
"live_water_placeholder": "Liters refilled",
"live_sog_btn": "SOG",
"live_stw_btn": "STW",
"live_sog_entry": "SOG {{speed}} kn",
"live_stw_entry": "STW {{speed}} kn",
"live_sog_placeholder": "e.g. 5.2",
"live_stw_placeholder": "e.g. 4.8",
"live_sog_hint": "Speed over ground (kn) — prefilled from GPS when available.",
"delete_entry": "Delete Day",
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
"carry_over_tanks_title": "Carry over from previous day?",
"carry_over_tanks_confirm": "Use the previous travel day's destination as departure port and closing tank levels as morning levels?\n\nDeparture port: {{departure}}\nFreshwater: {{fw}} L\nFuel: {{fuel}} L",
"carry_over_tanks_confirm": "Use the previous travel day's destination as departure port and closing tank levels as morning levels?\n\nDeparture port: {{departure}}\nFreshwater: {{fw}} L\nFuel: {{fuel}} L\nGreywater: {{greywater}} L",
"carry_over_tanks_yes": "Carry over",
"carry_over_tanks_no": "Start at 0",
"event_title": "Chronological Event Logbook",
@@ -174,9 +324,32 @@
"event_time": "Time",
"event_mgk": "MgK Course",
"event_rwk": "RwK Course",
"event_course_section": "Course",
"course_dial_hint": "Drag the ring or enter degrees",
"course_dial_step_label": "Step size",
"course_step_fine": "1°",
"course_step_medium": "5°",
"course_step_coarse": "10°",
"course_tab_mgk": "MgK",
"course_tab_rwk": "rwK",
"course_invalid": "Invalid course (0360)",
"course_placeholder_degrees": "e.g. 180",
"course_placeholder_cardinal": "e.g. NW",
"compass_n": "N",
"compass_e": "E",
"compass_s": "S",
"compass_w": "W",
"wind_mode_cardinal": "Cardinal",
"wind_mode_degrees": "As degrees",
"event_wind_direction": "Wind Dir",
"event_wind_strength": "Wind Str",
"event_sea_state": "Sea State",
"event_visibility": "Visibility",
"event_visibility_placeholder": "e.g. 10 km",
"weather_slider_unset": "—",
"weather_slider_pressure": "{{value}} hPa",
"weather_slider_sea_state": "State {{value}}",
"weather_slider_heel": "{{value}}°",
"event_weather": "Weather",
"event_log": "Log (nm)",
"event_gps": "GPS Position",
@@ -189,6 +362,10 @@
"event_heel": "Heel Angle (°)",
"event_sails": "Sails / Motor Status",
"motor_propulsion": "Engine Propulsion",
"sails_picker_show_more": "Show all sails",
"sails_picker_show_less": "Show less",
"motor_hours": "Engine hours (total)",
"fuel_per_motor_hour": "Consumption per engine hour",
"event_distance": "Distance (nm)",
"export_csv": "Download CSV",
"share_csv": "Share CSV",
@@ -218,6 +395,56 @@
"track_map_end": "End",
"track_map_speed_slow": "slow",
"track_map_speed_fast": "fast",
"nmea_import_title": "Import NMEA log",
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
"nmea_import_btn": "Import NMEA",
"nmea_file_label": "NMEA file",
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries.",
"nmea_mode_label": "Generate journal entries",
"nmea_mode_interval": "By time interval",
"nmea_mode_change": "On significant change",
"nmea_mode_both": "Both (merge)",
"nmea_interval_label": "Interval (minutes)",
"nmea_import_track": "Import GPS track from NMEA",
"nmea_preview": "Preview",
"nmea_preview_hint": "{{count}} suggested journal entries",
"nmea_select_all": "Select all",
"nmea_select_none": "Select none",
"nmea_source_interval": "Interval",
"nmea_source_change": "Event",
"nmea_apply": "Apply to journal",
"nmea_back": "Back",
"nmea_cancel": "Cancel",
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
"nmea_archive_keep": "Archive",
"nmea_archive_discard": "Discard",
"nmea_archive_stored": "NMEA archived: {{name}}",
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
"nmea_error_parse": "Could not read NMEA file.",
"nmea_error_read": "Could not read file.",
"nmea_error_no_file": "Please choose an NMEA file first.",
"nmea_error_no_selection": "Please select at least one journal entry.",
"nmea_remark_interval": "NMEA interval",
"nmea_remark_uncertain": "uncertain",
"nmea_remark_depth": "Depth {{depth}} m",
"nmea_change_course": "Course change {{from}}° → {{to}}°",
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
"nmea_change_depth": "Depth {{from}} → {{to}} m",
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
"nmea_change_engine_stop": "Engine off",
"nmea_change_autopilot_on": "Autopilot on",
"nmea_change_autopilot_off": "Autopilot off",
"nmea_change_gps_lost": "GPS fix lost",
"nmea_change_gps_regained": "GPS fix restored",
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop",
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
"track_map_error": "Could not load map.",
"exporting": "Exporting...",
"share_unsupported": "Web sharing is not supported on this device. File downloaded instead.",
@@ -236,6 +463,7 @@
"create_btn": "Create Logbook",
"new_logbook_placeholder": "Logbook or Yacht Name",
"logout": "Logout",
"logged_in_as": "Signed in as {{name}}",
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok.json) if you may need the data later.",
"no_logbooks": "No logbooks found. Create your first logbook to begin!",
"loading": "Loading logbooks...",
@@ -250,16 +478,221 @@
"role_crew": "Crew access",
"role_crew_hint": "Invited logbook — you can collaborate and sign as crew",
"role_read": "Read only",
"role_read_hint": "Shared logbook — view only, no editing"
"role_read_hint": "Shared logbook — view only, no editing",
"open_profile": "Open profile for {{name}}",
"open_logbook": "Open logbook “{{title}}”",
"edit_title": "Rename Logbook",
"edit_placeholder": "New name of the logbook",
"edit_success": "Logbook renamed successfully",
"edit_btn": "Rename",
"filter_label": "Filter logbooks",
"filter_placeholder": "Name, year, date, crew or vessel …",
"filter_clear": "Clear filter",
"filter_results": "{{count}} matches",
"filter_no_results": "No logbooks match your search. Try a different name or year.",
"sort_label": "Sort",
"sort_by_label": "Sort by",
"sort_by_name": "Name",
"sort_by_date": "Date",
"sort_dir_label": "Order",
"sort_asc": "Ascending",
"sort_desc": "Descending",
"sort_name_asc": "Name A to Z",
"sort_name_desc": "Name Z to A",
"sort_date_asc": "Oldest first",
"sort_date_desc": "Newest first"
},
"profile": {
"title": "User profile",
"subtitle": "Account, passkeys and statistics for {{name}}",
"back": "Back to dashboard",
"loading": "Loading profile…",
"load_error": "Could not load profile.",
"copy_failed": "Copy failed.",
"processing": "Processing…",
"identity_title": "Account identity",
"username": "Username",
"user_id": "User ID",
"copy_user_id": "Copy user ID",
"account_since": "Account since",
"prf_status": "Passkey key derivation (PRF)",
"prf_active": "Active",
"prf_inactive": "Not configured",
"passkeys_title": "Passkeys",
"passkeys_desc": "Register a passkey on each device you use. This helps when switching platforms or browsers.",
"passkeys_empty": "No passkeys found.",
"add_passkey_btn": "Add new passkey",
"add_passkey_success": "Passkey added successfully.",
"add_passkey_failed": "Could not add passkey.",
"remove_passkey_btn": "Remove passkey",
"remove_passkey_last_title": "Last passkey",
"remove_passkey_last_desc": "The only passkey cannot be removed without losing access to your account. To delete the account entirely, use the danger zone at the bottom of this page.",
"remove_passkey_failed": "Could not remove passkey.",
"remove_passkey_confirm_title": "Remove passkey?",
"remove_passkey_confirm_desc": "This device will no longer be able to sign in with this passkey.",
"remove_passkey_confirm_yes": "Remove",
"remove_passkey_confirm_no": "Cancel",
"pin_title": "Local PIN",
"pin_status": "Status",
"pin_active": "Active on this device",
"pin_inactive": "Not configured",
"pin_confirm_label": "Confirm PIN",
"pin_confirm_placeholder": "Re-enter PIN",
"pin_set_btn": "Set PIN",
"pin_change_btn": "Change PIN",
"pin_remove_btn": "Remove PIN",
"pin_saved": "PIN saved.",
"pin_save_failed": "Could not save PIN.",
"pin_mismatch": "PIN entries do not match.",
"pin_length_error": "PIN must be at least 4 characters.",
"pin_no_session": "Session expired — please sign in again.",
"remove_pin_confirm_title": "Remove PIN?",
"remove_pin_confirm_desc": "You will need to sign in on this device with passkey or recovery phrase again.",
"remove_pin_confirm_yes": "Remove PIN",
"remove_pin_confirm_no": "Cancel",
"security_title": "Security checklist",
"security_desc": "Overview of the most important protections for your account.",
"security_passkeys_ok": "At least one passkey registered",
"security_passkeys_missing": "No passkey registered",
"security_prf_ok": "PRF key derivation active",
"security_prf_missing": "PRF not configured",
"security_pin_ok": "Local PIN on this device",
"security_pin_missing": "No local PIN",
"security_recovery_ok": "Recovery phrase configured",
"security_recovery_hint": "The 12 words were shown at registration. Store them offline and separately from this device. You can create a new phrase below — the old one will then be invalidated.",
"recovery_rotate_btn": "Create new recovery phrase",
"recovery_rotate_confirm_title": "Create new recovery phrase?",
"recovery_rotate_confirm_desc": "Your previous 12-word phrase will be invalidated immediately. Make sure you can store the new phrase securely before continuing.",
"recovery_rotate_confirm_yes": "Create new phrase",
"recovery_rotate_confirm_no": "Cancel",
"recovery_rotate_new_warning": "IMPORTANT: Write down these 12 words and store them offline. Your previous recovery phrase is no longer valid.",
"recovery_rotate_failed": "Could not create a new recovery phrase.",
"recovery_rotate_no_session": "Encryption session expired — please sign out and sign in again, then retry.",
"device_title": "This device",
"device_desc": "Local cache, sync status, and quick login on this browser.",
"device_sync_pending": "{{count}} pending sync items",
"device_sync_ok": "All local changes synced",
"device_remembered": "Account saved for quick login on this device",
"device_not_remembered": "Account not in the quick-login list",
"device_forget_btn": "Forget account on this device",
"device_forget_confirm_title": "Remove quick login?",
"device_forget_confirm_desc": "The account will be removed from the quick-login list on this device. Your session and local logbooks stay on this device.",
"device_forget_confirm_yes": "Remove",
"device_forget_confirm_no": "Cancel",
"passkey_label": "Name for new passkey (optional)",
"passkey_label_placeholder": "e.g. MacBook, iPhone",
"passkey_rename_btn": "Save name",
"passkey_rename_success": "Passkey name saved.",
"passkey_rename_failed": "Could not save passkey name.",
"passkey_unnamed": "Unnamed passkey",
"stats_title": "Statistics",
"stats_subtitle": "Across all your logbooks on this device",
"stats_logbooks": "Logbooks",
"stats_account_since": "Account since",
"stats_shared_logbooks": "Shared logbooks",
"appearance_title": "App & appearance",
"appearance_desc": "Theme and color scheme apply to the entire app on this device.",
"theme_label": "Application style / theme",
"theme_auto": "Auto (OS detect)",
"theme_ocean": "Ocean (glassmorphism)",
"theme_material": "Material (Android)",
"theme_cupertino": "Cupertino (iOS)",
"color_scheme_label": "Light or dark mode",
"color_scheme_auto": "Auto (system)",
"color_scheme_light": "Light",
"color_scheme_dark": "Dark",
"integrations_title": "Integrations",
"owm_key": "OpenWeatherMap API key",
"owm_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.",
"prefs_save": "Save",
"prefs_saving": "Saving…",
"prefs_saved": "Saved",
"tour_title": "App tour",
"tour_desc": "Take a guided walkthrough of the main areas of the app again.",
"tour_restart": "Restart tour",
"push_title": "Push notifications",
"push_desc": "As logbook owner you are notified when invited crew members sync changes. No logbook content is sent in plain text.",
"push_enable": "Notify on crew changes",
"push_active": "Push notifications are active on this device.",
"push_unsupported": "Push notifications are not supported in this browser.",
"push_denied_hint": "Notifications are blocked. Allow them in your browser or device settings.",
"push_ios_install_hint": "On iPhone/iPad: add the app to your Home Screen (iOS 16.4+) to use push notifications.",
"push_error": "Could not enable push notifications.",
"sections": {
"account": "Account & settings",
"fleet": "Fleet & crew",
"security": "Security & device",
"stats": "Statistics",
"danger": "Danger zone"
}
},
"vessel_pool": {
"title": "Vessel fleet",
"section_title": "Your vessels",
"subtitle": "Maintain all vessels for your logbooks here. Select the active vessel per logbook from this list.",
"loading": "Loading vessel fleet…",
"add_vessel": "Add vessel",
"edit_vessel": "Edit vessel",
"no_vessels": "No vessels in the pool yet.",
"delete_confirm": "Remove this vessel from the fleet?",
"max_vessels": "Maximum of 20 vessels in the pool reached."
},
"logbook_vessel": {
"title": "Vessel for this logbook",
"subtitle": "Choose the vessel for this logbook. Travel days use sails and tank data from the selected vessel.",
"active_vessel": "Vessel for this logbook",
"no_vessels_in_pool": "No vessel in the fleet — add one in your user profile first.",
"no_vessel": "No vessel selected",
"unnamed": "Unnamed",
"save": "Save vessel",
"saved": "Logbook vessel saved.",
"selection_only_hint": "You see the vessel chosen by the owner (shared logbook).",
"manage_in_profile": "Manage vessels in user profile"
},
"person_pool": {
"title": "Core Crew & skippers",
"subtitle": "Maintain your person pool here — skippers and crew for all logbooks. Select active crew per logbook and travel day from this pool.",
"loading": "Loading person pool…",
"skippers_section": "Skippers",
"crew_section": "Core Crew",
"add_skipper": "Add skipper",
"add_crew": "Add crew member",
"edit_skipper": "Edit skipper",
"no_skippers": "No skippers in the pool yet.",
"no_crew": "No crew members in the pool yet.",
"delete_confirm": "Remove this person from the pool?"
},
"logbook_crew": {
"title": "Crew for this logbook",
"subtitle": "Choose skipper and crew for this logbook. New travel days inherit this selection by default.",
"loading": "Loading crew…",
"active_skipper": "Skipper for this logbook",
"active_crew": "Crew for this logbook",
"no_skippers_in_pool": "No skipper in the pool — add one in your user profile first.",
"no_crew_in_pool": "No crew in the pool — add members in your user profile first.",
"no_skipper": "No skipper selected",
"unnamed": "Unnamed",
"save": "Save crew",
"saved": "Logbook crew saved.",
"selection_only_hint": "You see the crew set by the owner (shared logbook)."
},
"entry_crew": {
"title": "Crew on this travel day",
"subtitle": "May differ from the logbook default. Following days inherit from the previous day.",
"day_skipper": "Skipper on this day",
"day_crew": "Crew on this day",
"no_skipper": "No skipper selected",
"no_crew": "No crew selected"
},
"crew": {
"title": "Skipper & Crew Profiles",
"skipper_section": "Skipper Profile",
"skipper_read_only_hint": "The skipper profile can only be edited by the logbook owner.",
"crew_section": "Crew List",
"add_crew": "Add Crew Member",
"edit_crew": "Edit Crew Member",
"no_crew": "No crew members added yet.",
"max_crew": "Maximum of 5 crew members reached.",
"max_crew": "Maximum of 12 crew members in the pool reached.",
"name": "Full Name",
"address": "Address",
"birthdate": "Date of Birth",
@@ -286,35 +719,22 @@
"loading": "Loading calibration table..."
},
"settings": {
"title": "System Settings",
"subtitle": "Configure external integrations and client credentials.",
"owm_title": "Weather Integration",
"owm_key": "OpenWeatherMap API Key",
"save": "Save Configuration",
"saving": "Saving...",
"saved": "Settings saved successfully!",
"key_help": "An API key is required to automatically fetch real-time weather and sea state parameters based on your vessel's GPS coordinates.",
"no_key": "Please set your OpenWeatherMap API Key in settings to enable weather auto-fill.",
"title": "Logbook settings",
"subtitle": "Sharing, backup, and collaboration for this logbook.",
"select_logbook_hint": "Select a logbook to edit its settings.",
"no_key": "No OpenWeatherMap API key available. Add your own key in your user profile or contact the operator.",
"weather_success": "Weather details fetched successfully!",
"weather_error": "Failed to fetch weather. Check your API key and connection.",
"weather_date_mismatch": "Weather data can only be fetched for today ({{today}}). This logbook entry is dated {{date}}.",
"gps_error": "Please enter a location or 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)",
"color_scheme_title": "Appearance",
"color_scheme_label": "Light or dark mode (default: follow system)",
"color_scheme_auto": "Auto (System)",
"color_scheme_light": "Light",
"color_scheme_dark": "Dark",
"share_title": "Share Logbook (Read-Only)",
"share_desc": "Enable this to generate a public, read-only link. Anyone with the link can view your travels, yacht profile, and crew members. Decryption keys are never transmitted to the server (they stay in the hash part of the URL).",
"share_privacy_warning": "Recommendation: Share this link only privately (e.g. via email or messenger), not on social media.",
"share_enable": "Enable Public Link",
"share_copied": "Link copied!",
"share_copy_btn": "Copy Link",
"link_qr_hint": "Scan this QR code with your phone",
"link_qr_alt": "QR code for the link",
"danger_zone_title": "Danger Zone",
"danger_zone_desc": "Deleting your account will permanently delete all your passkeys, logbooks, vessel data, crew profiles, travel logs, and E2E keys. This action cannot be undone.",
"delete_account_btn": "Permanently Delete Account",
@@ -323,10 +743,14 @@
"delete_account_confirm_yes": "Yes, Delete Account and All Data",
"delete_account_confirm_no": "Cancel",
"delete_account_failed": "Failed to delete account. Please try again.",
"delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok.json) in each logbook's settings.",
"deleting_account": "Deleting account…",
"tour_title": "App tour",
"tour_desc": "Take a guided walkthrough of the main areas of the app again.",
"tour_restart": "Restart tour",
"invite_push_prompt_title": "Enable push notifications?",
"invite_push_prompt_message": "When invited crew members sync changes, you can be notified via push. No logbook content is sent in plain text.",
"invite_push_prompt_ios_message": "When crew members sync changes, you can get push notifications. On iPhone/iPad: add the app to your Home Screen (iOS 16.4+), then enable push in your user profile.",
"invite_push_prompt_enable": "Enable now",
"invite_push_prompt_later": "Later",
"invite_push_prompt_success": "Push notifications are active on this device.",
"backup_title": "Backup & restore",
"backup_desc": "Full encrypted backup of this logbook (entries, photos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.",
"backup_export_title": "Create backup",
@@ -382,6 +806,29 @@
"close": "Close",
"button_title": "Legal notice & disclaimer"
},
"feedback": {
"button_title": "Send feedback",
"title": "Feedback",
"intro": "Share bugs, ideas or general feedback. Your message is sent to the project team via a secure notification channel.",
"category_label": "Category",
"category_general": "General",
"category_bug": "Bug report",
"category_feature": "Feature request",
"category_translation": "Translation error",
"contact_label": "Email (optional)",
"contact_placeholder": "your@email.example",
"message_label": "Message",
"message_placeholder": "Describe your feedback…",
"send": "Send",
"sending": "Sending…",
"cancel": "Cancel",
"success": "Thank you! Your feedback has been sent.",
"error_send": "Could not send feedback. Please try again later.",
"error_invalid_email": "Please enter a valid email address.",
"error_not_configured": "Feedback is not available on this server.",
"error_rate_limited": "Too many feedback messages in a short time. Please wait a few minutes.",
"error_spam": "This message could not be sent. Please rephrase it and try again."
},
"demo": {
"logbook_title": "Baltic Sea Demo Logbook",
"badge": "Demo",
@@ -389,6 +836,36 @@
"cta_register": "Create account",
"back_to_login": "Back to login"
},
"invitation": {
"error_invalid_key": "The invitation link is cryptographically invalid (corrupted key).",
"error_missing_key": "The invitation link is missing the decryption key (#key=...). Please use the complete link from the owner.",
"error_expired": "This invitation link has expired (valid for 48 hours only).",
"error_invalid_token": "Failed to verify invitation token.",
"error_load_failed": "Invitation details could not be retrieved.",
"error_incomplete_session": "Incomplete session — please log in again (user ID missing).",
"error_accept_failed": "Acceptance failed.",
"error_login_failed": "Passkey authentication failed.",
"error_username_missing": "Could not determine username — please try logging in again.",
"error_register_failed": "Registration failed.",
"loading_joining": "Joining...",
"loading_checking": "Checking Invitation...",
"loading_unlocking": "Unlocking logbook and syncing data...",
"loading_retrieving_key": "Retrieving encryption key...",
"error_title": "Invitation Error",
"back_to_start": "Back to Dashboard",
"title": "Logbook Invitation",
"invited_by": "INVITED BY",
"vessel_logbook": "VESSEL / LOGBOOK",
"signed_in_preparing": "Signed in as {{username}}. Preparing to join...",
"join_again": "Join again",
"login_or_register_hint": "Sign in or register an account to join this logbook.",
"or_sign_up": "OR SIGN UP",
"register_crew_account": "Register New Crew Account",
"username_label": "Username",
"create_passkey": "Create Passkey",
"switch_language_en": "English",
"switch_language_de": "Deutsch"
},
"stats": {
"title": "Statistics",
"subtitle": "Routes, consumption and propulsion at a glance",
@@ -401,6 +878,9 @@
"travel_days": "Travel days",
"sail_distance": "Under sail",
"motor_distance": "Engine",
"motor_hours_total": "Total engine hours",
"daily_motor_hours": "Engine hours per travel day",
"avg_motor_hours": "Avg. engine hours per travel day",
"unknown_propulsion": "Unknown",
"fuel_total": "Total fuel",
"water_total": "Total water",
@@ -414,13 +894,22 @@
"avg_fuel": "Avg. fuel",
"avg_water": "Avg. water",
"fuel_per_nm": "Fuel per nm",
"fuel_per_motor_hour": "Fuel per engine hour",
"daily_fuel_per_motor_hour": "Fuel consumption per engine hour by travel day",
"fuel_legend": "Fuel",
"water_legend": "Water",
"unit_nm": "nm",
"unit_h": "h",
"unit_l": "L",
"day_label": "Day {{day}}",
"account_logbooks": "Logbooks overview",
"col_logbook": "Logbook"
"col_logbook": "Logbook",
"event_series_title": "Event series",
"event_series_hint": "Chronological values from the event log.",
"event_series_pressure": "Barometric pressure",
"event_series_wind": "Wind",
"event_series_motor": "Engine",
"event_series_empty": "No entries yet."
},
"tour": {
"skip": "Skip tour",
@@ -431,11 +920,11 @@
"steps": {
"welcome": {
"title": "Welcome aboard!",
"body": "We created a demo logbook with three travel days in Kiel Bay. This short tour shows you the key features."
"body": "We created a demo logbook with three travel days in Kiel Bay. You can delete the sample entries anytime when you're ready to start your own logbook. This short tour shows you the key features."
},
"welcome_public": {
"title": "Welcome aboard!",
"body": "Explore our demo logbook with three travel days in Kiel Bay — no account required. This short tour shows vessel data, crew, and log entries."
"body": "Explore our demo logbook with three travel days in Kiel Bay — no account required. The tour covers log entries and vessel and crew selection for this logbook. Manage your fleet and core crew later in your user profile."
},
"nav_logs": {
"title": "Log entries",
@@ -454,18 +943,48 @@
"body": "Upload GPX files or view saved routes on the map including distance and speed stats."
},
"nav_vessel": {
"title": "Vessel data",
"body": "Enter your yacht's name, dimensions, and technical details fill once, use on every travel day."
"title": "Vessel for logbook",
"body": "Choose a vessel from your fleet for this logbook. Manage vessels in your user profile under Fleet & crew."
},
"nav_crew": {
"title": "Crew list",
"body": "Manage crew members and assign them to travel days later."
"profile_vessel_pool": {
"title": "Vessel fleet",
"body": "In your user profile you add all your vessels — charter yachts, your own boat, etc. Then pick the right vessel per logbook."
},
"profile_crew_pool": {
"title": "Core Crew & skippers",
"body": "In your user profile you maintain a person pool — multiple skippers (e.g. charter) and crew for all logbooks."
},
"nav_logbook_crew": {
"title": "Crew per logbook",
"body": "Pick skipper and crew from the pool for this logbook. Travel days inherit this selection by default."
},
"nav_stats": {
"title": "Statistics dashboard",
"body": "View travel distances, consumption, route maps, and propulsion breakdown — calculated automatically from your log entries."
},
"nav_feedback": {
"title": "Send feedback",
"body": "Use this form to report bugs, ideas, or general feedback to the project team — you can also open it anytime later via the icon in the top right."
},
"nav_profile": {
"title": "Your user profile",
"body": "Tap the skipper button at the top to open your personal profile — independent of the current logbook."
},
"profile_preferences": {
"title": "Account & appearance",
"body": "Manage your account identity, theme, and light/dark mode here. You can restart the app tour anytime. Passkeys and security settings are further down on the profile page."
},
"finish": {
"title": "You're all set!",
"body": "You can restart the tour anytime in Settings. Fair winds!"
"body": "You'll land on the statistics dashboard next. You can restart the tour anytime from your user profile. Fair winds!"
}
}
},
"seo": {
"title": "Kapteins Daagbok Free Digital Yacht Logbook (Ad-Free)",
"description": "Free, ad-free digital yacht logbook with end-to-end encryption and Passkey sign-in. Document travel days, GPS tracks, crew and vessel data securely — offline-capable PWA.",
"keywords": "yacht logbook, ship logbook, sailing log, maritime logbook, passkey, E2E encryption, GPS track, free, ad-free, offline PWA",
"ogImageAlt": "Kapteins Daagbok logo"
}
}
}
+990
View File
@@ -0,0 +1,990 @@
{
"translation": {
"app": {
"name": "Kapteins Daagbok",
"tagline": "Loggbok for private båter",
"beta": "Beta",
"beta_hint": "Betaversjon - funksjoner kan fortsatt endres"
},
"languages": {
"de": "Deutsch",
"en": "English",
"da": "Dansk",
"sv": "Svenska",
"nb": "Norsk"
},
"dialog": {
"ok": "OK",
"yes": "Ja",
"no": "Nei"
},
"errors": {
"load_failed": "Data kunne ikke lastes.",
"save_failed": "Endringer kunne ikke lagres.",
"delete_failed": "Sletting mislyktes.",
"export_failed": "Eksport mislyktes."
},
"common": {
"unsaved_changes_title": "Ikke-lagrede endringer",
"unsaved_changes_message": "Du har endringer som ikke er lagret. Vil du virkelig forlate siden? Endringene dine vil gå tapt.",
"unsaved_changes_stay": "Bli",
"unsaved_changes_save_leave": "Lagre og forlat",
"unsaved_changes_discard": "Forkast",
"unsaved_changes_leave": "Oppgivelse"
},
"nav": {
"dashboard": "Dashbord",
"vessel": "Skipsdata",
"crew": "Crew",
"deviation": "Tabell over distraksjoner",
"logs": "Loggbokoppføringer",
"stats": "Statistikk",
"settings": "Innstillinger"
},
"auth": {
"welcome": "Velkommen til Kapteins Daagbok",
"tagline": "Din sikre, E2E-krypterte maritime loggbok.",
"register": "Registrer deg med Passkey",
"login": "Logg inn med Passkey",
"login_as": "Logg inn som {{name}}",
"quick_login": "Rask innlogging",
"forget_account": "Glemt konto på denne enheten",
"not_user": "Ikke {{name}}?",
"recovery_title": "Gjenopprettingsnøkkelen din",
"recovery_warning": "VIKTIG: Skriv ned disse 12 ordene. Hvis du mister Passkey og disse ordene, kan du ikke gjenopprette dataene dine.",
"confirm_recovery": "Jeg har skrevet ned ordene",
"status_logged_in": "Innlogget",
"status_logged_out": "Avlyst",
"copied": "Oppfattet!",
"copy_phrase": "Kopieringstast",
"enter_recovery": "Skriv inn gjenopprettingsnøkkel",
"recovery_fallback_warning": "Din Passkey har blitt autentisert, men enheten din støtter ikke maskinvarebasert nøkkelderivering. Skriv inn gjenopprettingsnøkkelen på 12 ord for å dekryptere loggboken.",
"recovery_placeholder": "Skriv inn gjenopprettingsnøkkelen din, som består av 12 ord atskilt med mellomrom...",
"back": "Tilbake",
"decrypting": "Dekryptering...",
"decrypt_logbook": "Dekryptere loggbok",
"error_incorrect_recovery": "Feil gjenopprettingsnøkkel. Dekryptering mislyktes.",
"error_decryption_failed": "Dekryptering mislyktes. Vennligst sjekk gjenopprettingsnøkkelen din.",
"or_register": "eller registrer deg",
"explore_demo": "Utforsk demoen uten konto",
"username_placeholder": "Brukernavn / Skippernavn",
"processing": "Behandling...",
"help": "Hjelp",
"setup_pin_title": "Konfigurer lokal PIN-kode (valgfritt)",
"setup_pin_warning": "Siden enheten din ikke støtter direkte Passkey-nøkkelavledning, må du ellers skrive inn 12-ordsnøkkelen hver gang du logger deg på denne enheten. Konfigurer en lokal PIN-kode for å unngå dette.",
"pin_placeholder": "E.G. 123456",
"pin_label": "Lokal PIN-kode (4-8 sifre)",
"save_pin": "Lagre PIN-kode og fortsett",
"skip_pin": "Hopp over og bruk gjenoppretting",
"enter_pin_title": "Dekrypter med PIN-kode",
"enter_pin_warning": "Skriv inn din lokale PIN-kode for å låse opp dekrypteringsnøkkelen på denne enheten.",
"enter_pin_placeholder": "Tast inn PIN-koden din...",
"decrypt_with_pin": "Dekryptere",
"use_recovery_instead": "Bruk gjenopprettingsnøkler i stedet",
"error_incorrect_pin": "Feil PIN-kode. Dekryptering mislyktes.",
"error_invalid_host": "Passkeys fungerer ikke via 127.0.0.1. Åpne appen via localhost.",
"use_localhost_link": "Bytt til localhost",
"error_passkey_cancelled": "Passkey-innlogging ble avbrutt eller utløp. Prøv igjen.",
"error_invalid_rp_id": "Passkey-domene stemmer ikke (RP ID). Bruk http://localhost:5173 med RP_ID=localhost i .env for lokal utvikling.",
"error_session_incomplete": "Innlogging ufullstendig. Logg inn med passkey igjen."
},
"pwa": {
"title": "Installer app",
"generic_benefit": "Installer Kapteins Daagbok på enheten din for raskere tilgang, frakoblet bruk og permanent lagring av data.",
"ios_instructions": "På iPad/iPhone: Legg til appen på startskjermen, slik at loggbokdataene dine forblir beskyttet og appen starter som en vanlig app.",
"ios_step_share": "Trykk på aksjesymbolet i Safari-linjen",
"ios_step_add": "Velg \"Gå til startskjermen\"",
"install_now": "Installer nå",
"installing": "Installasjon...",
"later": "Senere",
"never": "Ikke vis mer",
"platform_ios": "Installasjon via Safari",
"platform_android": "Installasjon via nettleseren",
"platform_desktop": "Installasjon som en desktop-app",
"settings_section": "Installasjon av app",
"update_title": "Oppdatering tilgjengelig",
"update_desc": "En ny versjon av Kapteins Daagbok er klar. Oppdater for å få med de siste endringene.",
"update_now": "Oppdater nå",
"update_reloading": "Laster...",
"storage_persist_hint": "Nettleseren kan slette offlinedata. Tillat permanent lagring slik at loggboken din forblir beskyttet."
},
"sync": {
"status_synced": "Synkronisert",
"status_syncing": "Synkroniser...",
"status_offline": "Frakoblet hurtigbuffer",
"status_unsynced": "Usynkroniserte endringer",
"conflict_title": "Synkroniseringskonflikt",
"conflict_message": "{{count}} endring(er) kunne ikke synkroniseres (post {{id}}…). Velg hvilken versjon som skal gjelde.",
"conflict_use_server": "Bruk serverversjon",
"conflict_keep_local": "Behold min versjon"
},
"vessel": {
"title": "Stamdata for skip",
"name": "Båtens navn",
"type": "Båttype",
"type_unset": "- ikke spesifisert -",
"type_sailing": "Seilbåt",
"type_motor": "Motorbåt",
"length_m": "Lengde (m)",
"draft_m": "Trekkraft (m)",
"air_draft_m": "Høyde (m)",
"invalid_metric": "Ugyldig tallverdi - angi meter som desimaltall (f.eks. 12,5).",
"port": "Hjemmehavn",
"owner": "Eier",
"charter": "Charterselskap",
"registration": "Nummerskilt/registreringsnummer",
"callsign": "Radiokallesignal",
"atis": "ATIS nr.",
"mmsi": "MMSI-nr.",
"save": "Lagre skipsdata",
"saving": "...vil bli reddet...",
"saved": "Skipsdata vellykket lagret!",
"loading": "Skipsdata er lastet inn...",
"sails_list": "Seil (eksisterende seil)",
"sails_help": "Skriv inn seilene som er tilgjengelige på båten din her (f.eks. storseil, genua, fokk).",
"add_sail": "Legg til seil",
"sail_name_placeholder": "z. f.eks. storseil",
"no_sails": "Ingen seil lagret.",
"photo_add": "Legg til bilde",
"photo_change": "Endre bilde",
"photo_delete": "Slett bilde",
"tanks_section": "Tanker (kapasitet)",
"tanks_help": "Valgfritt i liter - muliggjør glidebryter i tidsskriftet for kjente tankstørrelser.",
"freshwater_capacity_l": "Drikkevann (liter)",
"fuel_capacity_l": "Drivstoff (liter)",
"greywater_capacity_l": "Gråvann (liter)",
"invalid_tank_liters": "Ugyldig tallverdi - skriv inn liter som et tall (f.eks. 200)."
},
"logs": {
"title": "Loggbokdagbok",
"new_entry": "Ny reisedag",
"travel_details": "Detaljer om reisen",
"add_event": "Legg til ny loggbokoppføring",
"add_event_btn": "Legg til hendelse",
"edit_event": "Rediger hendelse",
"save_event_btn": "Lagre endring",
"cancel_event_edit": "Avbryt",
"delete_event": "Slett hendelse",
"sign_cleared_skipper_re_sign_title": "Skippers signatur fjernet",
"sign_cleared_skipper_re_sign": "Hendelsesloggen har blitt endret. Skipperens signatur er fjernet. Vennligst godkjenn på nytt.",
"date": "dato",
"day_of_travel": "Reisedag",
"travel_day_number": "Reisedag {{number}}",
"departure": "Starthavn (reise fra)",
"destination": "Destinasjonsport (til)",
"route": "Reise fra/til",
"freshwater": "Ferskvann (liter)",
"fuel": "Drivstoff / Drivstoff (liter)",
"greywater": "Gråvann (liter)",
"greywater_level": "Fyllingsnivå",
"tank_slider_of_max": "{{current}} / {{max}} L",
"tank_capacity_tooltip": "Hvis tankkapasiteten (liter) er lagret i skipsdataene, kan du angi fyllingsnivåene her ved hjelp av glidebryteren.",
"morning": "Stå opp om morgenen",
"refilled": "Påfyllt",
"evening": "Kveldsstand",
"consumption": "Daglig forbruk",
"signatures": "Underskrifter / frigivelse",
"sign_skipper": "Skippers signatur",
"sign_crew": "Crews signatur",
"sign_hint": "Signer med finger, penn eller mus",
"sign_clear": "Slett",
"sign_export_image": "[Signatur]",
"sign_with_passkey": "Utgivelse med Passkey",
"sign_passkey_signing": "Passkey er forespurt...",
"sign_passkey_signed": "Utgitt av {{username}}",
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
"sign_attribution_export": "{{username}} ({{date}})",
"sign_passkey_clear": "Fjern Passkey utgivelse",
"sign_mode_passkey": "Passkey",
"sign_mode_classic": "Klassisk",
"sign_passkey_failed": "Passkey Utgivelsen mislyktes",
"sign_passkey_cancelled": "Passkey Utgivelse kansellert",
"sign_invalid": "Signaturen er ugyldig - innholdet har blitt endret",
"sign_badge_skipper": "Skipper",
"sign_badge_skipper_invalid": "Ugyldig",
"sign_badge_skipper_title_valid": "Skipper har gitt ut",
"sign_badge_skipper_title_invalid": "Skippersignaturen er ugyldig - innholdet har blitt endret",
"sign_classic_or_passkey": "Valgfritt: klassisk signatur eller Passkey utgivelse ovenfor",
"sign_crew_passkey_hint": "Crew-medlemmer med skrivetilgang kan frigjøre via Passkey.",
"sign_offline_hint": "Passkey-Godkjenning krever Internett - klassisk signatur mulig offline",
"sign_lock_notice": "Etter signering er det ikke mulig å gjøre endringer i loggbokoppføringen (unntatt bilder) uten at skipper og crew må signere på nytt.",
"sign_lock_active": "Denne oppføringen er signert. Endringer i loggboken (unntatt bilder) fjerner automatisk skipperens og crews signaturer.",
"sign_lock_warning_title": "Bekreft signatur",
"sign_lock_warning": "Etter signering er det ikke lenger mulig å gjøre endringer i loggbokoppføringen (unntatt bilder) uten at skipper og crew må signere på nytt.\n\nØnsker du å fortsette?",
"sign_proceed": "Skilt",
"sign_cancel": "Avbryt",
"sign_cleared_re_sign_title": "Signaturer fjernet",
"sign_cleared_re_sign": "Loggbokoppføringen har blitt endret. Skipperens og crews signaturer er fjernet. Vennligst signer på nytt.",
"no_entries": "Ingen loggbokoppføringer funnet for denne båten. Lag din første seilasdag!",
"back_to_list": "Tilbake til tidsskriftlisten",
"save": "Lagre loggbokside",
"saving": "...vil bli reddet...",
"saved": "Loggboksiden er vellykket lagret!",
"loading": "Tidsskriftet lastes inn...",
"view_mode_label": "Visning",
"view_list": "Liste",
"live_mode": "Live",
"live_title": "Live-journal",
"live_loading": "Live-journal lastes inn...",
"live_retry": "Prøv igjen",
"live_load_error": "Live-journal kunne ikke lastes inn.",
"live_action_error": "Oppføringen kunne ikke lagres.",
"live_open_editor": "Full editor",
"live_actions_label": "Hurtighandlinger",
"live_stream_label": "Hendelseslogg",
"live_stream_title": "Journal",
"live_no_events": "Ingen oppføringer ennå — trykk på en handling.",
"live_motor_start": "Motor Start",
"live_motor_stop": "Motor Stopp",
"live_cast_off": "Avreise",
"live_moor": "Anløp",
"live_sails_btn": "Seil",
"live_sails_pick": "Velg seil",
"live_sails_pick_hint": "Trykk flere seil (trykk igjen for å fjerne), deretter loggfør.",
"live_sails_selected": "Valgt: {{sails}}",
"live_sails_confirm": "Loggfør",
"live_sails_confirm_count": "Loggfør ({{count}})",
"live_sails": "Seil: {{sails}}",
"live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS ikke tilgjengelig. Skriv inn bredde- og lengdegrad manuelt, eller prøv igjen med GPS-knappen.",
"live_fix_gps_loading": "Henter GPS-posisjon…",
"live_fix_invalid": "Skriv inn gyldige koordinater (bredde 90…90, lengde 180…180).",
"live_fix_lat_placeholder": "Bredde (Lat)",
"live_fix_lng_placeholder": "Lengde (Lng)",
"live_photo_btn": "Foto (kamera)",
"live_photo_capture_btn": "Ta bilde",
"live_photo_save_btn": "Lagre",
"live_photo_retake_btn": "Ta på nytt",
"live_photo_capture_failed": "Opptak mislyktes. Prøv igjen.",
"live_photo_open_camera_btn": "Åpne kamera",
"live_photo_native_hint": "Ta et bilde med enhetskameraet og lagre det her etterpå.",
"live_photo_camera_starting": "Starter kamera…",
"live_photo_camera_denied": "Kameratilgang nektet eller utilgjengelig.",
"live_photo_camera_unavailable": "Kamera støttes ikke i denne nettleseren.",
"live_photo_error": "Kunne ikke lagre foto.",
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto tatt",
"live_undo_photo_hint": "Foto lagret",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Skriv inn tekst…",
"live_comment_confirm": "Loggfør",
"live_gps_error": "GPS-posisjon kunne ikke bestemmes.",
"live_gps_start_hint": "Start alltid dagsreisen med en posisjon.",
"live_event_generic": "Hendelse",
"live_weather_btn": "Vær",
"live_weather_owm_btn": "Hent OpenWeatherMap-vær",
"live_weather_owm_loading": "Henter vær…",
"live_weather_fix_required": "Logg først en GPS-fix (Fix-knapp) for å hente OpenWeatherMap-vær. Posisjonen må være maks 6 timer gammel.",
"live_weather_fix_stale": "Siste GPS-fix er eldre enn 6 timer. Logg en ny fix før du henter vær.",
"live_wind_btn": "Vind",
"live_temp_btn": "T °C",
"live_pressure_btn": "Lufttrykk",
"live_precip_btn": "Nedbør",
"live_sea_state_btn": "Sjøgang",
"live_visibility_btn": "Sikt",
"live_course_btn": "Kurs",
"live_fuel_btn": "Diesel",
"live_water_btn": "Vann",
"live_wind_entry": "Vind {{value}}",
"live_temp_entry": "Temperatur {{temp}} °C",
"live_pressure_entry": "Lufttrykk {{value}} hPa",
"live_precip_entry": "Nedbør {{value}}",
"live_sea_state_entry": "Sjøgang {{value}}",
"live_visibility_entry": "Sikt {{value}}",
"live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Vann +{{liters}} L",
"live_auto_position": "Auto-posisjon",
"live_undo_hint": "Oppføring lagret",
"live_undo_btn": "Angre",
"live_pressure_placeholder": "f.eks. 1013",
"live_temp_placeholder": "f.eks. 18",
"live_precip_placeholder": "f.eks. lett regn",
"live_sea_state_placeholder": "f.eks. 3",
"live_visibility_placeholder": "f.eks. 10 km",
"live_course_placeholder": "f.eks. 245",
"live_fuel_placeholder": "Påfylte liter",
"live_water_placeholder": "Påfylte liter",
"live_sog_btn": "SOG",
"live_stw_btn": "STW",
"live_sog_entry": "SOG {{speed}} kn",
"live_stw_entry": "STW {{speed}} kn",
"live_sog_placeholder": "f.eks. 5,2",
"live_stw_placeholder": "f.eks. 4,8",
"live_sog_hint": "Fart over grunn (kn) — GPS-verdi fylles inn hvis tilgjengelig.",
"delete_entry": "Slett tagg",
"delete_confirm": "Er du sikker på at du vil slette denne reisedagen permanent?",
"carry_over_tanks_title": "Overføre data fra dagen før?",
"carry_over_tanks_confirm": "Overta starthavn, ferskvann, drivstoff og gråvann fra startnivåene fra siste dag på turen?\n\nStart havn: {{departure}}\nFerskvann: {{fw}} L\nDrivstoff: {{fuel}} L\nGråvann: {{greywater}} L",
"carry_over_tanks_yes": "Ta over",
"carry_over_tanks_no": "Begynn med 0",
"event_title": "Kronologisk hendelseslogg",
"no_events": "Ingen arrangementer lagt inn for denne reisedagen ennå.",
"event_time": "Tid på døgnet",
"event_mgk": "MgK-kurs",
"event_rwk": "RwK-kurs",
"event_course_section": "Kurs",
"course_dial_hint": "Vri ringen eller angi grader",
"course_dial_step_label": "Trinnstørrelse",
"course_step_fine": "1°",
"course_step_medium": "5°",
"course_step_coarse": "10°",
"course_tab_mgk": "MgK",
"course_tab_rwk": "rwK",
"course_invalid": "Ugyldig kurs (0-360)",
"course_placeholder_degrees": "z. B. 180",
"course_placeholder_cardinal": "z. E.G. NW",
"compass_n": "N",
"compass_e": "O",
"compass_s": "S",
"compass_w": "W",
"wind_mode_cardinal": "Kardinal",
"wind_mode_degrees": "Som grad",
"event_wind_direction": "Vindretning",
"event_wind_strength": "Vindstyrke",
"event_sea_state": "Havets tilstand",
"event_visibility": "Sikt",
"event_visibility_placeholder": "f.eks. 10 km",
"weather_slider_unset": "—",
"weather_slider_pressure": "{{value}} hPa",
"weather_slider_sea_state": "Grad {{value}}",
"weather_slider_heel": "{{value}}°",
"event_weather": "Været",
"event_log": "Logg (sm)",
"event_gps": "GPS-posisjon",
"event_location": "Sted / havn",
"event_location_placeholder": "z. f.eks. Kiel",
"event_remarks": "Merknader / hendelser",
"gps_btn": "Hent GPS-koordinater",
"weather_btn": "OpenWeatherMap Ring opp været",
"event_wind_pressure": "Lufttrykk (hPa)",
"event_heel": "Helning (°)",
"event_sails": "Seilhåndtering / motor",
"motor_propulsion": "Maskinreise",
"sails_picker_show_more": "Vis alle seil",
"sails_picker_show_less": "Vis mindre",
"motor_hours": "Maskintimer (totalt)",
"fuel_per_motor_hour": "Forbruk per maskintime",
"event_distance": "Avstand (sm)",
"export_csv": "Last ned CSV",
"share_csv": "CSV andel",
"export_pdf": "Last ned PDF",
"exporting_pdf": "PDF genereres...",
"photos_title": "Bildevedlegg (E2E-kryptert)",
"photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)",
"photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen",
"photo_btn": "Ta bilde / last opp",
"photo_processing": "...blir behandlet...",
"no_photos": "Ingen bilder knyttet til denne reisedagen ennå.",
"photo_delete_confirm": "Er du sikker på at du vil slette dette bildet permanent?",
"confirm_yes": "Ja",
"confirm_no": "Nei",
"track_upload_title": "GPS-sporing (fil)",
"track_upload_points": "Poeng",
"gps_tracking_btn_gpx": "Last ned sporfil",
"gps_track_upload_help": "Dra en GPX-, KML- eller GeoJSON-fil hit, eller klikk for å velge",
"gps_track_upload_btn": "Last opp GPS-spor",
"gps_track_delete": "Slett sporfil",
"gps_track_delete_confirm": "Er du sikker på at du vil slette denne sporfilen permanent?",
"track_distance": "GPS-rute (sm)",
"track_speed_max": "Maks. Hastighet (kn)",
"track_speed_avg": "Ø Hastighet (kn)",
"track_map_title": "GPS-spor på OpenSeaMap",
"track_map_start": "Start",
"track_map_end": "Mål",
"track_map_speed_slow": "langsomt",
"track_map_speed_fast": "raskt",
"track_map_error": "Kartet kunne ikke lastes inn.",
"exporting": "Eksport...",
"share_unsupported": "Deling støttes ikke på denne enheten. Filen har blitt lastet ned i stedet.",
"invite_crew": "Inviter crewet",
"invite_link_copied": "Invitasjonslenke kopiert til utklippstavlen!",
"invite_link_desc": "Del denne lenken med Crew-medlemmene for å gi dem skrivetilgang til loggboken.",
"collaborators_list": "Medlemmer / Crew",
"revoke": "Fjern",
"revoke_confirm": "Er du sikker på at du vil oppheve dette Crew-medlemmets tilgang?",
"invite_role": "Rolle",
"invite_expires": "Lenken er gyldig i 48 timer",
"nmea_import_title": "Import NMEA log",
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
"nmea_import_btn": "Import NMEA",
"nmea_file_label": "NMEA file",
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
"nmea_mode_label": "Generate journal entries",
"nmea_mode_interval": "By time interval",
"nmea_mode_change": "On significant change",
"nmea_mode_both": "Both (merge)",
"nmea_interval_label": "Interval (minutes)",
"nmea_import_track": "Import GPS track from NMEA",
"nmea_preview": "Preview",
"nmea_preview_hint": "{{count}} suggested journal entries",
"nmea_select_all": "Select all",
"nmea_select_none": "Select none",
"nmea_source_interval": "Interval",
"nmea_source_change": "Event",
"nmea_apply": "Apply to journal",
"nmea_back": "Back",
"nmea_cancel": "Cancel",
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
"nmea_archive_keep": "Archive",
"nmea_archive_discard": "Discard",
"nmea_archive_stored": "NMEA archived: {{name}}",
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
"nmea_error_parse": "Could not read NMEA file.",
"nmea_error_read": "Could not read file.",
"nmea_error_no_file": "Please choose an NMEA file first.",
"nmea_error_no_selection": "Please select at least one journal entry.",
"nmea_remark_interval": "NMEA interval",
"nmea_remark_uncertain": "uncertain",
"nmea_remark_depth": "Depth {{depth}} m",
"nmea_change_course": "Course change {{from}}° → {{to}}°",
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
"nmea_change_depth": "Depth {{from}} → {{to}} m",
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
"nmea_change_engine_stop": "Engine off",
"nmea_change_autopilot_on": "Autopilot on",
"nmea_change_autopilot_off": "Autopilot off",
"nmea_change_gps_lost": "GPS fix lost",
"nmea_change_gps_regained": "GPS fix restored",
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop",
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries."
},
"dashboard": {
"title": "Loggbøkene dine",
"subtitle": "Velg en loggbok eller opprett en ny for å administrere reisene dine.",
"create_btn": "Opprett loggbok",
"new_logbook_placeholder": "Navn på loggboken eller båten",
"logout": "Logg ut",
"logged_in_as": "Innlogget som {{name}}",
"delete_confirm": "Er du sikker på at du vil slette denne loggboken permanent? Alle lokale data og serverkopier vil bli ødelagt.\n\nTips: Lag en sikkerhetskopi (.daagbok.json) på forhånd under Innstillinger → Sikkerhetskopiering og gjenoppretting hvis du ønsker å beholde dataene senere.",
"no_logbooks": "Ingen loggbøker funnet. Opprett din første loggbok for å komme i gang!",
"loading": "Loggbøker er lastet...",
"status_synced": "Synkronisert",
"status_local": "Kun lokal hurtigbuffer",
"delete_btn": "Slett loggbok",
"section_owned": "Loggbøkene mine",
"section_shared": "Felles loggbøker",
"section_shared_hint": "Du er invitert som Crew-medlem. Skipperprofil og innstillinger tilhører eieren.",
"role_owner": "Egen loggbok",
"role_owner_hint": "Du er eier og skipper av denne loggboken",
"role_crew": "Tilgang for crewet",
"role_crew_hint": "Loggbok med invitasjon - du kan jobbe som crew og signere den",
"role_read": "Bare les",
"role_read_hint": "Delt loggbok - kun visning, ingen redigering",
"open_profile": "Åpne profilen til {{name}}",
"open_logbook": "Åpne loggbok «{{title}}»",
"edit_title": "Endre navn på loggbok",
"edit_placeholder": "Nytt navn på loggboken",
"edit_success": "Loggboken har fått nytt navn",
"edit_btn": "Gi nytt navn",
"filter_label": "Filtrer loggbøker",
"filter_placeholder": "Navn, årstall, dato, crew eller skip …",
"filter_clear": "Tilbakestill filter",
"filter_results": "{{count}} Treff",
"filter_no_results": "Ingen loggbøker samsvarer med søket ditt. Prøv et annet navn eller et annet år.",
"sort_label": "Sortere",
"sort_by_label": "Sorter etter",
"sort_by_name": "Navn",
"sort_by_date": "dato",
"sort_dir_label": "Sekvens",
"sort_asc": "Stigende",
"sort_desc": "Synkende",
"sort_name_asc": "Navn A til Å",
"sort_name_desc": "Navn Z til A",
"sort_date_asc": "Eldst først",
"sort_date_desc": "Nyeste først"
},
"profile": {
"title": "Brukerprofil",
"subtitle": "Regnskap, Passkeys og statistikk for {{name}}.",
"back": "Tilbake til dashbordet",
"loading": "Profilen lastes inn...",
"load_error": "Profilen kunne ikke lastes inn.",
"copy_failed": "Kopien mislyktes.",
"processing": "Blir behandlet...",
"identity_title": "Kontoidentitet",
"username": "Brukernavn",
"user_id": "Bruker-ID",
"copy_user_id": "Kopier bruker-ID",
"account_since": "Konto siden",
"prf_status": "Passkey nøkkelavledning (PRF)",
"prf_active": "Aktiv",
"prf_inactive": "Ikke satt opp",
"passkeys_title": "Passkeys",
"passkeys_desc": "Registrer en separat Passkey på hver enhet. Dette gjør at du kan logge på selv etter at du har byttet plattform.",
"passkeys_empty": "Ingen Passkeyer funnet.",
"add_passkey_btn": "Legg til ny Passkey",
"add_passkey_success": "Passkey vellykket lagt til.",
"add_passkey_failed": "Passkey kunne ikke legges til.",
"remove_passkey_btn": "Fjern Passkey",
"remove_passkey_last_title": "Sist Passkey",
"remove_passkey_last_desc": "Den eneste Passkey kan ikke fjernes uten at du mister tilgangen til kontoen din. For å slette kontoen helt, bruk faresonen nederst på denne siden.",
"remove_passkey_failed": "Passkey kunne ikke fjernes.",
"remove_passkey_confirm_title": "Fjern Passkey?",
"remove_passkey_confirm_desc": "Denne enheten kan da ikke lenger logge inn med denne Passkey.",
"remove_passkey_confirm_yes": "Fjern",
"remove_passkey_confirm_no": "Avbryt",
"pin_title": "Lokal PIN-kode",
"pin_status": "Status",
"pin_active": "Aktiv på denne enheten",
"pin_inactive": "Ikke satt opp",
"pin_confirm_label": "Bekreft PIN-kode",
"pin_confirm_placeholder": "Tast inn PIN-koden på nytt",
"pin_set_btn": "Konfigurer PIN-kode",
"pin_change_btn": "Endre PIN-kode",
"pin_remove_btn": "Fjern PIN-kode",
"pin_saved": "PIN-kode lagret.",
"pin_save_failed": "PIN-koden kunne ikke lagres.",
"pin_mismatch": "PIN-kodene stemmer ikke overens.",
"pin_length_error": "PIN-koden må bestå av minst 4 tegn.",
"pin_no_session": "Økten er utløpt - vennligst registrer deg på nytt.",
"remove_pin_confirm_title": "Fjerne PIN-kode?",
"remove_pin_confirm_desc": "Du må logge på igjen på denne enheten med Passkey eller gjenopprettingsnøkkel.",
"remove_pin_confirm_yes": "Fjern PIN-kode",
"remove_pin_confirm_no": "Avbryt",
"security_title": "Sjekkliste for sikkerhet",
"security_desc": "Oversikt over de viktigste beskyttelsesmekanismene for kontoen din.",
"security_passkeys_ok": "Minst én Passkey registrert",
"security_passkeys_missing": "Nei Passkey registrert",
"security_prf_ok": "PRF-nøkkelavledning aktiv",
"security_prf_missing": "PRF ikke satt opp",
"security_pin_ok": "Lokal PIN-kode på denne enheten",
"security_pin_missing": "Ingen lokal PIN-kode",
"security_recovery_ok": "Oppsett av gjenopprettingsnøkkel",
"security_recovery_hint": "De 12 ordene ble vist under registreringen. Oppbevar dem frakoblet og adskilt fra enheten. Du kan opprette en ny nøkkel nedenfor - den gamle blir da ugyldig.",
"recovery_rotate_btn": "Opprett en ny gjenopprettingsnøkkel",
"recovery_rotate_confirm_title": "Opprette en ny gjenopprettingsnøkkel?",
"recovery_rotate_confirm_desc": "Den forrige 12-ordsnøkkelen blir ugyldig umiddelbart. Sørg for at du oppbevarer den nye nøkkelen trygt før du fortsetter.",
"recovery_rotate_confirm_yes": "Opprett ny nøkkel",
"recovery_rotate_confirm_no": "Avbryt",
"recovery_rotate_new_warning": "VIKTIG: Skriv ned disse 12 ordene og oppbevar dem offline. Den forrige gjenopprettingsnøkkelen er nå ugyldig.",
"recovery_rotate_failed": "Gjenopprettingsnøkkel kunne ikke opprettes.",
"recovery_rotate_no_session": "Krypteringsøkten er utløpt - logg ut og logg inn igjen, og prøv deretter på nytt.",
"device_title": "Denne enheten",
"device_desc": "Lokal hurtigbuffer, synkroniseringsstatus og hurtigpålogging i denne nettleseren.",
"device_sync_pending": "{{count}} ventende synkroniseringsoppføringer",
"device_sync_ok": "Alle lokale endringer synkroniseres",
"device_remembered": "Konto for hurtiginnlogging lagret på denne enheten",
"device_not_remembered": "Kontoen er ikke i hurtiginnloggingslisten",
"device_forget_btn": "Glemt konto på denne enheten",
"device_forget_confirm_title": "Fjerne hurtiginnlogging?",
"device_forget_confirm_desc": "Kontoen forsvinner fra hurtiginnloggingslisten på denne enheten. Økten og de lokale loggbøkene beholdes.",
"device_forget_confirm_yes": "Fjern",
"device_forget_confirm_no": "Avbryt",
"passkey_label": "Navn på ny Passkey (valgfritt)",
"passkey_label_placeholder": "z. f.eks. MacBook, iPhone",
"passkey_rename_btn": "Lagre navn",
"passkey_rename_success": "Passkey navn lagret.",
"passkey_rename_failed": "Passkey-Navn kunne ikke lagres.",
"passkey_unnamed": "Uten tittel Passkey",
"stats_title": "Statistikk",
"stats_subtitle": "Om alle loggbøkene dine på denne enheten",
"stats_logbooks": "Loggbøker",
"stats_account_since": "Konto siden",
"stats_shared_logbooks": "Felles loggbøker",
"appearance_title": "App og visualisering",
"appearance_desc": "Designet og fargevalget gjelder for hele appen på denne enheten.",
"theme_label": "Appens designstil",
"theme_auto": "Automatisk (OS-deteksjon)",
"theme_ocean": "Ocean (glassmorfisme)",
"theme_material": "Materiale (Android)",
"theme_cupertino": "Cupertino (iOS)",
"color_scheme_label": "Lys eller mørk modus",
"color_scheme_auto": "Automatisk (system)",
"color_scheme_light": "Lys",
"color_scheme_dark": "Mørk",
"integrations_title": "Integrasjoner",
"owm_key": "OpenWeatherMap API-nøkkel",
"owm_help": "Valgfritt: egen OpenWeatherMap API-nøkkel. Hvis ingen oppføring er gjort, brukes serverside-nøkkelen fra operatørkonfigurasjonen.",
"prefs_save": "Spar",
"prefs_saving": "...vil bli reddet...",
"prefs_saved": "Reddet",
"tour_title": "App-tur",
"tour_desc": "La deg veilede gjennom de viktigste områdene i appen på nytt.",
"tour_restart": "Start turen på nytt",
"push_title": "Push-varsler",
"push_desc": "Som loggbokseier vil du bli varslet når inviterte Crew-medlemmer synkroniserer endringer. Ingen innhold overføres i ren tekst.",
"push_enable": "Gi oss beskjed om endringer i crewet",
"push_active": "Push-varsler er aktive på denne enheten.",
"push_unsupported": "Push-varsler støttes ikke i denne nettleseren.",
"push_denied_hint": "Varsler er blokkert. Tillat dem i innstillingene i nettleseren eller på enheten.",
"push_ios_install_hint": "På iPhone/iPad: Legg til app på startskjermen (iOS 16.4+) for å bruke push.",
"push_error": "Push-varsler kunne ikke aktiveres.",
"sections": {
"account": "Konto og innstillinger",
"fleet": "Flåte og crew",
"security": "Sikkerhet og enhet",
"stats": "Statistikk",
"danger": "Faresone"
}
},
"vessel_pool": {
"title": "Skipsflåte",
"section_title": "Dine skip",
"subtitle": "Hold alle skip for loggbøkene dine her. Velg aktivt skip per loggbok fra listen.",
"loading": "Laster skipsflåte…",
"add_vessel": "Legg til skip",
"edit_vessel": "Rediger skip",
"no_vessels": "Ingen skip i poolen ennå.",
"delete_confirm": "Fjerne dette skipet fra flåten?",
"max_vessels": "Maksimalt 20 skip i poolen."
},
"logbook_vessel": {
"title": "Skip for denne loggboken",
"subtitle": "Velg skip for denne loggboken. Reisedager bruker seil- og tankdata fra valgt skip.",
"active_vessel": "Skip for denne loggboken",
"no_vessels_in_pool": "Ingen skip i flåten legg til i brukerprofilen først.",
"no_vessel": "Ingen skip valgt",
"unnamed": "Uten navn",
"save": "Lagre skip",
"saved": "Loggbok-skip lagret.",
"selection_only_hint": "Du ser skipet eieren har valgt (delt loggbok).",
"manage_in_profile": "Administrer skip i brukerprofilen"
},
"person_pool": {
"title": "Stamm-Crew og skippere",
"subtitle": "Hold personpoolen din her skippere og crew for alle loggbøker. Velg aktivt crew per loggbok og reisedag fra poolen.",
"loading": "Laster personpool…",
"skippers_section": "Skippere",
"crew_section": "Stamm-Crew",
"add_skipper": "Legg til skipper",
"add_crew": "Legg til Crew-medlem",
"edit_skipper": "Rediger skipper",
"no_skippers": "Ingen skipper i poolen ennå.",
"no_crew": "Ingen Crew-medlemmer i poolen ennå.",
"delete_confirm": "Fjerne denne personen fra poolen?"
},
"logbook_crew": {
"title": "Crew for denne loggboken",
"subtitle": "Velg skipper og crew for denne loggboken. Nye reisedager arver valget som standard.",
"loading": "Laster crew…",
"active_skipper": "Skipper for denne loggboken",
"active_crew": "Crew for denne loggboken",
"no_skippers_in_pool": "Ingen skipper i poolen legg til i brukerprofilen først.",
"no_crew_in_pool": "Ingen crew i poolen legg til i brukerprofilen først.",
"no_skipper": "Ingen skipper valgt",
"unnamed": "Uten navn",
"save": "Lagre crew",
"saved": "Loggbok-Crew lagret.",
"selection_only_hint": "Du ser crewet eieren har valgt (delt loggbok)."
},
"entry_crew": {
"title": "Crew på denne reisedagen",
"subtitle": "Kan avvike fra loggbokstandard. Følgende dager arver fra forrige dag.",
"day_skipper": "Skipper denne dagen",
"day_crew": "Crew denne dagen",
"no_skipper": "Ingen skipper valgt",
"no_crew": "Ingen crew valgt"
},
"crew": {
"title": "Skipper- og Crew-profiler",
"skipper_section": "Skipperprofil",
"skipper_read_only_hint": "Skipperprofilen kan bare redigeres av eieren av loggboken.",
"crew_section": "Crew-liste",
"add_crew": "Legg til Crew-medlem",
"edit_crew": "Rediger Crew-medlem",
"no_crew": "Ingen Crew-medlemmer er lagt til ennå.",
"max_crew": "Maksimalt antall på 12 Crew-medlemmer i poolen er nådd.",
"name": "Navn",
"address": "adresse",
"birthdate": "Bursdag",
"phone": "Telefonnummer",
"nationality": "Nasjonalitet",
"passport": "Pass-/ID-nummer",
"bloodtype": "Blodgruppe",
"allergies": "Allergier",
"diseases": "Eksisterende tilstander/sykdommer",
"save": "Lagre skipperdata",
"save_member": "Lagre medlem",
"saved": "Skipperprofilen er vellykket lagret!",
"loading": "Crew-filene er lastet inn...",
"delete_confirm": "Er du sikker på at du vil fjerne dette Crew-medlemmet?"
},
"deviation": {
"title": "Tabell over kompassavvik",
"subtitle": "Angi den magnetiske kompassavbøyningen (avbøyning) for kurser (MgK) fra 000° til 360° i trinn på 10°.",
"heading": "MgK",
"deviation": "Distraksjon",
"save": "Lagre kalibreringsrutenettet",
"saving": "...vil bli reddet...",
"saved": "Kalibreringsrutenettet er vellykket lagret!",
"loading": "Kalibreringstabellen er lastet inn..."
},
"settings": {
"title": "Innstillinger for loggbok",
"subtitle": "Del, sikkerhetskopier og samarbeid for denne loggboken.",
"select_logbook_hint": "Velg en loggbok for å redigere innstillingene.",
"no_key": "Ingen OpenWeatherMap API-nøkkel tilgjengelig. Lagre din egen nøkkel i brukerprofilen, eller kontakt operatøren.",
"weather_success": "Værdata vellykket hentet!",
"weather_error": "Henting av værdata mislyktes. Kontroller API-nøkkelen og tilkoblingen.",
"weather_date_mismatch": "Værdata kan bare hentes ut for i dag ({{today}}). Denne loggbokoppføringen er datert {{date}}.",
"gps_error": "Vennligst skriv inn en posisjon eller finn GPS-koordinatene.",
"share_title": "Del loggbok (skrivebeskyttet)",
"share_desc": "Aktiver dette alternativet for å opprette en offentlig, skrivebeskyttet lenke. Alle som har denne lenken, kan se seilasene, båtprofilene og crewet ditt. Krypteringsnøklene overføres aldri til serveren (de forblir i hash-delen av URL-en).",
"share_privacy_warning": "Anbefaling: Del denne lenken kun privat (f.eks. via e-post eller messenger), ikke på sosiale medier.",
"share_enable": "Aktiver offentlig lenke",
"share_copied": "Linken er kopiert!",
"share_copy_btn": "Kopier lenke",
"link_qr_hint": "Skann QR-koden med telefonen",
"link_qr_alt": "QR-kode for lenken",
"danger_zone_title": "Faresone",
"danger_zone_desc": "Hvis du sletter kontoen din, slettes alle dine Passkeys, loggbøker, skipsdata, Crew-profiler, reiseoppføringer og E2E-nøkler ugjenkallelig. Denne handlingen kan ikke angres.",
"delete_account_btn": "Slett konto ugjenkallelig",
"delete_account_confirm_title": "Slett konto?",
"delete_account_confirm_desc": "Er du helt sikker på at du vil slette kontoen din og alle tilknyttede loggbøker og E2E-krypterte data ugjenkallelig?",
"delete_account_confirm_yes": "Ja, slett konto og alle data",
"delete_account_confirm_no": "Avbryt",
"delete_account_failed": "Kontoen kunne ikke slettes. Vennligst prøv igjen.",
"delete_backup_hint": "Tips: Lag sikkerhetskopier av loggbøkene dine (.daagbok.json) i innstillingene for hver loggbok før du sletter dem.",
"deleting_account": "Kontoen vil bli slettet...",
"invite_push_prompt_title": "Aktivere push-varsler?",
"invite_push_prompt_message": "Så snart inviterte Crew-medlemmer synkroniserer endringer, kan du bli informert via push. Ingen loggbokinnhold sendes i ren tekst.",
"invite_push_prompt_ios_message": "Så snart Crew-medlemmene synkroniserer endringer, kan du bli informert via push. På iPhone/iPad: Legg til appen på startskjermen (iOS 16.4+), og aktiver deretter push i brukerprofilen.",
"invite_push_prompt_enable": "Aktiver nå",
"invite_push_prompt_later": "Senere",
"invite_push_prompt_success": "Push-varsler er aktive på denne enheten.",
"backup_title": "Sikkerhetskopiering og gjenoppretting",
"backup_desc": "Fullstendig kryptert sikkerhetskopi av denne loggboken (oppføringer, bilder, GPS-spor, crew, skip). Beskyttet med sikkerhetskopieringspassord - for gjenoppretting til denne eller en ny konto.",
"backup_export_title": "Opprett sikkerhetskopi",
"backup_export_desc": "Laster ned alle lokale data som .daagbok.json. Hold filen og passordfrasen adskilt og sikker.",
"backup_restore_title": "Gjenopprett sikkerhetskopi",
"backup_restore_desc": "Gjenoppretter en sikkerhetskopi til din nåværende konto - selv etter at du har registrert en ny konto.",
"backup_passphrase": "Passord for sikkerhetskopiering",
"backup_passphrase_placeholder": "Minst 8 tegn",
"backup_passphrase_confirm": "Bekreft passordfrasen",
"backup_passphrase_short": "Passordfrasen for sikkerhetskopiering må være på minst 8 tegn.",
"backup_passphrase_mismatch": "Passordfraser stemmer ikke overens.",
"backup_wrong_passphrase": "Passordfrasen er feil eller sikkerhetskopien er ødelagt.",
"backup_export_btn": "Last ned sikkerhetskopi",
"backup_exporting": "Sikkerhetskopien er opprettet...",
"backup_export_success": "Sikkerhetskopi opprettet ({{count}} reisedager).",
"backup_file_label": "Sikkerhetskopifil (.daagbok.json)",
"backup_preview_btn": "Sjekk innhold",
"backup_previewing": "Sjekk...",
"backup_restore_btn": "Gjenopprett",
"backup_restoring": "Vil bli restaurert...",
"backup_restore_success": "Loggbok \"{{title}}\" er gjenopprettet.",
"backup_restore_cancelled": "Gjenoppretting avlyst.",
"backup_invalid_json": "Filen er ikke en gyldig JSON-fil.",
"backup_invalid_format": "Ukjent eller utdatert sikkerhetskopiformat.",
"backup_not_owner": "Det er bare eieren av loggboken som kan opprette sikkerhetskopier.",
"backup_not_authenticated": "Vennligst logg inn for å gjenopprette en sikkerhetskopi.",
"backup_id_conflict": "Det finnes allerede en loggbok med denne ID-en.",
"backup_overwrite_confirm": "Den eksisterende loggboken med samme ID erstattes. Fortsette?",
"backup_new_id_confirm": "Importere sikkerhetskopien som en ny loggbok med ny ID?",
"backup_stat_entries": "{{count}} Reisedager",
"backup_stat_photos": "{{count}} Bilder",
"backup_stat_crew": "{{count}} Crew-poster",
"backup_stat_tracks": "{{count}} GPS-spor",
"backup_exported_at": "Eksportert: {{date}}"
},
"disclaimer": {
"title": "Viktige merknader",
"intro": "Vennligst les følgende instruksjoner før du bruker Kapteins Daagbok.",
"e2e_title": "Ende-til-ende-kryptering",
"e2e_body": "Loggbokdataene dine er kryptert fra ende til ende. Bare du - eller personer med din nøkkel - kan lese innholdet. Kun krypterte data lagres på serveren.",
"pwa_title": "Progressiv webapp (PWA)",
"pwa_body": "Kapteins Daagbok kjører som en progressiv webapp i nettleseren din og kan installeres på enheten din - på samme måte som en native-app, men uten en appbutikk.",
"storage_title": "Lokal lagring og synkronisering",
"storage_body": "Dataene dine lagres lokalt på enheten din (IndexedDB). Endringer synkroniseres med serveren når en Internett-tilkobling er aktiv. Du kan fortsette å jobbe uten tilkobling, synkroniseringen skjer senere.",
"free_title": "Gratis og reklamefri",
"free_body": "Kapteins Daagbok er gratis og inneholder ingen reklame.",
"liability_title": "Ansvarsfraskrivelse",
"liability_body": "Bruk av appen skjer på eget ansvar. Vi fraskriver oss ethvert ansvar for skader som oppstår som følge av bruk av appen - inkludert feilaktige eller ufullstendige loggbokoppføringer, tap av data eller tekniske feil.",
"warranty_title": "Ingen garanti",
"warranty_body": "Det gis ingen garanti for tjenestens funksjon, korrekthet eller tilgjengelighet. Driften kan når som helst bli avbrutt, begrenset eller kansellert.",
"copyright": "© 2026 KnorrLabs, Markus F.J. Busche",
"accept": "Godta og fortsett",
"close": "Lukk",
"button_title": "Merknader og ansvarsfraskrivelse"
},
"feedback": {
"button_title": "Send tilbakemelding",
"title": "Tilbakemeldinger",
"intro": "Del feil, ideer eller generelle tilbakemeldinger. Meldingen din vil bli sendt til prosjektteamet via en sikker varslingskanal.",
"category_label": "Kategori",
"category_general": "Generelt",
"category_bug": "Rapporter feil",
"category_feature": "Forespørsel om funksjonalitet",
"category_translation": "Oversettelsesfeil",
"contact_label": "E-post (valgfritt)",
"contact_placeholder": "deine@email.beispiel",
"message_label": "Melding",
"message_placeholder": "Beskriv tilbakemeldingene dine...",
"send": "Send",
"sending": "Vil bli sendt...",
"cancel": "Avbryt",
"success": "Tusen takk skal du ha! Tilbakemeldingen din er sendt.",
"error_send": "Tilbakemelding kunne ikke sendes. Vennligst prøv igjen senere.",
"error_invalid_email": "Vennligst skriv inn en gyldig e-postadresse.",
"error_not_configured": "Tilbakemelding er ikke tilgjengelig på denne serveren.",
"error_rate_limited": "For mange tilbakemeldinger på kort tid. Vennligst vent noen minutter.",
"error_spam": "Denne meldingen kunne ikke sendes. Vennligst omformuler den."
},
"demo": {
"logbook_title": "Demologgbok Østersjøen",
"badge": "Demo",
"public_banner": "Skrivebeskyttet demovisning",
"cta_register": "Opprett konto",
"back_to_login": "Til registreringen"
},
"invitation": {
"error_invalid_key": "Invitasjonslenken er kryptografisk ugyldig (feil nøkkel).",
"error_missing_key": "Invitasjonslenken inneholder ikke en dekrypteringsnøkkel (#key=...). Vennligst bruk den fullstendige lenken fra eieren.",
"error_expired": "Denne invitasjonen har utløpt (gyldig i 48 timer).",
"error_invalid_token": "Invitasjonstokenet er ugyldig.",
"error_load_failed": "Invitasjonsdetaljer kunne ikke lastes inn.",
"error_incomplete_session": "Økten er ufullstendig - vennligst logg inn på nytt (bruker-ID mangler).",
"error_accept_failed": "Tiltredelse mislyktes.",
"error_login_failed": "Passkey Innlogging mislyktes.",
"error_username_missing": "Brukernavnet ble ikke funnet - vennligst logg inn på nytt.",
"error_register_failed": "Registrering mislyktes.",
"loading_joining": "Bli med...",
"loading_checking": "Invitasjonen vil bli sjekket...",
"loading_unlocking": "Loggboken er låst opp og synkronisert...",
"loading_retrieving_key": "Last ned krypteringsnøkkelen...",
"error_title": "Feil i invitasjonen",
"back_to_start": "Tilbake til start",
"title": "Invitasjon til loggbok",
"invited_by": "Invitasjon fra",
"vessel_logbook": "Skip / Loggbok",
"signed_in_preparing": "Registrert som {{username}}. Tilslutning er under forberedelse...",
"join_again": "Bli med igjen",
"login_or_register_hint": "Logg inn eller registrer en konto for å bli med i loggboken.",
"or_sign_up": "ELLER REGISTRER DEG PÅ NYTT",
"register_crew_account": "Opprett en ny crew-konto",
"username_label": "Brukernavn",
"create_passkey": "Opprett Passkey",
"switch_language_en": "Engelsk",
"switch_language_de": "Tysk"
},
"stats": {
"title": "Statistikk",
"subtitle": "Oversikt over ruter, forbruk og kjøretype",
"scope_label": "Evalueringsområde",
"scope_logbook": "Denne loggboken",
"scope_account": "Alle loggbøker",
"loading": "Statistikken er beregnet...",
"no_data": "Ingen reisedager tilgjengelig ennå.",
"total_distance": "Total avstand",
"travel_days": "Reisedager",
"sail_distance": "Under seil",
"motor_distance": "Maskinreise",
"motor_hours_total": "Totalt antall maskintimer",
"daily_motor_hours": "Maskintimer per reisedøgn",
"avg_motor_hours": "Ø maskintimer per reisedøgn",
"unknown_propulsion": "Ukjent",
"fuel_total": "Totalt drivstoff",
"water_total": "Totalt vann",
"daily_etmal": "Daglige tider",
"daily_consumption": "Daglig forbruk",
"route_overview": "Rute",
"route_map_title": "Oversikt over ruten",
"propulsion_title": "Seil vs. maskin",
"propulsion_hint": "Fordelingen er basert på loggbokhendelser per reisedag, ikke på GPS-segmenter.",
"avg_distance": "Ø per reisedag",
"avg_fuel": "Ø Drivstoff",
"avg_water": "Ø Vann",
"fuel_per_nm": "Drivstoff per sm",
"fuel_per_motor_hour": "Drivstoff per maskintime",
"daily_fuel_per_motor_hour": "Drivstofforbruk per maskintime per kjøredag",
"fuel_legend": "Drivstoff",
"water_legend": "Vann",
"unit_nm": "sm",
"unit_h": "h",
"unit_l": "L",
"day_label": "Dag {{day}}",
"account_logbooks": "Oversikt over loggbøker",
"col_logbook": "Loggbok",
"event_series_title": "Hendelsesforløp",
"event_series_hint": "Kronologiske verdier fra hendelsesloggen.",
"event_series_pressure": "Lufttrykk",
"event_series_wind": "Vind",
"event_series_motor": "Motor",
"event_series_empty": "Ingen oppføringer ennå."
},
"tour": {
"skip": "Hopp over turen",
"back": "Tilbake",
"next": "Videre",
"finish": "Ferdig",
"progress": "Trinn {{current}} fra {{total}}",
"steps": {
"welcome": {
"title": "Velkommen om bord!",
"body": "Vi har laget en demo-loggbok med tre dagers reise i Kielfjorden for deg. Du kan når som helst slette eksempeloppføringene hvis du vil starte din egen loggbok. Denne korte omvisningen viser deg de viktigste funksjonene."
},
"welcome_public": {
"title": "Velkommen om bord!",
"body": "Utforsk vår demologgbok med tre dagers reise i Kielfjorden uten konto. Omvisningen viser loggbokoppføringer og valg av skip og crew for denne loggboken. Flåte og stamm-crew legger du inn senere i brukerprofilen."
},
"nav_logs": {
"title": "Loggbokoppføringer",
"body": "Her administrerer du reisedagene dine - avreise, destinasjon, vær, drivstoffnivå og GPS-spor."
},
"entry_list": {
"title": "Dine reisedager",
"body": "Hvert kort representerer en reisedag. Trykk på en oppføring for å vise eller redigere detaljer."
},
"entry_open": {
"title": "Åpen reisedag",
"body": "Slik ser en fullført loggbok ut - med hendelser, tanknivåer og mer."
},
"entry_track": {
"title": "GPS-sporing",
"body": "Last opp GPX-filer eller se allerede lagrede ruter på kartet - inkludert avstand og hastighet."
},
"nav_vessel": {
"title": "Skip for loggbok",
"body": "Velg skip fra flåten for denne loggboken. Administrer skip i brukerprofilen under Flåte og crew."
},
"profile_vessel_pool": {
"title": "Skipsflåte",
"body": "I brukerprofilen legger du inn alle skip charter, eget båt osv. Velg deretter riktig skip per loggbok."
},
"profile_crew_pool": {
"title": "Stamm-Crew og skippere",
"body": "I brukerprofilen vedlikeholder du en personpool flere skippere (f.eks. charter) og crew for alle loggbøker."
},
"nav_logbook_crew": {
"title": "Crew per loggbok",
"body": "Velg skipper og crew fra poolen for denne loggboken. Reisedager arver valget som standard."
},
"nav_stats": {
"title": "Dashbord for statistikk",
"body": "Her kan du se kjørelengder, drivstofforbruk, rutekart og kjøreandeler - automatisk beregnet ut fra loggbokoppføringene dine."
},
"nav_feedback": {
"title": "Send tilbakemelding",
"body": "Du kan bruke dette skjemaet til å sende feil, ideer eller generelle tilbakemeldinger direkte til prosjektteamet - også etter omvisningen, når som helst ved hjelp av ikonet øverst til høyre."
},
"nav_profile": {
"title": "Din brukerprofil",
"body": "Du får tilgang til din personlige profil via skipperknappen øverst - uavhengig av hvilken loggbok du bruker."
},
"profile_preferences": {
"title": "Regnskap og presentasjon",
"body": "Her kan du administrere kontoidentitet, tema og lys/mørk modus. Du kan når som helst starte appturen på nytt. Passkeys og sikkerhetsinnstillinger finner du lenger ned i profilen."
},
"finish": {
"title": "Greit!",
"body": "Du kommer rett til statistikkoversikten. Du kan når som helst starte turen på nytt i brukerprofilen din. Ha en riktig god tur!"
}
}
},
"seo": {
"title": "Kapteins Daagbok - Gratis digital loggbok for fritidsbåter (uten reklame)",
"description": "Gratis, annonsefri digital loggbok med ende-til-ende-kryptering og Passkey-pålogging. Dokumenter seilingsdager, GPS-spor, Crew- og skipsdata på en sikker måte - også offline som PWA.",
"keywords": "Yachtloggbok, skipsloggbok, loggbok om bord, seiling, Passkey, E2E-kryptering, GPS-sporing, maritim loggbok, gratis, reklamefri, gratis, uten reklame",
"ogImageAlt": "Kapteins Daagbok Logo"
}
}
}
+990
View File
@@ -0,0 +1,990 @@
{
"translation": {
"app": {
"name": "Kapteins Daagbok",
"tagline": "Loggbok för privat yacht",
"beta": "Beta",
"beta_hint": "Betaversion - funktioner kan fortfarande ändras"
},
"languages": {
"de": "Deutsch",
"en": "English",
"da": "Dansk",
"sv": "Svenska",
"nb": "Norsk"
},
"dialog": {
"ok": "OK",
"yes": "Ja",
"no": "Nej"
},
"errors": {
"load_failed": "Data kunde inte laddas.",
"save_failed": "Ändringar kunde inte sparas.",
"delete_failed": "Radering misslyckades.",
"export_failed": "Export misslyckades."
},
"common": {
"unsaved_changes_title": "Osparade ändringar",
"unsaved_changes_message": "Du har ändringar som inte sparats. Vill du verkligen lämna sidan? Dina ändringar kommer att gå förlorade.",
"unsaved_changes_stay": "Stanna kvar",
"unsaved_changes_save_leave": "Spara och lämna",
"unsaved_changes_discard": "Kasta",
"unsaved_changes_leave": "Övergivande"
},
"nav": {
"dashboard": "Instrumentpanel",
"vessel": "Fartygsdata",
"crew": "Crew",
"deviation": "Distraktionsbord",
"logs": "Loggboksanteckningar",
"stats": "Statistik",
"settings": "Inställningar"
},
"auth": {
"welcome": "Välkommen till Kapteins Daagbok",
"tagline": "Din säkra, E2Ekrypterade loggbok för sjöfarten.",
"register": "Registrera dig med Passkey",
"login": "Logga in med Passkey",
"login_as": "Logga in som {{name}}",
"quick_login": "Snabb inloggning",
"forget_account": "Glömt konto på den här enheten",
"not_user": "Inte {{name}}?",
"recovery_title": "Din återställningsnyckel",
"recovery_warning": "VIKTIGT: Skriv ner dessa 12 ord. Om du förlorar din Passkey och dessa ord kan dina data inte återställas.",
"confirm_recovery": "Jag har skrivit ner orden",
"status_logged_in": "Inloggad",
"status_logged_out": "Avbruten",
"copied": "Kopierat!",
"copy_phrase": "Kopiera tangent",
"enter_recovery": "Ange återställningsnyckel",
"recovery_fallback_warning": "Din Passkey har autentiserats, men din enhet stöder inte maskinvarubaserad nyckelavledning. Ange din återställningsnyckel på 12 ord för att dekryptera din loggbok.",
"recovery_placeholder": "Ange din återställningsnyckel som består av 12 ord åtskilda av mellanslag...",
"back": "Tillbaka",
"decrypting": "Dekryptering...",
"decrypt_logbook": "Dekryptera loggbok",
"error_incorrect_recovery": "Felaktig återställningsnyckel. Dekryptering misslyckades.",
"error_decryption_failed": "Dekrypteringen misslyckades. Vänligen kontrollera din återställningsnyckel.",
"or_register": "eller registrera dig",
"explore_demo": "Utforska demoversionen utan konto",
"username_placeholder": "Användarnamn / Skepparnamn",
"processing": "Bearbetning...",
"help": "Hjälp",
"setup_pin_title": "Ange lokal PIN-kod (tillval)",
"setup_pin_warning": "Eftersom din enhet inte stöder direkt härledning av Passkey-nycklar måste du annars ange din nyckel på 12 ord varje gång du loggar in på den här enheten. Konfigurera en lokal PIN-kod för att undvika detta.",
"pin_placeholder": "E.G. 123456",
"pin_label": "Lokal PIN-kod (4-8 siffror)",
"save_pin": "Spara PIN-kod och fortsätt",
"skip_pin": "Skip & använd återvinning",
"enter_pin_title": "Dekryptera med PIN-kod",
"enter_pin_warning": "Ange din lokala PIN-kod för att låsa upp dekrypteringsnyckeln på den här enheten.",
"enter_pin_placeholder": "Ange din PIN-kod...",
"decrypt_with_pin": "Dekryptera",
"use_recovery_instead": "Använd återställningsnycklar istället",
"error_incorrect_pin": "Felaktig PIN-kod. Dekryptering misslyckades.",
"error_invalid_host": "Passkeys fungerar inte via 127.0.0.1. Öppna appen via localhost.",
"use_localhost_link": "Byt till localhost",
"error_passkey_cancelled": "Passkey-inloggning avbröts eller gick ut. Försök igen.",
"error_invalid_rp_id": "Passkey-domänen matchar inte (RP ID). Använd http://localhost:5173 med RP_ID=localhost i .env för lokal utveckling.",
"error_session_incomplete": "Inloggning ofullständig. Logga in med passkey igen."
},
"pwa": {
"title": "Installera app",
"generic_benefit": "Installera Kapteins Daagbok på din enhet för snabbare åtkomst, offline-användning och permanent datalagring.",
"ios_instructions": "På iPad/iPhone: Lägg till appen på startskärmen så att dina loggboksdata förblir skyddade och appen startar som en inbyggd app.",
"ios_step_share": "Tryck på aktiesymbolen i fältet Safari.",
"ios_step_add": "Välj \"Gå till startskärmen\"",
"install_now": "Installera nu",
"installing": "Installation...",
"later": "Senare",
"never": "Visa inte mer",
"platform_ios": "Installation via Safari.",
"platform_android": "Installation via webbläsaren",
"platform_desktop": "Installation som en skrivbordsapp",
"settings_section": "Installation av app",
"update_title": "Uppdatering tillgänglig",
"update_desc": "En ny version av Kapteins Daagbok är klar. Uppdatera för att få de senaste ändringarna.",
"update_now": "Uppdatering nu",
"update_reloading": "Laddar...",
"storage_persist_hint": "Webbläsaren kan radera offlinedata. Tillåt permanent lagring så att din loggbok förblir skyddad."
},
"sync": {
"status_synced": "Synkroniserad",
"status_syncing": "Synkronisera...",
"status_offline": "Offline-cache",
"status_unsynced": "Osynkroniserade förändringar",
"conflict_title": "Synkroniseringskonflikt",
"conflict_message": "{{count}} ändring(ar) kunde inte synkas (post {{id}}…). Välj vilken version som ska gälla.",
"conflict_use_server": "Använd serverversion",
"conflict_keep_local": "Behåll min version"
},
"vessel": {
"title": "Masterdata för fartyg",
"name": "Yacht namn",
"type": "Typ av båt",
"type_unset": "- inte specificerad -",
"type_sailing": "Segelyacht",
"type_motor": "Motorbåt",
"length_m": "Längd (m)",
"draft_m": "Djupgående (m)",
"air_draft_m": "Höjd (m)",
"invalid_metric": "Ogiltigt numeriskt värde - ange meter som ett decimaltal (t.ex. 12,5).",
"port": "Hem hamn",
"owner": "Ägare",
"charter": "Charterbolag",
"registration": "Registreringsnummer/registreringsskylt",
"callsign": "Radioanropssignal",
"atis": "ATIS nr.",
"mmsi": "MMSI nr.",
"save": "Spara fartygsdata",
"saving": "Kommer att sparas...",
"saved": "Fartygsdata har sparats framgångsrikt!",
"loading": "Fartygsdata är inlästa...",
"sails_list": "Segel (befintliga segel)",
"sails_help": "Ange här de segel som finns tillgängliga på din båt (t.ex. storsegel, genua, fock).",
"add_sail": "Lägg till segel",
"sail_name_placeholder": "z. t.ex. storsegel",
"no_sails": "Inga segel lagrade.",
"photo_add": "Lägg till foto",
"photo_change": "Ändra foto",
"photo_delete": "Ta bort foto",
"tanks_section": "Tankar (kapacitet)",
"tanks_help": "Valfritt i liter - möjliggör slider i journalen för kända tankstorlekar.",
"freshwater_capacity_l": "Dricksvatten (liter)",
"fuel_capacity_l": "Bränsle (liter)",
"greywater_capacity_l": "Gråvatten (liter)",
"invalid_tank_liters": "Ogiltigt numeriskt värde - ange liter som ett tal (t.ex. 200)."
},
"logs": {
"title": "Loggboksjournal",
"new_entry": "Ny resdag",
"travel_details": "Detaljer om resan",
"add_event": "Lägg till ny loggbokspost",
"add_event_btn": "Lägg till händelse",
"edit_event": "Redigera händelse",
"save_event_btn": "Spara ändring",
"cancel_event_edit": "Avbryt",
"delete_event": "Ta bort händelse",
"sign_cleared_skipper_re_sign_title": "Skippers signatur borttagen",
"sign_cleared_skipper_re_sign": "Händelseloggen har ändrats. Skepparens signatur har tagits bort. Vänligen godkänn igen.",
"date": "datum",
"day_of_travel": "Resedag",
"travel_day_number": "Resedag {{number}}",
"departure": "Starthamn (resa från)",
"destination": "Destinationsport (till)",
"route": "Resa från/till",
"freshwater": "Färskvatten (liter)",
"fuel": "Treibstoff / Bränsle (liter)",
"greywater": "Gråvatten (liter)",
"greywater_level": "Fyllnadsnivå",
"tank_slider_of_max": "{{current}} / {{max}} L",
"tank_capacity_tooltip": "Om tankens kapacitet (liter) finns lagrad i fartygets data kan du ange fyllnadsnivåerna här med hjälp av skjutreglaget.",
"morning": "Stå på morgonen",
"refilled": "Påfylld",
"evening": "Kvällsställ",
"consumption": "Daglig konsumtion",
"signatures": "Underskrifter / frisläppande",
"sign_skipper": "Skepparens signatur",
"sign_crew": "Crews signatur",
"sign_hint": "Signera med finger, penna eller mus",
"sign_clear": "Radera",
"sign_export_image": "[Signatur]",
"sign_with_passkey": "Frigör med Passkey",
"sign_passkey_signing": "Passkey begärs...",
"sign_passkey_signed": "Utgiven av {{username}}",
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
"sign_attribution_export": "{{username}} ({{date}})",
"sign_passkey_clear": "Ta bort Passkey release",
"sign_mode_passkey": "Passkey",
"sign_mode_classic": "Klassisk",
"sign_passkey_failed": "Passkey Frigöring misslyckades",
"sign_passkey_cancelled": "Passkey Frigörandet inställt",
"sign_invalid": "Signaturen är ogiltig - innehållet har ändrats",
"sign_badge_skipper": "Skeppare",
"sign_badge_skipper_invalid": "Ogiltig",
"sign_badge_skipper_title_valid": "Skepparen har släppt",
"sign_badge_skipper_title_invalid": "Skippers signatur ogiltig - innehållet har ändrats",
"sign_classic_or_passkey": "Valfritt: klassisk signatur eller Passkey release ovan",
"sign_crew_passkey_hint": "Crew-medlemmar med skrivbehörighet kan frigöra via Passkey.",
"sign_offline_hint": "Passkey-Godkännande kräver Internet - klassisk signatur möjlig offline",
"sign_lock_notice": "Efter undertecknandet är det inte möjligt att göra ändringar i loggboksanteckningen (utom foton) utan att skepparen och crewen måste underteckna på nytt.",
"sign_lock_active": "Denna post är signerad. Ändringar i loggboken (utom foton) tar automatiskt bort skepparens och crews signaturer.",
"sign_lock_warning_title": "Bekräfta underskrift",
"sign_lock_warning": "Efter undertecknandet är det inte längre möjligt att göra ändringar i loggboksanteckningen (utom foton) utan att skepparen och crewen måste underteckna på nytt.\n\nVill du fortsätta?",
"sign_proceed": "Teckna",
"sign_cancel": "Avbryt",
"sign_cleared_re_sign_title": "Underskrifter borttagna",
"sign_cleared_re_sign": "Loggboksanteckningen har ändrats. Skepparens och crews namnteckningar har tagits bort. Vänligen underteckna igen.",
"no_entries": "Inga loggboksposter hittade för denna yacht. Skapa din första resedag!",
"back_to_list": "Tillbaka till tidskriftslistan",
"save": "Spara loggbokssida",
"saving": "Kommer att sparas...",
"saved": "Loggbokssidan har sparats framgångsrikt!",
"loading": "Journalen laddas...",
"view_mode_label": "Vy",
"view_list": "Lista",
"live_mode": "Live",
"live_title": "Live-journal",
"live_loading": "Live-journal laddas...",
"live_retry": "Försök igen",
"live_load_error": "Live-journal kunde inte laddas.",
"live_action_error": "Posten kunde inte sparas.",
"live_open_editor": "Fullständig editor",
"live_actions_label": "Snabbåtgärder",
"live_stream_label": "Händelselogg",
"live_stream_title": "Journal",
"live_no_events": "Inga poster ännu — tryck på en åtgärd.",
"live_motor_start": "Motor Start",
"live_motor_stop": "Motor Stopp",
"live_cast_off": "Avgång",
"live_moor": "Anlöp",
"live_sails_btn": "Segel",
"live_sails_pick": "Välj segel",
"live_sails_pick_hint": "Tryck på flera segel (tryck igen för att avmarkera), logga sedan.",
"live_sails_selected": "Valt: {{sails}}",
"live_sails_confirm": "Logga",
"live_sails_confirm_count": "Logga ({{count}})",
"live_sails": "Segel: {{sails}}",
"live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS ej tillgänglig. Ange latitud och longitud manuellt, eller försök igen med GPS-knappen.",
"live_fix_gps_loading": "Hämtar GPS-position…",
"live_fix_invalid": "Ange giltiga koordinater (latitud 90…90, longitud 180…180).",
"live_fix_lat_placeholder": "Latitud (Lat)",
"live_fix_lng_placeholder": "Longitud (Lng)",
"live_photo_btn": "Foto (kamera)",
"live_photo_capture_btn": "Ta foto",
"live_photo_save_btn": "Spara",
"live_photo_retake_btn": "Ta om",
"live_photo_capture_failed": "Bildtagning misslyckades. Försök igen.",
"live_photo_open_camera_btn": "Öppna kamera",
"live_photo_native_hint": "Ta ett foto med enhetens kamera och spara det här efteråt.",
"live_photo_camera_starting": "Startar kamera…",
"live_photo_camera_denied": "Kameraåtkomst nekad eller ej tillgänglig.",
"live_photo_camera_unavailable": "Kameran stöds inte i den här webbläsaren.",
"live_photo_error": "Foto kunde inte sparas.",
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto taget",
"live_undo_photo_hint": "Foto sparat",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Ange text…",
"live_comment_confirm": "Logga",
"live_gps_error": "GPS-position kunde inte bestämmas.",
"live_gps_start_hint": "Börja alltid dagsresan med en position.",
"live_event_generic": "Händelse",
"live_weather_btn": "Väder",
"live_weather_owm_btn": "Hämta OpenWeatherMap-väder",
"live_weather_owm_loading": "Hämtar väder…",
"live_weather_fix_required": "Logga först en GPS-fix (Fix-knappen) för att hämta OpenWeatherMap-väder. Positionen får högst vara 6 timmar gammal.",
"live_weather_fix_stale": "Senaste GPS-fixen är äldre än 6 timmar. Logga en ny fix innan du hämtar väder.",
"live_wind_btn": "Vind",
"live_temp_btn": "T °C",
"live_pressure_btn": "Lufttryck",
"live_precip_btn": "Nederbörd",
"live_sea_state_btn": "Sjögang",
"live_visibility_btn": "Sikt",
"live_course_btn": "Kurs",
"live_fuel_btn": "Diesel",
"live_water_btn": "Vatten",
"live_wind_entry": "Vind {{value}}",
"live_temp_entry": "Temperatur {{temp}} °C",
"live_pressure_entry": "Lufttryck {{value}} hPa",
"live_precip_entry": "Nederbörd {{value}}",
"live_sea_state_entry": "Sjögang {{value}}",
"live_visibility_entry": "Sikt {{value}}",
"live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Vatten +{{liters}} L",
"live_auto_position": "Auto-position",
"live_undo_hint": "Post sparad",
"live_undo_btn": "Ångra",
"live_pressure_placeholder": "t.ex. 1013",
"live_temp_placeholder": "t.ex. 18",
"live_precip_placeholder": "t.ex. lätt regn",
"live_sea_state_placeholder": "t.ex. 3",
"live_visibility_placeholder": "t.ex. 10 km",
"live_course_placeholder": "t.ex. 245",
"live_fuel_placeholder": "Påfyllda liter",
"live_water_placeholder": "Påfyllda liter",
"live_sog_btn": "SOG",
"live_stw_btn": "STW",
"live_sog_entry": "SOG {{speed}} kn",
"live_stw_entry": "STW {{speed}} kn",
"live_sog_placeholder": "t.ex. 5,2",
"live_stw_placeholder": "t.ex. 4,8",
"live_sog_hint": "Fart över grund (kn) — GPS-värde fylls i om tillgängligt.",
"delete_entry": "Ta bort tagg",
"delete_confirm": "Är du säker på att du vill radera den här resedagen permanent?",
"carry_over_tanks_title": "Överföra data från föregående dag?",
"carry_over_tanks_confirm": "Ta över starthamn, färskvatten, bränsle och gråvatten från startnivåerna från resans sista dag?\n\nStarthamn: {{departure}}\nFärskvatten: {{fw}} L\nBränsle: {{fuel}} L\nGråvatten: {{greywater}} L",
"carry_over_tanks_yes": "Ta över",
"carry_over_tanks_no": "Börja med 0",
"event_title": "Kronologisk händelselogg",
"no_events": "Inga händelser inlagda för denna resdag ännu.",
"event_time": "Tid på dygnet",
"event_mgk": "MgK-kurs",
"event_rwk": "RwK-kurs",
"event_course_section": "Kurs",
"course_dial_hint": "Vrid ringen eller gå in i grader",
"course_dial_step_label": "Stegstorlek",
"course_step_fine": "1°",
"course_step_medium": "5°",
"course_step_coarse": "10°",
"course_tab_mgk": "MgK",
"course_tab_rwk": "rwK",
"course_invalid": "Ogiltig kurs (0-360)",
"course_placeholder_degrees": "z. B. 180",
"course_placeholder_cardinal": "z. E.G. NW",
"compass_n": "N",
"compass_e": "O",
"compass_s": "S",
"compass_w": "W",
"wind_mode_cardinal": "Kardinal",
"wind_mode_degrees": "Som examen",
"event_wind_direction": "Vindriktning",
"event_wind_strength": "Vindstyrka",
"event_sea_state": "Havets tillstånd",
"event_visibility": "Sikt",
"event_visibility_placeholder": "t.ex. 10 km",
"weather_slider_unset": "—",
"weather_slider_pressure": "{{value}} hPa",
"weather_slider_sea_state": "Grad {{value}}",
"weather_slider_heel": "{{value}}°",
"event_weather": "Väder",
"event_log": "Log (sm)",
"event_gps": "GPS-position",
"event_location": "Plats / hamn",
"event_location_placeholder": "z. t.ex. Kiel",
"event_remarks": "Anmärkningar / incidenter",
"gps_btn": "Hämta GPS-koordinater",
"weather_btn": "OpenWeatherMap Ring upp väder",
"event_wind_pressure": "Lufttryck (hPa)",
"event_heel": "Krängning (°)",
"event_sails": "Segelhantering / motor",
"motor_propulsion": "Maskinens resa",
"sails_picker_show_more": "Visa alla segel",
"sails_picker_show_less": "Visa mindre",
"motor_hours": "Maskintimmar (totalt)",
"fuel_per_motor_hour": "Förbrukning per maskintimme",
"event_distance": "Avstånd (sm)",
"export_csv": "Hämta CSV.",
"share_csv": "Aktie",
"export_pdf": "Hämta PDF.",
"exporting_pdf": "PDF genereras...",
"photos_title": "Fotobilagor (E2E-krypterade)",
"photo_caption_label": "Fotobeskrivning/etikett (valfritt)",
"photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet",
"photo_btn": "Ta foto / ladda upp",
"photo_processing": "Håller på att bearbetas...",
"no_photos": "Inga foton kopplade till denna resdag ännu.",
"photo_delete_confirm": "Är du säker på att du vill radera det här fotot permanent?",
"confirm_yes": "Ja",
"confirm_no": "Nej",
"track_upload_title": "GPS-spårning (fil)",
"track_upload_points": "Poäng",
"gps_tracking_btn_gpx": "Ladda ner spårfil",
"gps_track_upload_help": "Dra en GPX-, KML- eller GeoJSON-fil hit eller klicka för att välja",
"gps_track_upload_btn": "Ladda upp GPS-spår",
"gps_track_delete": "Ta bort spårfil",
"gps_track_delete_confirm": "Är du säker på att du vill radera den här spårfilen permanent?",
"track_distance": "GPS-rutt (sm)",
"track_speed_max": "Max. hastighet Hastighet (kn)",
"track_speed_avg": "Ø Hastighet (kn)",
"track_map_title": "GPS-spår på OpenSeaMap",
"track_map_start": "Start",
"track_map_end": "Mål",
"track_map_speed_slow": "långsamt",
"track_map_speed_fast": "snabb",
"track_map_error": "Kartan kunde inte läsas in.",
"exporting": "Export...",
"share_unsupported": "Delning stöds inte på den här enheten. Filen har laddats ner istället.",
"invite_crew": "Bjud in crewen",
"invite_link_copied": "Länk till inbjudan kopierad till urklipp!",
"invite_link_desc": "Dela den här länken med Crew-medlemmar för att ge dem skrivrättigheter till loggboken.",
"collaborators_list": "Medlemmar / Crew",
"revoke": "Ta bort",
"revoke_confirm": "Är du säker på att du vill återkalla den här Crew-medlemmens åtkomst?",
"invite_role": "Roll",
"invite_expires": "Länken är giltig i 48 timmar",
"nmea_import_title": "Import NMEA log",
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
"nmea_import_btn": "Import NMEA",
"nmea_file_label": "NMEA file",
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
"nmea_mode_label": "Generate journal entries",
"nmea_mode_interval": "By time interval",
"nmea_mode_change": "On significant change",
"nmea_mode_both": "Both (merge)",
"nmea_interval_label": "Interval (minutes)",
"nmea_import_track": "Import GPS track from NMEA",
"nmea_preview": "Preview",
"nmea_preview_hint": "{{count}} suggested journal entries",
"nmea_select_all": "Select all",
"nmea_select_none": "Select none",
"nmea_source_interval": "Interval",
"nmea_source_change": "Event",
"nmea_apply": "Apply to journal",
"nmea_back": "Back",
"nmea_cancel": "Cancel",
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
"nmea_archive_keep": "Archive",
"nmea_archive_discard": "Discard",
"nmea_archive_stored": "NMEA archived: {{name}}",
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
"nmea_error_parse": "Could not read NMEA file.",
"nmea_error_read": "Could not read file.",
"nmea_error_no_file": "Please choose an NMEA file first.",
"nmea_error_no_selection": "Please select at least one journal entry.",
"nmea_remark_interval": "NMEA interval",
"nmea_remark_uncertain": "uncertain",
"nmea_remark_depth": "Depth {{depth}} m",
"nmea_change_course": "Course change {{from}}° → {{to}}°",
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
"nmea_change_depth": "Depth {{from}} → {{to}} m",
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
"nmea_change_engine_stop": "Engine off",
"nmea_change_autopilot_on": "Autopilot on",
"nmea_change_autopilot_off": "Autopilot off",
"nmea_change_gps_lost": "GPS fix lost",
"nmea_change_gps_regained": "GPS fix restored",
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop",
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries."
},
"dashboard": {
"title": "Dina loggböcker",
"subtitle": "Välj en loggbok eller skapa en ny för att hantera dina resor.",
"create_btn": "Skapa loggbok",
"new_logbook_placeholder": "Loggbokens eller båtens namn",
"logout": "Logga ut",
"logged_in_as": "Inloggad som {{name}}",
"delete_confirm": "Är du säker på att du vill radera den här loggboken permanent? Alla lokala data och serverkopior kommer att förstöras.\n\nTips: Skapa en säkerhetskopia (.daagbok.json) i förväg under Inställningar → Säkerhetskopiering och återställning om du vill behålla data senare.",
"no_logbooks": "Inga loggböcker hittades. Skapa din första loggbok för att komma igång!",
"loading": "Loggböckerna är fulla...",
"status_synced": "Synkroniserad",
"status_local": "Endast lokal cache",
"delete_btn": "Radera loggbok",
"section_owned": "Mina loggböcker",
"section_shared": "Delade loggböcker",
"section_shared_hint": "Du har blivit inbjuden som Crew-medlem. Skepparens profil och inställningar tillhör ägaren.",
"role_owner": "Egen loggbok",
"role_owner_hint": "Du är ägare och skeppare till denna loggbok",
"role_crew": "Tillträde för crewen",
"role_crew_hint": "Inbjuden loggbok - du kan arbeta som crew och underteckna den",
"role_read": "Endast läsning",
"role_read_hint": "Delad loggbok - endast visning, ingen redigering",
"open_profile": "Öppna profil för {{name}}",
"open_logbook": "Öppna loggbok ”{{title}}”",
"edit_title": "Byt namn på loggbok",
"edit_placeholder": "Nytt namn på loggboken",
"edit_success": "Loggboken har framgångsrikt bytt namn",
"edit_btn": "Byt namn på",
"filter_label": "Filtrera loggböcker",
"filter_placeholder": "Namn, årtal, datum, crew eller fartyg …",
"filter_clear": "Återställ filter",
"filter_results": "{{count}} Träffar",
"filter_no_results": "Inga loggböcker matchar din sökning. Försök med ett annat namn eller ett annat år.",
"sort_label": "Sortera",
"sort_by_label": "Sortera efter",
"sort_by_name": "Namn",
"sort_by_date": "datum",
"sort_dir_label": "Sekvens",
"sort_asc": "Stigande",
"sort_desc": "Nedåtgående",
"sort_name_asc": "Namn A till Ö",
"sort_name_desc": "Namn Z till A",
"sort_date_asc": "Äldst först",
"sort_date_desc": "Nyast först"
},
"profile": {
"title": "Användarprofil",
"subtitle": "Konto, Passkeys och statistik för {{name}}",
"back": "Tillbaka till instrumentpanelen",
"loading": "Profilen håller på att laddas...",
"load_error": "Profilen kunde inte laddas.",
"copy_failed": "Kopiering misslyckades.",
"processing": "Håller på att bearbetas...",
"identity_title": "Kontots identitet",
"username": "Användarens namn",
"user_id": "Användar-ID",
"copy_user_id": "Kopiera användar-ID",
"account_since": "Konto sedan",
"prf_status": "Passkey härledning av nyckel (PRF)",
"prf_active": "Aktiv",
"prf_inactive": "Inte konfigurerad",
"passkeys_title": "Passkeys",
"passkeys_desc": "Registrera en separat Passkey på varje enhet. Detta gör att du kan logga in även efter att du bytt plattform.",
"passkeys_empty": "Inga Passkeys hittades.",
"add_passkey_btn": "Lägg till ny Passkey",
"add_passkey_success": "Passkey har lagts till.",
"add_passkey_failed": "Passkey kunde inte läggas till.",
"remove_passkey_btn": "Ta bort Passkey.",
"remove_passkey_last_title": "Senaste Passkey.",
"remove_passkey_last_desc": "Den enda Passkey kan inte tas bort utan att du förlorar åtkomsten till ditt konto. Om du vill radera kontot helt använder du riskzonen längst ner på den här sidan.",
"remove_passkey_failed": "Passkey kunde inte tas bort.",
"remove_passkey_confirm_title": "Ta bort Passkey?",
"remove_passkey_confirm_desc": "Denna enhet kan sedan inte längre logga in med denna Passkey.",
"remove_passkey_confirm_yes": "Ta bort",
"remove_passkey_confirm_no": "Avbryt",
"pin_title": "Lokal PIN-kod",
"pin_status": "Status",
"pin_active": "Aktiv på den här enheten",
"pin_inactive": "Inte konfigurerad",
"pin_confirm_label": "Bekräfta PIN-kod",
"pin_confirm_placeholder": "Ange PIN-koden igen",
"pin_set_btn": "Ange PIN-kod",
"pin_change_btn": "Ändra PIN-kod",
"pin_remove_btn": "Ta bort PIN-koden",
"pin_saved": "PIN-koden sparad.",
"pin_save_failed": "PIN-koden kunde inte räddas.",
"pin_mismatch": "PIN-koderna stämmer inte överens.",
"pin_length_error": "PIN-koden måste innehålla minst 4 tecken.",
"pin_no_session": "Sessionen har löpt ut - vänligen registrera dig igen.",
"remove_pin_confirm_title": "Ta bort PIN-koden?",
"remove_pin_confirm_desc": "Du måste logga in igen på den här enheten med Passkey eller återställningsnyckel.",
"remove_pin_confirm_yes": "Ta bort PIN-koden",
"remove_pin_confirm_no": "Avbryt",
"security_title": "Checklista för säkerhet",
"security_desc": "Översikt över de viktigaste skyddsmekanismerna för ditt konto.",
"security_passkeys_ok": "Minst en Passkey registrerad",
"security_passkeys_missing": "Nej Passkey registrerad",
"security_prf_ok": "Avledning av PRF-nyckel aktiv",
"security_prf_missing": "PRF inte upprättad",
"security_pin_ok": "Lokal PIN-kod på den här enheten",
"security_pin_missing": "Ingen lokal PIN-kod",
"security_recovery_ok": "Uppsättning av återställningsnyckel",
"security_recovery_hint": "De 12 orden visades under registreringen. Håll dem offline och åtskilda från enheten. Du kan skapa en ny nyckel nedan - den gamla kommer då att bli ogiltig.",
"recovery_rotate_btn": "Skapa en ny återställningsnyckel",
"recovery_rotate_confirm_title": "Skapa en ny återställningsnyckel?",
"recovery_rotate_confirm_desc": "Den tidigare nyckeln på 12 ord blir ogiltig omedelbart. Se till att du förvarar den nya nyckeln säkert innan du fortsätter.",
"recovery_rotate_confirm_yes": "Skapa ny nyckel",
"recovery_rotate_confirm_no": "Avbryt",
"recovery_rotate_new_warning": "VIKTIGT: Skriv ner dessa 12 ord och förvara dem offline. Den tidigare återställningsnyckeln är nu ogiltig.",
"recovery_rotate_failed": "Återställningsnyckel kunde inte skapas.",
"recovery_rotate_no_session": "Krypteringssessionen har löpt ut - logga ut och logga in igen och försök sedan igen.",
"device_title": "Denna enhet",
"device_desc": "Lokal cache, synkroniseringsstatus och snabb inloggning i den här webbläsaren.",
"device_sync_pending": "{{count}} väntande synkroniseringsposter",
"device_sync_ok": "Alla lokala ändringar synkroniseras",
"device_remembered": "Konto för snabb inloggning sparat på den här enheten",
"device_not_remembered": "Kontot finns inte med i listan för snabb inloggning",
"device_forget_btn": "Glömt konto på den här enheten",
"device_forget_confirm_title": "Ta bort snabb inloggning?",
"device_forget_confirm_desc": "Kontot försvinner från snabbinloggningslistan på den här enheten. Din session och dina lokala loggböcker behålls.",
"device_forget_confirm_yes": "Ta bort",
"device_forget_confirm_no": "Avbryt",
"passkey_label": "Namn för ny Passkey (valfritt)",
"passkey_label_placeholder": "z. t.ex. MacBook, iPhone",
"passkey_rename_btn": "Spara namn",
"passkey_rename_success": "Passkey namn sparat.",
"passkey_rename_failed": "Passkey-Namnet kunde inte sparas.",
"passkey_unnamed": "Utan titel Passkey",
"stats_title": "Statistik",
"stats_subtitle": "Om alla dina loggböcker på den här enheten",
"stats_logbooks": "Loggböcker",
"stats_account_since": "Konto sedan",
"stats_shared_logbooks": "Delade loggböcker",
"appearance_title": "App & visualisering",
"appearance_desc": "Designen och färgschemat gäller för hela appen på den här enheten.",
"theme_label": "Appens designstil",
"theme_auto": "Automatisk (OS-detektering)",
"theme_ocean": "Ocean (glasmorfism)",
"theme_material": "Material (Android)",
"theme_cupertino": "Cupertino (iOS)",
"color_scheme_label": "Ljust eller mörkt läge",
"color_scheme_auto": "Automatisk (system)",
"color_scheme_light": "Ljus",
"color_scheme_dark": "Mörk",
"integrations_title": "Integrationer",
"owm_key": "OpenWeatherMap API-nyckel",
"owm_help": "Valfritt: egen OpenWeatherMap API-nyckel. Om inget anges används nyckeln på serversidan från operatörskonfigurationen.",
"prefs_save": "Spara",
"prefs_saving": "Kommer att sparas...",
"prefs_saved": "Sparade",
"tour_title": "App-turné",
"tour_desc": "Låt dig vägledas genom de viktigaste områdena i appen igen.",
"tour_restart": "Starta resan igen",
"push_title": "Push-meddelanden",
"push_desc": "Som loggboksägare får du ett meddelande när inbjudna Crew-medlemmar synkroniserar ändringar. Inget innehåll överförs i klartext.",
"push_enable": "Meddela oss om förändringar i crewen",
"push_active": "Push-meddelanden är aktiva på den här enheten.",
"push_unsupported": "Push-meddelanden stöds inte i den här webbläsaren.",
"push_denied_hint": "Meddelanden är blockerade. Tillåt dem i webbläsarens eller enhetens inställningar.",
"push_ios_install_hint": "På iPhone/iPad: Lägg till app på startskärmen (iOS 16.4+) för att använda push.",
"push_error": "Push-meddelanden kunde inte aktiveras.",
"sections": {
"account": "Konto och inställningar",
"fleet": "Flotta och besättning",
"security": "Säkerhet och enhet",
"stats": "Statistik",
"danger": "Riskzon"
}
},
"vessel_pool": {
"title": "Skipsflotta",
"section_title": "Dina fartyg",
"subtitle": "Underhåll alla fartyg för dina loggböcker här. Välj aktivt fartyg per loggbok från listan.",
"loading": "Laddar fartygsflotta…",
"add_vessel": "Lägg till fartyg",
"edit_vessel": "Redigera fartyg",
"no_vessels": "Inga fartyg i poolen ännu.",
"delete_confirm": "Ta bort detta fartyg från flottan?",
"max_vessels": "Högst 20 fartyg i poolen."
},
"logbook_vessel": {
"title": "Fartyg för denna loggbok",
"subtitle": "Välj fartyg för denna loggbok. Resdagar använder segel- och tankdata från valt fartyg.",
"active_vessel": "Fartyg för denna loggbok",
"no_vessels_in_pool": "Inget fartyg i flottan lägg till i användarprofilen först.",
"no_vessel": "Inget fartyg valt",
"unnamed": "Namnlös",
"save": "Spara fartyg",
"saved": "Loggbok-fartyg sparat.",
"selection_only_hint": "Du ser fartyget ägaren valt (delad loggbok).",
"manage_in_profile": "Hantera fartyg i användarprofilen"
},
"person_pool": {
"title": "Stamm-Crew och skeppare",
"subtitle": "Underhåll din personpool här skeppare och crew för alla loggböcker. Välj aktiv crew per loggbok och resdag från poolen.",
"loading": "Laddar personpool…",
"skippers_section": "Skeppare",
"crew_section": "Stamm-Crew",
"add_skipper": "Lägg till skeppare",
"add_crew": "Lägg till Crew-medlem",
"edit_skipper": "Redigera skeppare",
"no_skippers": "Ingen skeppare i poolen ännu.",
"no_crew": "Inga Crew-medlemmar i poolen ännu.",
"delete_confirm": "Ta bort denna person från poolen?"
},
"logbook_crew": {
"title": "Crew för denna loggbok",
"subtitle": "Välj skeppare och crew för denna loggbok. Nya resdagar ärver valet som standard.",
"loading": "Laddar crew…",
"active_skipper": "Skeppare för denna loggbok",
"active_crew": "Crew för denna loggbok",
"no_skippers_in_pool": "Ingen skeppare i poolen lägg till i användarprofilen först.",
"no_crew_in_pool": "Ingen crew i poolen lägg till i användarprofilen först.",
"no_skipper": "Ingen skeppare vald",
"unnamed": "Namnlös",
"save": "Spara crew",
"saved": "Loggbok-Crew sparad.",
"selection_only_hint": "Du ser den crew ägaren valt (delad loggbok)."
},
"entry_crew": {
"title": "Crew denna resdag",
"subtitle": "Kan skilja sig från loggboksstandard. Följande dagar ärver från föregående dag.",
"day_skipper": "Skeppare denna dag",
"day_crew": "Crew denna dag",
"no_skipper": "Ingen skeppare vald",
"no_crew": "Ingen crew vald"
},
"crew": {
"title": "Profiler för skeppare och crew",
"skipper_section": "Skepparens profil",
"skipper_read_only_hint": "Skepparens profil kan endast redigeras av loggbokens ägare.",
"crew_section": "Crew-lista",
"add_crew": "Lägg till Crew-medlem",
"edit_crew": "Redigera Crew-medlem",
"no_crew": "Inga Crew-medlemmar har lagts till ännu.",
"max_crew": "Maximalt antal på 12 Crew-medlemmar i poolen uppnått.",
"name": "Namn",
"address": "adress",
"birthdate": "Födelsedag",
"phone": "Telefonnummer",
"nationality": "Nationalitet",
"passport": "Pass/ID-nummer",
"bloodtype": "Blodgrupp",
"allergies": "Allergier",
"diseases": "Redan existerande tillstånd/sjukdomar",
"save": "Spara skeppardata",
"save_member": "Spara medlem",
"saved": "Skepparens profil har sparats!",
"loading": "Crew-filerna är laddade...",
"delete_confirm": "Är du säker på att du vill ta bort den här Crew-medlemmen?"
},
"deviation": {
"title": "Tabell för kompassavvikelse",
"subtitle": "Ange den magnetiska kompassdeflektionen (deflektion) för kurser (MgK) från 000° till 360° i steg om 10°.",
"heading": "MgK",
"deviation": "Distraktion",
"save": "Spara kalibreringsrutan",
"saving": "Kommer att sparas...",
"saved": "Kalibreringsnätet har sparats framgångsrikt!",
"loading": "Kalibreringsbordet är laddat..."
},
"settings": {
"title": "Inställningar för loggbok",
"subtitle": "Dela, säkerhetskopiera och samarbeta för den här loggboken.",
"select_logbook_hint": "Välj en loggbok för att redigera dess inställningar.",
"no_key": "Ingen OpenWeatherMap API-nyckel tillgänglig. Spara din egen nyckel i användarprofilen eller kontakta operatören.",
"weather_success": "Väderdata har hämtats framgångsrikt!",
"weather_error": "Hämtning av väderdata misslyckades. Kontrollera API-nyckeln och anslutningen.",
"weather_date_mismatch": "Väderdata kan endast hämtas för idag ({{today}}). Denna loggbokspost är daterad {{date}}.",
"gps_error": "Ange en plats eller bestäm GPS-koordinaterna.",
"share_title": "Aktieloggbok (skrivskyddad)",
"share_desc": "Aktivera det här alternativet för att skapa en publik, skrivskyddad länk. Alla som har länken kan se dina resor, båtprofiler och crew. Krypteringsnycklarna överförs aldrig till servern (de finns kvar i hashdelen av URL:en).",
"share_privacy_warning": "Rekommendation: Dela endast den här länken privat (t.ex. via e-post eller messenger), inte på sociala medier.",
"share_enable": "Aktivera offentlig länk",
"share_copied": "Länk kopierad!",
"share_copy_btn": "Kopiera länk",
"link_qr_hint": "Skanna QR-koden med mobilen",
"link_qr_alt": "QR-kod för länken",
"danger_zone_title": "Farlig zon",
"danger_zone_desc": "Om du raderar ditt konto raderas oåterkalleligen alla dina Passkey, loggböcker, fartygsdata, Crew-profiler, reseanteckningar och E2E-nycklar. Denna åtgärd kan inte ångras.",
"delete_account_btn": "Ta bort konto oåterkalleligt",
"delete_account_confirm_title": "Radera konto?",
"delete_account_confirm_desc": "Är du helt säker på att du oåterkalleligen vill radera ditt konto och alla tillhörande loggböcker och E2E-krypterade data?",
"delete_account_confirm_yes": "Ja, radera konto och all data",
"delete_account_confirm_no": "Avbryt",
"delete_account_failed": "Kontot kunde inte raderas. Vänligen försök igen.",
"delete_backup_hint": "Tips: Skapa säkerhetskopior av dina loggböcker (.daagbok.json) i inställningarna för varje loggbok innan du raderar dem.",
"deleting_account": "Kontot kommer att raderas...",
"invite_push_prompt_title": "Aktivera push-meddelanden?",
"invite_push_prompt_message": "Så snart inbjudna Crew-medlemmar synkroniserar ändringar kan du bli informerad via push. Inget loggboksinnehåll skickas i klartext.",
"invite_push_prompt_ios_message": "Så snart Crew-medlemmar synkroniserar ändringar kan du bli informerad via push. På iPhone/iPad: Lägg till appen på startskärmen (iOS 16.4+) och aktivera sedan push i användarprofilen.",
"invite_push_prompt_enable": "Aktivera nu",
"invite_push_prompt_later": "Senare",
"invite_push_prompt_success": "Push-meddelanden är aktiva på den här enheten.",
"backup_title": "Säkerhetskopiering och återställning",
"backup_desc": "Komplett krypterad säkerhetskopia av denna loggbok (poster, foton, GPS-spår, crew, fartyg). Skyddad med lösenfras för säkerhetskopian - för återställning till detta eller ett nytt konto.",
"backup_export_title": "Skapa säkerhetskopia",
"backup_export_desc": "Laddar ner alla lokala data som .daagbok.json. Förvara filen och lösenfrasen separat och säkert.",
"backup_restore_title": "Återställ säkerhetskopian",
"backup_restore_desc": "Återställer en säkerhetskopia till ditt nuvarande konto - även efter att du har registrerat ett nytt konto.",
"backup_passphrase": "Lösenord för säkerhetskopiering",
"backup_passphrase_placeholder": "Minst 8 tecken",
"backup_passphrase_confirm": "Bekräfta lösenfras",
"backup_passphrase_short": "Säkerhetskopians lösenfras måste vara minst 8 tecken lång.",
"backup_passphrase_mismatch": "Lösenfraserna stämmer inte överens.",
"backup_wrong_passphrase": "Lösenordet är felaktigt eller säkerhetskopian är skadad.",
"backup_export_btn": "Ladda ner backup",
"backup_exporting": "Säkerhetskopian skapas...",
"backup_export_success": "Säkerhetskopia skapad ({{count}} resdagar).",
"backup_file_label": "Säkerhetskopieringsfil (.daagbok.json)",
"backup_preview_btn": "Kontrollera innehåll",
"backup_previewing": "Check...",
"backup_restore_btn": "Återställ",
"backup_restoring": "Kommer att återställas...",
"backup_restore_success": "Loggbok \"{{title}}\" har återställts.",
"backup_restore_cancelled": "Återhämtning avbruten.",
"backup_invalid_json": "Filen är inte en giltig JSON-fil.",
"backup_invalid_format": "Okänt eller föråldrat backupformat.",
"backup_not_owner": "Endast loggbokens ägare kan skapa säkerhetskopior.",
"backup_not_authenticated": "Logga in för att återställa en säkerhetskopia.",
"backup_id_conflict": "En loggbok med detta ID finns redan.",
"backup_overwrite_confirm": "Den befintliga loggboken med samma ID ersätts. Fortsätter du?",
"backup_new_id_confirm": "Importera säkerhetskopian som en ny loggbok med ett nytt ID?",
"backup_stat_entries": "{{count}} Resdagar",
"backup_stat_photos": "{{count}} Foton",
"backup_stat_crew": "{{count}} Crew-poster",
"backup_stat_tracks": "{{count}} GPS-spår",
"backup_exported_at": "Exporterad: {{date}}"
},
"disclaimer": {
"title": "Viktiga anmärkningar",
"intro": "Läs följande anvisningar innan du använder Kapteins Daagbok.",
"e2e_title": "End-to-end-kryptering",
"e2e_body": "Dina loggboksdata är krypterade från början till slut. Endast du - eller personer med din nyckel - kan läsa innehållet. Endast krypterade data lagras på servern.",
"pwa_title": "Progressiv webbapplikation (PWA)",
"pwa_body": "Kapteins Daagbok körs som en progressiv webbapp i din webbläsare och kan installeras på din enhet - på samma sätt som en native-app, utan en appbutik.",
"storage_title": "Lokal lagring och synkronisering",
"storage_body": "Dina data lagras lokalt på din enhet (IndexedDB). Ändringar synkroniseras med servern när en internetanslutning är aktiv. Du kan fortsätta att arbeta utan anslutning, synkroniseringen sker senare.",
"free_title": "Kostnadsfritt och reklamfritt",
"free_body": "Kapteins Daagbok är kostnadsfritt och innehåller ingen reklam.",
"liability_title": "Ansvarsfriskrivning",
"liability_body": "Användningen av appen sker på egen risk. Inget ansvar accepteras för skador som uppstår till följd av användningen av appen - inklusive felaktiga eller ofullständiga loggboksanteckningar, förlust av data eller tekniska fel.",
"warranty_title": "Ingen garanti",
"warranty_body": "Ingen garanti ges för tjänstens funktion, korrekthet eller tillgänglighet. Driften kan när som helst avbrytas, begränsas eller ställas in.",
"copyright": "© 2026 KnorrLabs, Markus F.J. Busche",
"accept": "Acceptera och fortsätt",
"close": "Nära",
"button_title": "Anmärkningar och ansvarsfriskrivning"
},
"feedback": {
"button_title": "Skicka feedback",
"title": "Återkoppling",
"intro": "Dela med dig av buggar, idéer eller allmän feedback. Ditt meddelande kommer att skickas till projektgruppen via en säker meddelandekanal.",
"category_label": "Kategori",
"category_general": "Allmänt",
"category_bug": "Rapportera fel",
"category_feature": "Begäran om funktion",
"category_translation": "Översättningsfel",
"contact_label": "E-post (valfritt)",
"contact_placeholder": "deine@email.beispiel",
"message_label": "Meddelande",
"message_placeholder": "Beskriv din feedback...",
"send": "Skicka",
"sending": "Kommer att skickas...",
"cancel": "Avbryt",
"success": "Tack så mycket! Din feedback har skickats.",
"error_send": "Feedback kunde inte skickas. Vänligen försök igen senare.",
"error_invalid_email": "Vänligen ange en giltig e-postadress.",
"error_not_configured": "Feedback är inte tillgängligt på den här servern.",
"error_rate_limited": "För många feedbackmeddelanden på kort tid. Vänligen vänta några minuter.",
"error_spam": "Det här meddelandet kunde inte skickas. Vänligen omformulera det."
},
"demo": {
"logbook_title": "Demo loggbok Östersjön",
"badge": "Demo",
"public_banner": "Skrivskyddad demovy",
"cta_register": "Skapa konto",
"back_to_login": "Till registreringen"
},
"invitation": {
"error_invalid_key": "Länken till inbjudan är kryptografiskt ogiltig (nyckeln är felaktig).",
"error_missing_key": "Länken till inbjudan innehåller ingen dekrypteringsnyckel (#key=...). Vänligen använd den fullständiga länken från ägaren.",
"error_expired": "Denna inbjudan har löpt ut (giltig i 48 timmar).",
"error_invalid_token": "Inbjudan ogiltig.",
"error_load_failed": "Inbjudan kunde inte läsas in.",
"error_incomplete_session": "Sessionen är ofullständig - logga in igen (användar-ID saknas).",
"error_accept_failed": "Anslutningen misslyckades.",
"error_login_failed": "Passkey Inloggningen misslyckades.",
"error_username_missing": "Användarnamnet kunde inte fastställas - vänligen logga in igen.",
"error_register_failed": "Registreringen misslyckades.",
"loading_joining": "Ansluter sig...",
"loading_checking": "Inbjudan kommer att kontrolleras...",
"loading_unlocking": "Loggboken är upplåst och synkroniserad...",
"loading_retrieving_key": "Ladda ner krypteringsnyckel...",
"error_title": "Fel i inbjudan",
"back_to_start": "Tillbaka till början",
"title": "Inbjudan till loggbok",
"invited_by": "Inbjudan från",
"vessel_logbook": "Fartyg / Loggbok",
"signed_in_preparing": "Registrerad som {{username}}. Anslutning förbereds...",
"join_again": "Gå med igen",
"login_or_register_hint": "Logga in eller registrera ett konto för att gå med i loggboken.",
"or_sign_up": "ELLER REGISTRERA DIG IGEN",
"register_crew_account": "Skapa ett nytt konto för crewen",
"username_label": "Användarens namn",
"create_passkey": "Skapa Passkey.",
"switch_language_en": "Engelska",
"switch_language_de": "Tysk"
},
"stats": {
"title": "Statistik",
"subtitle": "Översikt över rutter, förbrukning och typ av körning",
"scope_label": "Utvärderingsområde",
"scope_logbook": "Denna loggbok",
"scope_account": "Alla loggböcker",
"loading": "Statistiken är beräknad...",
"no_data": "Inga resdagar tillgängliga ännu.",
"total_distance": "Totalt avstånd",
"travel_days": "Resdagar",
"sail_distance": "Under segel",
"motor_distance": "Maskinens resa",
"motor_hours_total": "Totalt antal maskintimmar",
"daily_motor_hours": "Maskintimmar per resdag",
"avg_motor_hours": "Ø maskintimmar per resdag",
"unknown_propulsion": "Okänd",
"fuel_total": "Totalt bränsle",
"water_total": "Totalt vatten",
"daily_etmal": "Dagliga tider",
"daily_consumption": "Daglig konsumtion",
"route_overview": "Vägbeskrivning",
"route_map_title": "Översikt över rutten",
"propulsion_title": "Segel vs. maskin",
"propulsion_hint": "Fördelningen baseras på loggbokshändelser per resdag, inte på GPS-segment.",
"avg_distance": "Ø per resdag",
"avg_fuel": "Ø Bränsle",
"avg_water": "Ø Vatten",
"fuel_per_nm": "Bränsle per sm",
"fuel_per_motor_hour": "Bränsle per maskintimme",
"daily_fuel_per_motor_hour": "Bränsleförbrukning per maskintimme och resdag",
"fuel_legend": "Bränsle",
"water_legend": "Vatten",
"unit_nm": "sm",
"unit_h": "h",
"unit_l": "L",
"day_label": "Dag {{day}}__.",
"account_logbooks": "Loggböcker i en överblick",
"col_logbook": "Loggbok",
"event_series_title": "Händelseförlopp",
"event_series_hint": "Kronologiska värden från händelseloggen.",
"event_series_pressure": "Lufttryck",
"event_series_wind": "Vind",
"event_series_motor": "Motor",
"event_series_empty": "Inga poster ännu."
},
"tour": {
"skip": "Hoppa över turen",
"back": "Tillbaka",
"next": "Ytterligare",
"finish": "Färdig",
"progress": "Steg {{current}} från {{total}}.",
"steps": {
"welcome": {
"title": "Välkommen ombord!",
"body": "Vi har skapat en demo-loggbok med tre dagars resa i Kielfjorden åt dig. Du kan när som helst radera exempelposterna om du vill starta din egen loggbok. Den här korta rundturen visar dig de viktigaste funktionerna."
},
"welcome_public": {
"title": "Välkommen ombord!",
"body": "Utforska vår demologgbok med tre dagars resor i Kielfjorden utan konto. Rundturen visar loggboksanteckningar samt val av fartyg och besättning för denna loggbok. Flotta och stamm-besättning hanterar du senare i användarprofilen."
},
"nav_logs": {
"title": "Loggboksanteckningar",
"body": "Det är här du hanterar dina resdagar - avresa, destination, väder, bränslenivåer och GPS-spår."
},
"entry_list": {
"title": "Dina resdagar",
"body": "Varje kort representerar en resdag. Tryck på en post för att visa eller redigera detaljer."
},
"entry_open": {
"title": "Öppen resdag",
"body": "Så här ser en komplett loggboksanteckning ut - med händelser, tanknivåer och mycket mer."
},
"entry_track": {
"title": "GPS-spårning",
"body": "Ladda upp GPX-filer eller visa redan sparade rutter på kartan - inklusive avstånd och hastighet."
},
"nav_vessel": {
"title": "Fartyg för loggbok",
"body": "Välj fartyg från flottan för denna loggbok. Hantera fartyg i användarprofilen under Flotta och besättning."
},
"profile_vessel_pool": {
"title": "Fartygsflotta",
"body": "I användarprofilen lägger du in alla fartyg charter, egen båt m.m. Välj sedan rätt fartyg per loggbok."
},
"profile_crew_pool": {
"title": "Stamm-Crew och skeppare",
"body": "I användarprofilen underhåller du en personpool flera skeppare (t.ex. charter) och crew för alla loggböcker."
},
"nav_logbook_crew": {
"title": "Crew per loggbok",
"body": "Välj skeppare och crew från poolen för denna loggbok. Resdagar ärver valet som standard."
},
"nav_stats": {
"title": "Kontrollpanel för statistik",
"body": "Här kan du se körsträckor, bränsleförbrukning, ruttkartor och körandelar - automatiskt beräknade från dina loggboksanteckningar."
},
"nav_feedback": {
"title": "Skicka feedback",
"body": "Du kan använda det här formuläret för att skicka fel, idéer eller allmän feedback direkt till projektgruppen - även efter rundturen när som helst med hjälp av ikonen längst upp till höger."
},
"nav_profile": {
"title": "Din användarprofil",
"body": "Du kommer åt din personliga profil via skipperknappen högst upp - oavsett vilken loggbok som är aktuell."
},
"profile_preferences": {
"title": "Redovisning & presentation",
"body": "Här kan du hantera din konto-identitet, ditt tema och ljus/mörker-läge. Du kan när som helst starta om appturen. Passkeys och säkerhetsinställningar hittar du längre ner i profilen."
},
"finish": {
"title": "Okej!",
"body": "Du kommer direkt till instrumentpanelen för statistik. Du kan när som helst starta om turen i din användarprofil. Ha en trevlig resa!"
}
}
},
"seo": {
"title": "Kapteins Daagbok - Gratis digital loggbok för båtar (reklamfri)",
"description": "Gratis, annonsfri digital loggbok för båtar med kryptering från början till slut och Passkey-inloggning. Dokumentera resdagar, GPS-spår, Crew- och fartygsdata på ett säkert sätt - även offline som PWA.",
"keywords": "Yachtloggbok, skeppsdagbok, ombordloggbok, segling, Passkey, E2E kryptering, GPS-spår, sjöfartsloggbok, gratis, reklamfri, gratis, utan reklam",
"ogImageAlt": "Kapteins Daagbok Logotyp"
}
}
}
+11 -102
View File
@@ -1,64 +1,8 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
/* Minimal app shell — component styles live in App.css / themes.css */
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
*,
*::before,
*::after {
box-sizing: border-box;
}
@@ -66,46 +10,11 @@ body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
#root {
width: 100%;
max-width: 100%;
min-height: 100svh;
display: flex;
flex-direction: column;
align-items: center;
}
+86 -7
View File
@@ -3,14 +3,93 @@ import { createRoot } from 'react-dom/client'
import 'leaflet/dist/leaflet.css'
import './themes.css'
import './index.css'
import App from './App.tsx'
import './App.css'
import './i18n'
import App from './App.tsx'
import { applyAppearanceToDocument } from './services/appearance.ts'
import { flushPendingPwaBootEvents } from './services/analytics.ts'
import {
installStaleAssetRecovery,
markReloadAttempt,
reconcileVersionOnStartup
} from './services/pwaStartup.ts'
import { redirectToPasskeyCompatibleHostIfNeeded } from './utils/passkeyHost.ts'
applyAppearanceToDocument()
declare global {
interface Window {
__KDB_MAIN_MODULE_LOADED?: boolean
__KDB_APP_BOOTSTRAPPED?: boolean
}
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
window.__KDB_MAIN_MODULE_LOADED = true
/** Stale PWA precache on localhost can shadow Vite dev modules. */
async function clearDevServiceWorkerCaches(): Promise<void> {
if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return
const regs = await navigator.serviceWorker.getRegistrations()
await Promise.all(regs.map((r) => r.unregister()))
if ('caches' in window) {
const keys = await caches.keys()
await Promise.all(keys.map((k) => caches.delete(k)))
}
}
function renderBootstrapError(message: string): void {
const root = document.getElementById('root')
if (!root) return
root.innerHTML = `
<div class="auth-screen">
<div class="auth-card glass" role="alert" style="max-width:420px">
<h2 style="margin-top:0">Kapteins Daagbok</h2>
<p style="color:var(--app-text-muted);line-height:1.5">${message}</p>
<button type="button" class="btn primary" style="width:100%;margin-top:16px" onclick="location.reload()">
Neu laden
</button>
</div>
</div>`
}
async function bootstrap(): Promise<void> {
if (redirectToPasskeyCompatibleHostIfNeeded()) {
return
}
applyAppearanceToDocument()
installStaleAssetRecovery()
flushPendingPwaBootEvents()
window.addEventListener('load', () => {
flushPendingPwaBootEvents()
}, { once: true })
await clearDevServiceWorkerCaches()
const startupResult = await reconcileVersionOnStartup()
if (startupResult === 'reload') {
markReloadAttempt()
window.location.reload()
return
}
if (startupResult === 'recovered') {
return
}
const rootEl = document.getElementById('root')
if (!rootEl) {
throw new Error('Missing #root element')
}
createRoot(rootEl).render(
<StrictMode>
<App />
</StrictMode>,
)
window.__KDB_APP_BOOTSTRAPPED = true
}
void bootstrap().catch((err) => {
console.error('App bootstrap failed:', err)
renderBootstrapError(
'Die App konnte nicht gestartet werden. Bitte neu laden oder die App vollständig beenden und erneut öffnen.',
)
window.__KDB_APP_BOOTSTRAPPED = false
})
+85 -1
View File
@@ -14,18 +14,53 @@ export const PlausibleEvents = {
ONBOARDING_TOUR_SKIPPED: 'Onboarding Tour Skipped',
INVITE_GENERATED: 'Invite Generated',
INVITE_ACCEPTED: 'Invite Accepted',
LOGBOOK_SHARED: 'Logbook Shared',
PUBLIC_LINK_OPENED: 'Public Link Opened',
PDF_EXPORTED: 'PDF Exported',
CSV_EXPORTED: 'CSV Exported',
CSV_SHARED: 'CSV Shared',
PHOTO_UPLOADED: 'Photo Uploaded',
BACKUP_EXPORTED: 'Backup Exported',
BACKUP_RESTORED: 'Backup Restored',
DEMO_OPENED: 'Demo Opened'
DEMO_OPENED: 'Demo Opened',
PUSH_ENABLED: 'Push Enabled',
PUSH_DISABLED: 'Push Disabled',
FOOTER_LINK_CLICKED: 'Footer Link Clicked',
PROFILE_OPENED: 'Profile Opened',
PASSKEY_ADDED: 'Passkey Added',
PASSKEY_REMOVED: 'Passkey Removed',
PASSKEY_RENAMED: 'Passkey Renamed',
LAST_PASSKEY_REMOVE_HINTED: 'Last Passkey Remove Hinted',
LOCAL_PIN_SET: 'Local PIN Set',
LOCAL_PIN_REMOVED: 'Local PIN Removed',
DEVICE_FORGOTTEN: 'Device Forgotten',
RECOVERY_ROTATED: 'Recovery Rotated',
LANGUAGE_CHANGED: 'Language Changed',
NMEA_IMPORTED: 'NMEA Imported',
NMEA_UPLOADED: 'NMEA Uploaded',
LIVE_LOG_OPENED: 'Live Log Opened',
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
LIVE_LOG_PHOTO_UPLOADED: 'Live Log Photo Uploaded',
OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
PWA_BOOT_WATCHDOG_HARD: 'PWA Boot Watchdog Hard',
PWA_BOOT_WATCHDOG_FALLBACK: 'PWA Boot Watchdog Fallback',
PWA_BOOT_WATCHDOG_MANUAL_REPAIR: 'PWA Boot Watchdog Manual Repair'
} as const
/** Where a successful OpenWeatherMap API call originated (no coordinates or place names). */
export type OwmAnalyticsSource = 'live_log' | 'entry_editor' | 'entry_editor_gps_lookup'
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
export type PlausibleEventProps = Record<string, string | number | boolean>
type PendingPwaBootEvent = {
name: PlausibleEventName
props?: PlausibleEventProps
ts?: number
}
const PWA_BOOT_PENDING_EVENTS_KEY = 'pwa_boot_pending_events'
export function trackPlausibleEvent(name: PlausibleEventName, props?: PlausibleEventProps): void {
if (typeof window.plausible !== 'function') return
@@ -35,3 +70,52 @@ export function trackPlausibleEvent(name: PlausibleEventName, props?: PlausibleE
}
window.plausible(name)
}
export function flushPendingPwaBootEvents(): number {
if (typeof window.plausible !== 'function') return 0
let raw: string | null = null
try {
raw = sessionStorage.getItem(PWA_BOOT_PENDING_EVENTS_KEY)
} catch {
return 0
}
if (!raw) return 0
let pending: PendingPwaBootEvent[]
try {
pending = JSON.parse(raw) as PendingPwaBootEvent[]
} catch {
try {
sessionStorage.removeItem(PWA_BOOT_PENDING_EVENTS_KEY)
} catch {
/* ignore storage errors */
}
return 0
}
if (!Array.isArray(pending) || pending.length === 0) {
try {
sessionStorage.removeItem(PWA_BOOT_PENDING_EVENTS_KEY)
} catch {
/* ignore storage errors */
}
return 0
}
for (const event of pending) {
if (!event || typeof event.name !== 'string') continue
if (event.props && Object.keys(event.props).length > 0) {
window.plausible(event.name, { props: event.props })
} else {
window.plausible(event.name)
}
}
try {
sessionStorage.removeItem(PWA_BOOT_PENDING_EVENTS_KEY)
} catch {
/* ignore storage errors */
}
return pending.length
}
+59
View File
@@ -0,0 +1,59 @@
export class ApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
export async function apiFetch(
input: string,
init: RequestInit = {},
timeoutMs = 15000
): Promise<Response> {
const headers = new Headers(init.headers)
if (init.body !== undefined && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
if (init.signal) {
if (init.signal.aborted) {
controller.abort()
} else {
init.signal.addEventListener('abort', () => controller.abort())
}
}
try {
return await fetch(input, {
...init,
headers,
credentials: 'include',
signal: controller.signal
})
} finally {
clearTimeout(timeoutId)
}
}
export async function apiJson<T>(
input: string,
init: RequestInit = {},
timeoutMs = 15000
): Promise<T> {
const res = await apiFetch(input, init, timeoutMs)
const data = await res.json().catch(() => ({}))
if (!res.ok) {
const message =
typeof data === 'object' && data && 'error' in data && typeof data.error === 'string'
? data.error
: `Request failed (${res.status})`
throw new ApiError(message, res.status)
}
return data as T
}
+70
View File
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
applyAppearanceToDocument,
resolveAppTheme,
resolveColorScheme,
type AppTheme,
type ResolvedColorScheme
} from './appearance.js'
import { setColorSchemePreference } from './userPreferences.js'
const USER_ID = 'appearance-test-user'
const COMBOS: Array<{ theme: AppTheme; scheme: ResolvedColorScheme }> = [
{ theme: 'ocean', scheme: 'dark' },
{ theme: 'ocean', scheme: 'light' },
{ theme: 'material', scheme: 'dark' },
{ theme: 'material', scheme: 'light' },
{ theme: 'cupertino', scheme: 'dark' },
{ theme: 'cupertino', scheme: 'light' }
]
describe('appearance', () => {
beforeEach(() => {
localStorage.clear()
document.documentElement.className = ''
document.documentElement.style.colorScheme = ''
document.head.querySelector('meta[name="theme-color"]')?.remove()
})
it.each(COMBOS)('applies $theme · $scheme classes to document', ({ theme, scheme }) => {
applyAppearanceToDocument(theme, scheme)
const root = document.documentElement
expect(root.classList.contains(`theme-${theme}`)).toBe(true)
expect(root.classList.contains(`scheme-${scheme}`)).toBe(true)
expect(root.style.colorScheme).toBe(scheme)
})
it('replaces previous theme classes when switching appearance', () => {
applyAppearanceToDocument('ocean', 'dark')
applyAppearanceToDocument('material', 'light')
const root = document.documentElement
expect(root.classList.contains('theme-material')).toBe(true)
expect(root.classList.contains('theme-ocean')).toBe(false)
expect(root.classList.contains('scheme-light')).toBe(true)
expect(root.classList.contains('scheme-dark')).toBe(false)
})
it('resolves stored light scheme even when system prefers dark', () => {
vi.stubGlobal(
'matchMedia',
vi.fn().mockReturnValue({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })
)
localStorage.setItem('active_userid', USER_ID)
setColorSchemePreference(USER_ID, 'light')
expect(resolveColorScheme()).toBe('light')
applyAppearanceToDocument('material', resolveColorScheme())
expect(document.documentElement.classList.contains('scheme-light')).toBe(true)
})
it('auto theme picks material on Android user agent', () => {
vi.stubGlobal('navigator', {
...navigator,
userAgent: 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36'
})
expect(resolveAppTheme()).toBe('material')
})
})
+17 -2
View File
@@ -1,3 +1,5 @@
import { getColorSchemePreference as getStoredColorScheme, getThemePreference } from './userPreferences.js'
export type ColorSchemePreference = 'auto' | 'light' | 'dark'
export type ResolvedColorScheme = 'light' | 'dark'
export type AppTheme = 'ocean' | 'material' | 'cupertino'
@@ -6,7 +8,7 @@ const THEME_CLASSES = ['theme-ocean', 'theme-material', 'theme-cupertino'] as co
const SCHEME_CLASSES = ['scheme-light', 'scheme-dark'] as const
export function getColorSchemePreference(): ColorSchemePreference {
const stored = localStorage.getItem('active_color_scheme')
const stored = getStoredColorScheme()
if (stored === 'light' || stored === 'dark' || stored === 'auto') return stored
return 'auto'
}
@@ -19,7 +21,7 @@ export function resolveColorScheme(pref?: ColorSchemePreference): ResolvedColorS
}
export function resolveAppTheme(): AppTheme {
const configTheme = localStorage.getItem('active_theme') || 'auto'
const configTheme = getThemePreference() || 'auto'
if (configTheme === 'material' || configTheme === 'cupertino' || configTheme === 'ocean') {
return configTheme
}
@@ -29,6 +31,18 @@ export function resolveAppTheme(): AppTheme {
return 'ocean'
}
function updateThemeColorMeta(root: HTMLElement): void {
const color = getComputedStyle(root).getPropertyValue('--app-theme-color').trim()
if (!color) return
let meta = document.querySelector('meta[name="theme-color"]')
if (!meta) {
meta = document.createElement('meta')
meta.setAttribute('name', 'theme-color')
document.head.appendChild(meta)
}
meta.setAttribute('content', color)
}
export function applyAppearanceToDocument(
theme: AppTheme = resolveAppTheme(),
scheme: ResolvedColorScheme = resolveColorScheme()
@@ -37,6 +51,7 @@ export function applyAppearanceToDocument(
root.classList.remove(...THEME_CLASSES, ...SCHEME_CLASSES)
root.classList.add(`theme-${theme}`, `scheme-${scheme}`)
root.style.colorScheme = scheme
updateThemeColorMeta(root)
}
export function subscribeToSystemColorScheme(onChange: () => void): () => void {
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
fetchAppearancePrefs,
saveAppearancePrefsToServer,
syncAppearancePrefs
} from './appearancePrefs.js'
import { setThemePreference } from './userPreferences.js'
const USER_ID = 'appearance-sync-user'
vi.mock('./api.js', () => ({
apiJson: vi.fn()
}))
import { apiJson } from './api.js'
const mockedApiJson = vi.mocked(apiJson)
describe('appearancePrefs', () => {
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
})
it('fetchAppearancePrefs returns defaults when not authenticated', async () => {
await expect(fetchAppearancePrefs()).resolves.toEqual({
theme: 'auto',
colorScheme: 'auto',
persisted: false
})
expect(mockedApiJson).not.toHaveBeenCalled()
})
it('syncAppearancePrefs applies server prefs after cache wipe', async () => {
localStorage.setItem('active_userid', USER_ID)
mockedApiJson.mockResolvedValueOnce({
theme: 'ocean',
colorScheme: 'dark',
persisted: true
})
const changed = vi.fn()
window.addEventListener('appearance-changed', changed)
await syncAppearancePrefs(USER_ID)
expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean')
expect(localStorage.getItem(`user_pref_color_scheme_${USER_ID}`)).toBe('dark')
expect(changed).toHaveBeenCalledTimes(1)
})
it('syncAppearancePrefs uploads local prefs when server has none', async () => {
localStorage.setItem('active_userid', USER_ID)
setThemePreference(USER_ID, 'material')
mockedApiJson
.mockResolvedValueOnce({ theme: 'auto', colorScheme: 'auto', persisted: false })
.mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', persisted: true })
await syncAppearancePrefs(USER_ID)
expect(mockedApiJson).toHaveBeenCalledTimes(2)
expect(mockedApiJson).toHaveBeenLastCalledWith('/api/auth/appearance-prefs', {
method: 'PUT',
body: JSON.stringify({ theme: 'material', colorScheme: 'auto' })
})
})
it('saveAppearancePrefsToServer skips when not authenticated', async () => {
await saveAppearancePrefsToServer('ocean', 'light')
expect(mockedApiJson).not.toHaveBeenCalled()
})
it('syncAppearancePrefs skips server sync when userId does not match active session', async () => {
localStorage.setItem('active_userid', 'session-user')
setThemePreference('other-user', 'ocean')
mockedApiJson.mockResolvedValue({
theme: 'material',
colorScheme: 'dark',
persisted: true
})
await syncAppearancePrefs('other-user')
expect(mockedApiJson).not.toHaveBeenCalled()
expect(localStorage.getItem('user_pref_theme_other-user')).toBe('ocean')
})
it('syncAppearancePrefs skips server sync when active session is missing', async () => {
setThemePreference(USER_ID, 'ocean')
await syncAppearancePrefs(USER_ID)
expect(mockedApiJson).not.toHaveBeenCalled()
expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean')
})
})
+76
View File
@@ -0,0 +1,76 @@
import { apiJson } from './api.js'
import { notifyAppearanceChanged } from './appearance.js'
import {
getActiveUserId,
getColorSchemePreference,
getThemePreference,
setColorSchemePreference,
setThemePreference
} from './userPreferences.js'
const API_BASE = '/api/auth/appearance-prefs'
export interface AppearancePrefs {
theme: string
colorScheme: string
persisted: boolean
}
function hasLocalAppearancePrefs(userId: string): boolean {
return (
localStorage.getItem(`user_pref_theme_${userId}`) != null ||
localStorage.getItem(`user_pref_color_scheme_${userId}`) != null
)
}
function resolveSyncedUserId(userId?: string | null): string | null {
const id = userId?.trim() || getActiveUserId()?.trim() || null
if (!id) return null
const activeId = getActiveUserId()?.trim() || null
if (!activeId || activeId !== id) return null
return id
}
export async function fetchAppearancePrefs(userId?: string | null): Promise<AppearancePrefs> {
if (!resolveSyncedUserId(userId)) {
return { theme: 'auto', colorScheme: 'auto', persisted: false }
}
return apiJson<AppearancePrefs>(API_BASE)
}
export async function saveAppearancePrefsToServer(
theme: string,
colorScheme: string,
userId?: string | null
): Promise<void> {
if (!resolveSyncedUserId(userId)) return
await apiJson<AppearancePrefs>(API_BASE, {
method: 'PUT',
body: JSON.stringify({ theme, colorScheme })
})
}
/** Merge server-stored appearance with local cache (server wins after cache wipe). */
export async function syncAppearancePrefs(userId?: string | null): Promise<void> {
const id = resolveSyncedUserId(userId)
if (!id) return
try {
const server = await fetchAppearancePrefs(id)
if (server.persisted) {
setThemePreference(id, server.theme)
setColorSchemePreference(id, server.colorScheme)
} else if (hasLocalAppearancePrefs(id)) {
await saveAppearancePrefsToServer(getThemePreference(id), getColorSchemePreference(id), id)
}
} catch (err) {
console.warn('Failed to sync appearance preferences:', err)
}
notifyAppearanceChanged()
}
+232 -77
View File
@@ -6,27 +6,23 @@ import {
deriveKeyFromPin,
encryptBuffer,
decryptBuffer,
generateRecoveryPhrase,
base64ToBuffer,
bufferToBase64
generateRecoveryPhrase
} from './crypto.js'
import { clearLogbookKeysCache } from './logbookKeys.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
import { db } from './db.js'
import { apiFetch, apiJson } from './api.js'
import { isWebAuthnUserAbortError } from '../utils/passkeyHost.js'
const API_BASE = '/api/auth'
// Shared in-memory container for the active user's session master key
// Master key lives in memory only (never localStorage — XSS-resistant).
let activeMasterKey: ArrayBuffer | null = null
// Restore key from localStorage on load if present (survives reload/restart)
try {
const savedKey = localStorage.getItem('active_master_key')
if (savedKey) {
activeMasterKey = base64ToBuffer(savedKey)
}
} catch (e) {
console.error('Failed to restore active master key:', e)
localStorage.removeItem('active_master_key')
} catch {
/* ignore */
}
export function getActiveMasterKey(): ArrayBuffer | null {
@@ -35,17 +31,57 @@ export function getActiveMasterKey(): ArrayBuffer | null {
export function setActiveMasterKey(key: ArrayBuffer | null) {
activeMasterKey = key
if (key) {
try {
localStorage.setItem('active_master_key', bufferToBase64(key))
} catch (e) {
console.error('Failed to save master key to localStorage:', e)
}
} else {
localStorage.removeItem('active_master_key')
}
export async function checkServerSession(): Promise<{ authenticated: boolean; userId?: string }> {
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), 8_000)
try {
return await apiJson<{ authenticated: boolean; userId?: string }>(`${API_BASE}/session`, {
signal: controller.signal
})
} catch {
return { authenticated: false }
} finally {
window.clearTimeout(timeoutId)
}
}
/** Master key + username in memory/storage — enough to stay in the unlocked UI. */
export function hasUnlockedLocalCrypto(): boolean {
return !!(getActiveMasterKey() && localStorage.getItem('active_username'))
}
/** Crypto unlock plus user id for authenticated API calls (userId may already be in localStorage). */
export function hasUnlockedLocalSession(): boolean {
return hasUnlockedLocalCrypto() && !!localStorage.getItem('active_userid')
}
/** Persist server session user id when the /session response includes it. */
export function persistSessionUserId(userId: string | undefined): void {
if (userId) {
localStorage.setItem('active_userid', userId)
}
}
export async function reauthWithPasskey(): Promise<boolean> {
const options = await apiJson<any>(`${API_BASE}/reauth-options`, {
method: 'POST'
})
const credentialResponse = await startAuthentication({ optionsJSON: options })
await apiJson(`${API_BASE}/reauth-verify`, {
method: 'POST',
body: JSON.stringify({
credentialResponse,
challenge: options.challenge
})
})
return true
}
// PIN fallback mechanism functions
export async function setLocalPin(pin: string, username: string, masterKey: ArrayBuffer): Promise<void> {
const pinKey = await deriveKeyFromPin(pin, username)
@@ -152,19 +188,11 @@ export interface RegistrationResult {
export async function registerUser(username: string): Promise<RegistrationResult> {
// 1. Get registration options
const optionsRes = await fetch(`${API_BASE}/register-options`, {
const options = await apiJson<any>(`${API_BASE}/register-options`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
})
if (!optionsRes.ok) {
const err = await optionsRes.json()
throw new Error(err.error || 'Failed to fetch registration options')
}
const options = await optionsRes.json()
// Request the PRF extension WITH an evaluation salt. This must match the
// salt used during login (PRF_SALT), otherwise the PRF-derived key produced
// at login would never match what was stored here and every login would fall
@@ -229,9 +257,8 @@ export async function registerUser(username: string): Promise<RegistrationResult
const encryptedRecovery = await encryptBuffer(masterKey, recoveryKey)
// 4. Verify registration on the server
const verifyRes = await fetch(`${API_BASE}/register-verify`, {
const result = await apiJson<{ verified: boolean; userId: string }>(`${API_BASE}/register-verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
credentialResponse,
@@ -243,13 +270,6 @@ export async function registerUser(username: string): Promise<RegistrationResult
encryptedMasterKeyRecTag: encryptedRecovery.tag
})
})
if (!verifyRes.ok) {
const err = await verifyRes.json()
throw new Error(err.error || 'Failed to verify registration response')
}
const result = await verifyRes.json()
if (result.verified) {
setActiveMasterKey(masterKey)
localStorage.setItem('active_username', username)
@@ -292,19 +312,11 @@ export async function loginUser(username?: string): Promise<LoginResult> {
}
// 1. Get authentication options
const optionsRes = await fetch(`${API_BASE}/login-options`, {
const options = await apiJson<any>(`${API_BASE}/login-options`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
})
if (!optionsRes.ok) {
const err = await optionsRes.json()
throw new Error(err.error || 'Failed to fetch login options')
}
const options = await optionsRes.json()
// Add PRF extension evaluation input.
// When the server returned a concrete allowCredentials list we use
// `evalByCredential` (keyed by the base64url credential id), which is the
@@ -350,7 +362,11 @@ export async function loginUser(username?: string): Promise<LoginResult> {
const prfRequested = !!options.extensions?.prf
try {
credentialResponse = await startAuthentication({ optionsJSON: options })
} catch (err: any) {
} catch (err: unknown) {
// User cancelled or timed out — never open a second platform prompt.
if (isWebAuthnUserAbortError(err)) {
throw err
}
if (prfRequested) {
console.warn('Passkey authentication with PRF extension failed, retrying without PRF:', err)
if (options.extensions) {
@@ -366,21 +382,23 @@ export async function loginUser(username?: string): Promise<LoginResult> {
}
// 3. Verify assertion on the server
const verifyRes = await fetch(`${API_BASE}/login-verify`, {
const result = await apiJson<{
verified: boolean
userId: string
username: string
encryptedMasterKeyPrf: string | null
encryptedMasterKeyPrfIv: string | null
encryptedMasterKeyPrfTag: string | null
encryptedMasterKeyRec: string
encryptedMasterKeyRecIv: string
encryptedMasterKeyRecTag: string
}>(`${API_BASE}/login-verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
credentialResponse,
challenge: options.challenge
})
})
if (!verifyRes.ok) {
const err = await verifyRes.json()
throw new Error(err.error || 'Failed to verify login response')
}
const result = await verifyRes.json()
if (!result.verified) {
return { verified: false, prfSuccess: false }
}
@@ -407,7 +425,12 @@ export async function loginUser(username?: string): Promise<LoginResult> {
console.log('PRF extension results first present:', !!prfResults.results?.first)
}
if (prfResults?.results?.first && result.encryptedMasterKeyPrf) {
if (
prfResults?.results?.first &&
result.encryptedMasterKeyPrf &&
result.encryptedMasterKeyPrfIv &&
result.encryptedMasterKeyPrfTag
) {
try {
const firstBuffer = typeof prfResults.results.first === 'string'
? base64urlToBuffer(prfResults.results.first)
@@ -475,22 +498,14 @@ export async function completeLoginWithRecovery(
const prfKey = await deriveKeyFromPrf(firstBuffer)
const encryptedPrf = await encryptBuffer(decryptedMaster, prfKey)
console.log('Sending PRF credentials to server...')
const enrollRes = await fetch(`${API_BASE}/enroll-prf`, {
await apiJson(`${API_BASE}/enroll-prf`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': encryptedPayloads.userId
},
body: JSON.stringify({
encryptedMasterKeyPrf: encryptedPrf.ciphertext,
encryptedMasterKeyPrfIv: encryptedPrf.iv,
encryptedMasterKeyPrfTag: encryptedPrf.tag
})
})
console.log('Enrollment response status:', enrollRes.status)
if (!enrollRes.ok) {
console.warn('Server rejected PRF enrollment')
}
} catch (err) {
console.error('Failed to encrypt/enroll master key with PRF key:', err)
}
@@ -508,25 +523,26 @@ export async function completeLoginWithRecovery(
}
}
export function logoutUser() {
export async function logoutUser() {
setActiveMasterKey(null)
clearLogbookKeysCache()
localStorage.removeItem('active_username')
localStorage.removeItem('active_userid')
try {
await apiFetch(`${API_BASE}/logout`, { method: 'POST' })
} catch {
/* ignore network errors on logout */
}
}
export async function deleteAccount(): Promise<boolean> {
const userId = localStorage.getItem('active_userid')
const username = localStorage.getItem('active_username')
if (!userId) return false
if (!localStorage.getItem('active_userid')) return false
try {
const res = await fetch(`${API_BASE}/delete-account`, {
method: 'DELETE',
headers: {
'X-User-Id': userId
}
})
await reauthWithPasskey()
const res = await apiFetch(`${API_BASE}/delete-account`, { method: 'DELETE' })
if (res.ok) {
if (username) {
@@ -542,11 +558,16 @@ export async function deleteAccount(): Promise<boolean> {
db.photos.clear(),
db.gpsTracks.clear(),
db.syncQueue.clear(),
db.logbookKeys.clear()
db.logbookKeys.clear(),
db.personPool.clear(),
db.vesselPool.clear(),
db.logbookCrewSelections.clear(),
db.logbookVesselSelections.clear(),
db.userSyncQueue.clear()
])
// Wipe localStorage and session variables
logoutUser()
await logoutUser()
trackPlausibleEvent(PlausibleEvents.ACCOUNT_DELETED)
return true
}
@@ -555,3 +576,137 @@ export async function deleteAccount(): Promise<boolean> {
}
return false
}
export interface UserProfileCredential {
id: string
label: string | null
credentialIdPreview: string
transports: string[]
}
export interface UserProfile {
userId: string
username: string
createdAt: string
hasPrfEncryption: boolean
credentials: UserProfileCredential[]
serverMeta: {
ownedLogbookCount: number
collaborationCount: number
}
}
export async function fetchUserProfile(): Promise<UserProfile> {
return apiJson<UserProfile>(`${API_BASE}/profile`)
}
async function enrollPrfFromMasterKey(masterKey: ArrayBuffer, prfFirst: ArrayBuffer): Promise<void> {
const prfKey = await deriveKeyFromPrf(prfFirst)
const encryptedPrf = await encryptBuffer(masterKey, prfKey)
await apiJson(`${API_BASE}/enroll-prf`, {
method: 'POST',
body: JSON.stringify({
encryptedMasterKeyPrf: encryptedPrf.ciphertext,
encryptedMasterKeyPrfIv: encryptedPrf.iv,
encryptedMasterKeyPrfTag: encryptedPrf.tag
})
})
}
export async function addPasskey(label?: string): Promise<void> {
await reauthWithPasskey()
const options = await apiJson<any>(`${API_BASE}/add-credential-options`, {
method: 'POST'
})
if (!options.extensions) {
options.extensions = {}
}
options.extensions.prf = { eval: { first: PRF_SALT.buffer } }
let credentialResponse
const prfRequested = !!options.extensions?.prf
try {
credentialResponse = await startRegistration({ optionsJSON: options })
} catch (err: any) {
const isOptionError = err.name === 'NotSupportedError' ||
err.message?.toLowerCase().includes('options') ||
err.message?.toLowerCase().includes('process') ||
err.message?.toLowerCase().includes('unable to')
if (prfRequested && isOptionError) {
console.warn('Add passkey with PRF extension failed, retrying without PRF:', err)
if (options.extensions) {
delete options.extensions.prf
}
credentialResponse = await startRegistration({ optionsJSON: options })
} else {
throw err
}
}
await apiJson(`${API_BASE}/add-credential-verify`, {
method: 'POST',
body: JSON.stringify({
credentialResponse,
challenge: options.challenge,
...(label?.trim() ? { label: label.trim() } : {})
})
})
const masterKey = getActiveMasterKey()
const prfFirstBuffer = extractPrfFirst(credentialResponse.clientExtensionResults || {})
if (masterKey && prfFirstBuffer) {
try {
await enrollPrfFromMasterKey(masterKey, prfFirstBuffer)
} catch (err) {
console.error('Failed to enroll PRF after adding passkey:', err)
}
}
}
export async function removePasskey(credentialDbId: string): Promise<void> {
await reauthWithPasskey()
const res = await apiFetch(`${API_BASE}/credentials/${credentialDbId}`, {
method: 'DELETE'
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || 'Failed to remove passkey')
}
}
export async function renamePasskey(credentialDbId: string, label: string): Promise<void> {
await reauthWithPasskey()
await apiJson(`${API_BASE}/credentials/${credentialDbId}`, {
method: 'PATCH',
body: JSON.stringify({ label })
})
}
export async function rotateRecoveryPhrase(): Promise<string> {
const masterKey = getActiveMasterKey()
if (!masterKey) {
throw new Error('NO_ACTIVE_MASTER_KEY')
}
await reauthWithPasskey()
const recoveryPhrase = generateRecoveryPhrase()
const recoveryKey = await deriveKeyFromPhrase(recoveryPhrase)
const encryptedRecovery = await encryptBuffer(masterKey, recoveryKey)
await apiJson(`${API_BASE}/rotate-recovery`, {
method: 'POST',
body: JSON.stringify({
encryptedMasterKeyRec: encryptedRecovery.ciphertext,
encryptedMasterKeyRecIv: encryptedRecovery.iv,
encryptedMasterKeyRecTag: encryptedRecovery.tag
})
})
return recoveryPhrase
}
+53
View File
@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
hasUnlockedLocalCrypto,
hasUnlockedLocalSession,
setActiveMasterKey
} from './auth.js'
describe('local session unlock checks', () => {
beforeEach(() => {
localStorage.clear()
setActiveMasterKey(null)
})
it('hasUnlockedLocalCrypto with master key and username only', () => {
setActiveMasterKey(new ArrayBuffer(32))
localStorage.setItem('active_username', 'skipper')
expect(hasUnlockedLocalCrypto()).toBe(true)
expect(hasUnlockedLocalSession()).toBe(false)
})
it('hasUnlockedLocalSession when userId is present', () => {
setActiveMasterKey(new ArrayBuffer(32))
localStorage.setItem('active_username', 'skipper')
localStorage.setItem('active_userid', 'user-1')
expect(hasUnlockedLocalCrypto()).toBe(true)
expect(hasUnlockedLocalSession()).toBe(true)
})
it('hasUnlockedLocalCrypto false without master key', () => {
localStorage.setItem('active_username', 'skipper')
localStorage.setItem('active_userid', 'user-1')
expect(hasUnlockedLocalCrypto()).toBe(false)
})
})
describe('persistSessionUserId', () => {
beforeEach(() => {
localStorage.clear()
})
it('stores userId when provided', async () => {
const { persistSessionUserId } = await import('./auth.js')
persistSessionUserId('user-42')
expect(localStorage.getItem('active_userid')).toBe('user-42')
})
it('does not clear existing userId when omitted', async () => {
const { persistSessionUserId } = await import('./auth.js')
localStorage.setItem('active_userid', 'user-1')
persistSessionUserId(undefined)
expect(localStorage.getItem('active_userid')).toBe('user-1')
})
})
+125
View File
@@ -0,0 +1,125 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { decryptJson, encryptJson } from './crypto.js'
import { getLogbookKey } from './logbookKeys.js'
import type { PersonData } from '../types/person.js'
import { buildLogbookCrewSelection, pickActiveSkipperId } from '../utils/personSnapshots.js'
import { entryCrewFromLogbookSelection } from '../utils/personSnapshots.js'
import { saveLogbookCrewSelection } from './logbookCrewSelection.js'
const MIGRATION_FLAG = 'crew_pool_migration_v1_done'
export async function migrateLegacyCrewToPoolIfNeeded(): Promise<void> {
const userId = localStorage.getItem('active_userid')
if (!userId || localStorage.getItem(MIGRATION_FLAG) === userId) return
const masterKey = getActiveMasterKey()
if (!masterKey) return
try {
const ownedLogbooks = await db.logbooks.filter((lb) => lb.isShared !== 1).toArray()
const poolByLegacyKey = new Map<string, string>()
const poolData = new Map<string, PersonData>()
for (const logbook of ownedLogbooks) {
const logbookKey = (await getLogbookKey(logbook.id)) || masterKey
const legacyCrews = await db.crews.where({ logbookId: logbook.id }).toArray()
const legacyIds: { skipperIds: string[]; crewIds: string[] } = {
skipperIds: [],
crewIds: []
}
for (const record of legacyCrews) {
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, logbookKey)) as
| PersonData
| null
if (!data) continue
const role = record.payloadId === 'skipper' ? 'skipper' : 'crew'
const personData: PersonData = { ...data, role }
const dedupeKey = `${role}:${personData.name}:${personData.passportNumber}`
let poolId = poolByLegacyKey.get(dedupeKey)
if (!poolId) {
poolId = record.payloadId === 'skipper' ? 'skipper' : record.payloadId
const existing = await db.personPool.get(poolId)
if (!existing) {
const encrypted = await encryptJson(personData, masterKey)
const now = new Date().toISOString()
await db.personPool.put({
payloadId: poolId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.userSyncQueue.put({
action: 'create',
type: 'person',
payloadId: poolId,
data: JSON.stringify(encrypted),
updatedAt: now
})
}
poolByLegacyKey.set(dedupeKey, poolId)
poolData.set(poolId, personData)
}
if (role === 'skipper') {
if (!legacyIds.skipperIds.includes(poolId)) legacyIds.skipperIds.push(poolId)
} else {
legacyIds.crewIds.push(poolId)
}
}
const activeSkipperId = pickActiveSkipperId(legacyIds.skipperIds)
const existingSelection = await db.logbookCrewSelections.get(logbook.id)
if (!existingSelection && (activeSkipperId || legacyIds.crewIds.length > 0)) {
const selection = buildLogbookCrewSelection(
activeSkipperId,
legacyIds.crewIds,
poolData
)
await saveLogbookCrewSelection(logbook.id, selection)
const entryCrew = entryCrewFromLogbookSelection(selection)
const entries = await db.entries.where({ logbookId: logbook.id }).toArray()
for (const entry of entries) {
const dec = (await decryptJson(entry.encryptedData, entry.iv, entry.tag, logbookKey)) as Record<
string,
unknown
> | null
if (!dec) continue
if (dec.selectedSkipperId != null || (Array.isArray(dec.selectedCrewIds) && dec.selectedCrewIds.length > 0)) {
continue
}
const updated = {
...dec,
...entryCrew
}
const encrypted = await encryptJson(updated, logbookKey)
const now = new Date().toISOString()
await db.entries.put({
...entry,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'update',
type: 'entry',
payloadId: entry.payloadId,
logbookId: logbook.id,
data: JSON.stringify(encrypted),
updatedAt: now
})
}
}
}
localStorage.setItem(MIGRATION_FLAG, userId)
} catch (err) {
console.warn('Crew pool migration failed:', err)
}
}
+30 -23
View File
@@ -3,7 +3,9 @@ import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.js'
import { formatSignatureForExport, normalizeSignature } from '../utils/signatures.js'
import { sortLogEventsByTime } from '../utils/logEntryPayload.js'
import i18n from '../i18n/index.js'
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
function escapeCsvValue(val: string | number | undefined | null): string {
if (val === null || val === undefined) return '';
@@ -35,22 +37,17 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
throw new Error('Encryption key not found. User must log in.')
}
// 1. Fetch Yacht details
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);
}
const { resolveVesselForLogbook } = await import('./resolveVessel.js')
const yacht = await resolveVesselForLogbook(logbookId)
if (yacht) {
yachtName = yacht.name || ''
homePort = yacht.homePort || ''
owner = yacht.owner || ''
charter = yacht.charterCompany || ''
registration = yacht.registrationNumber || ''
callsign = yacht.callSign || ''
atis = yacht.atis || ''
mmsi = yacht.mmsi || ''
}
// 2. Fetch logbook entries
@@ -79,13 +76,14 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const headers = [
'Date', 'Day of Travel', 'Departure Port', 'Destination Port',
'Skipper Signature', 'Crew Signature',
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)',
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
'Event Time', 'MgK Course', 'RwK Course',
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State',
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Visibility',
'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)',
'Greywater Level (L)',
'Yacht Name', 'Home Port', 'Owner', 'Charter Company', 'Registration', 'Callsign', 'ATIS', 'MMSI'
];
@@ -93,8 +91,12 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const exportLabels = {
imagePlaceholder: i18n.t('logs.sign_export_image'),
passkeyLabel: (username: string, signedAt: string) => {
const date = new Date(signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB')
const date = formatAppDateTime(signedAt, i18n.language)
return i18n.t('logs.sign_passkey_export', { username, date })
},
attributionLabel: (username: string, signedAt: string) => {
const date = formatAppDateTime(signedAt, i18n.language)
return i18n.t('logs.sign_attribution_export', { username, date })
}
};
@@ -108,6 +110,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const trackDist = entry.trackDistanceNm ?? '';
const trackMax = entry.trackSpeedMaxKn ?? '';
const trackAvg = entry.trackSpeedAvgKn ?? '';
const motorH = entry.motorHours ?? '';
const fwM = entry.freshwater?.morning ?? '';
const fwR = entry.freshwater?.refilled ?? '';
const fwE = entry.freshwater?.evening ?? '';
@@ -116,6 +119,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const fuelR = entry.fuel?.refilled ?? '';
const fuelE = entry.fuel?.evening ?? '';
const fuelCons = entry.fuel?.consumption ?? '';
const greywaterLevel = entry.greywater?.level ?? '';
const eventsList = entry.events || [];
if (eventsList.length === 0) {
@@ -123,29 +127,32 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
rows.push([
dateVal, travelDay, dep, dest,
signS, signC,
trackDist, trackMax, trackAvg,
trackDist, trackMax, trackAvg, motorH,
'', '', '',
'', '', '', '',
'', '', '', '', '',
'', '', '', '', '',
'', '', '',
fwM, fwR, fwE, fwCons,
fuelM, fuelR, fuelE, fuelCons,
greywaterLevel,
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 || ''));
const sortedEvents = sortLogEventsByTime(eventsList);
for (const ev of sortedEvents) {
rows.push([
dateVal, travelDay, dep, dest,
signS, signC,
trackDist, trackMax, trackAvg,
trackDist, trackMax, trackAvg, motorH,
ev.time || '', ev.mgk || '', ev.rwk || '',
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
ev.visibility || '',
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
ev.gpsLat || '', ev.gpsLng || '', ev.remarks || '',
fwM, fwR, fwE, fwCons,
fuelM, fuelR, fuelE, fuelCons,
greywaterLevel,
yachtName, homePort, owner, charter, registration, callsign, atis, mmsi
].map(escapeCsvValue));
}
+145 -1
View File
@@ -35,6 +35,14 @@ export interface LocalDeviation {
updatedAt: string
}
export interface EntryListCache {
date: string
dayOfTravel: string
departure: string
destination: string
skipperSignStatus: 'none' | 'valid' | 'invalid'
}
export interface LocalEntry {
payloadId: string
logbookId: string
@@ -42,6 +50,8 @@ export interface LocalEntry {
iv: string
tag: string
updatedAt: string
/** Plaintext list fields — avoids full decrypt when opening the journal list. */
listCache?: EntryListCache
}
export interface LocalPhoto {
@@ -64,6 +74,15 @@ export interface LocalGpsTrack {
updatedAt: string
}
export interface LocalNmeaArchive {
entryId: string
logbookId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface LocalLogbookKey {
logbookId: string
encryptedKey: string
@@ -71,16 +90,75 @@ export interface LocalLogbookKey {
tag: string
}
export interface LocalPerson {
payloadId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface LocalVessel {
payloadId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface LocalLogbookCrewSelection {
logbookId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface LocalLogbookVesselSelection {
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' | 'photo' | 'gpsTrack'
type:
| 'yacht'
| 'crew'
| 'deviation'
| 'entry'
| 'logbook'
| 'photo'
| 'gpsTrack'
| 'logbookCrew'
| 'logbookVessel'
payloadId: string // payloadId or logbookId depending on the type
logbookId: string
data: string // JSON representation of the local record
updatedAt: string
}
export interface UserSyncQueueItem {
id?: number
action: 'create' | 'update' | 'delete'
type: 'person' | 'vessel'
payloadId: string
data: string
updatedAt: string
}
export interface EntryDraftRecord {
logbookId: string
entryId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
class DaagboxDatabase extends Dexie {
logbooks!: Table<LocalLogbook>
yachts!: Table<LocalYacht>
@@ -89,8 +167,15 @@ class DaagboxDatabase extends Dexie {
entries!: Table<LocalEntry>
photos!: Table<LocalPhoto>
gpsTracks!: Table<LocalGpsTrack>
nmeaArchives!: Table<LocalNmeaArchive>
logbookKeys!: Table<LocalLogbookKey>
personPool!: Table<LocalPerson>
vesselPool!: Table<LocalVessel>
logbookCrewSelections!: Table<LocalLogbookCrewSelection>
logbookVesselSelections!: Table<LocalLogbookVesselSelection>
syncQueue!: Table<SyncQueueItem>
userSyncQueue!: Table<UserSyncQueueItem>
entryDrafts!: Table<EntryDraftRecord, [string, string]>
constructor() {
super('DaagboxDatabase')
@@ -145,6 +230,65 @@ class DaagboxDatabase extends Dexie {
gpsTracks: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId'
})
this.version(6).stores({
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
yachts: 'logbookId, updatedAt',
crews: 'payloadId, logbookId, updatedAt',
deviations: 'logbookId, updatedAt',
entries: 'payloadId, logbookId, updatedAt',
syncQueue: '++id, action, type, payloadId, logbookId',
photos: 'payloadId, entryId, logbookId, updatedAt',
gpsTracks: 'entryId, logbookId, updatedAt',
nmeaArchives: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId'
})
this.version(7).stores({
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
yachts: 'logbookId, updatedAt',
crews: 'payloadId, logbookId, updatedAt',
deviations: 'logbookId, updatedAt',
entries: 'payloadId, logbookId, updatedAt',
syncQueue: '++id, action, type, payloadId, logbookId',
photos: 'payloadId, entryId, logbookId, updatedAt',
gpsTracks: 'entryId, logbookId, updatedAt',
nmeaArchives: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId',
entryDrafts: '[logbookId+entryId], updatedAt'
})
this.version(8).stores({
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
yachts: 'logbookId, updatedAt',
crews: 'payloadId, logbookId, updatedAt',
deviations: 'logbookId, updatedAt',
entries: 'payloadId, logbookId, updatedAt',
syncQueue: '++id, action, type, payloadId, logbookId',
photos: 'payloadId, entryId, logbookId, updatedAt',
gpsTracks: 'entryId, logbookId, updatedAt',
nmeaArchives: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId',
personPool: 'payloadId, updatedAt',
logbookCrewSelections: 'logbookId, updatedAt',
userSyncQueue: '++id, action, type, payloadId',
entryDrafts: '[logbookId+entryId], updatedAt'
})
this.version(9).stores({
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
yachts: 'logbookId, updatedAt',
crews: 'payloadId, logbookId, updatedAt',
deviations: 'logbookId, updatedAt',
entries: 'payloadId, logbookId, updatedAt',
syncQueue: '++id, action, type, payloadId, logbookId',
photos: 'payloadId, entryId, logbookId, updatedAt',
gpsTracks: 'entryId, logbookId, updatedAt',
nmeaArchives: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId',
personPool: 'payloadId, updatedAt',
vesselPool: 'payloadId, updatedAt',
logbookCrewSelections: 'logbookId, updatedAt',
logbookVesselSelections: 'logbookId, updatedAt',
userSyncQueue: '++id, action, type, payloadId',
entryDrafts: '[logbookId+entryId], updatedAt'
})
}
}
+36
View File
@@ -0,0 +1,36 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
clearDemoLogbookRefs,
getDemoFirstEntryStorageKey,
getDemoLogbookStorageKey
} from './demoLogbook.js'
describe('clearDemoLogbookRefs', () => {
const userId = 'user-1'
beforeEach(() => {
localStorage.clear()
localStorage.setItem('active_userid', userId)
})
it('removes demo logbook and first-entry keys for the user', () => {
const logbookId = 'lb-demo'
localStorage.setItem(getDemoLogbookStorageKey(userId), logbookId)
localStorage.setItem(getDemoFirstEntryStorageKey(userId), 'entry-1')
clearDemoLogbookRefs(userId, logbookId)
expect(localStorage.getItem(getDemoLogbookStorageKey(userId))).toBeNull()
expect(localStorage.getItem(getDemoFirstEntryStorageKey(userId))).toBeNull()
})
it('does not clear refs when logbookId does not match stored demo id', () => {
localStorage.setItem(getDemoLogbookStorageKey(userId), 'other-logbook')
localStorage.setItem(getDemoFirstEntryStorageKey(userId), 'entry-1')
clearDemoLogbookRefs(userId, 'deleted-logbook')
expect(localStorage.getItem(getDemoLogbookStorageKey(userId))).toBe('other-logbook')
expect(localStorage.getItem(getDemoFirstEntryStorageKey(userId))).toBe('entry-1')
})
})
+123 -24
View File
@@ -4,9 +4,13 @@ import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
import { putEntryRecord } from '../utils/entryListCache.js'
import { syncPersonPool } from './personPoolSync.js'
import i18n from '../i18n/index.js'
import type { PersonData } from '../types/person.js'
import { buildLogbookCrewSelection } from '../utils/personSnapshots.js'
import {
buildDemoCrewRecords,
buildDemoPersonPool,
buildDemoEntryPayloads,
buildDemoYachtData
} from './demoLogbookData.js'
@@ -24,7 +28,7 @@ export function getDemoFirstEntryStorageKey(userId: string): string {
async function putEncryptedRecord(
logbookId: string,
key: ArrayBuffer,
type: 'entry' | 'crew' | 'yacht' | 'gpsTrack',
type: 'entry' | 'yacht' | 'gpsTrack' | 'logbookCrew',
payloadId: string,
data: unknown,
now: string
@@ -32,23 +36,17 @@ async function putEncryptedRecord(
const encrypted = await encryptJson(data, key)
if (type === 'entry') {
await db.entries.put({
payloadId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
} else if (type === 'crew') {
await db.crews.put({
payloadId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await putEntryRecord(
{
payloadId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
},
data as Record<string, unknown>
)
} else if (type === 'yacht') {
await db.yachts.put({
logbookId,
@@ -66,25 +64,62 @@ async function putEncryptedRecord(
tag: encrypted.tag,
updatedAt: now
})
} else if (type === 'logbookCrew') {
await db.logbookCrewSelections.put({
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
}
await db.syncQueue.put({
action: type === 'yacht' ? 'update' : 'create',
action: type === 'yacht' || type === 'logbookCrew' ? 'update' : 'create',
type,
payloadId: type === 'yacht' ? logbookId : payloadId,
payloadId: type === 'yacht' || type === 'logbookCrew' ? logbookId : payloadId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
}
async function seedPersonPool(masterKey: ArrayBuffer, now: string): Promise<Map<string, PersonData>> {
const poolMap = new Map<string, PersonData>()
for (const person of buildDemoPersonPool()) {
poolMap.set(person.payloadId, person.data)
const encrypted = await encryptJson(person.data, masterKey)
await db.personPool.put({
payloadId: person.payloadId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.userSyncQueue.put({
action: 'create',
type: 'person',
payloadId: person.payloadId,
data: JSON.stringify(encrypted),
updatedAt: now
})
}
syncPersonPool().catch((err) => console.warn('Demo person pool sync failed:', err))
return poolMap
}
async function seedYachtAndCrew(logbookId: string, key: ArrayBuffer, now: string): Promise<void> {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not available')
const yachtData = buildDemoYachtData()
await putEncryptedRecord(logbookId, key, 'yacht', logbookId, yachtData, now)
for (const crew of buildDemoCrewRecords()) {
await putEncryptedRecord(logbookId, key, 'crew', crew.payloadId, crew.data, now)
}
const poolMap = await seedPersonPool(masterKey, now)
const skipperId = [...poolMap.entries()].find(([, d]) => d.role === 'skipper')?.[0] ?? null
const crewIds = [...poolMap.entries()].filter(([, d]) => d.role === 'crew').map(([id]) => id)
const selection = buildLogbookCrewSelection(skipperId, crewIds, poolMap)
await putEncryptedRecord(logbookId, key, 'logbookCrew', logbookId, selection, now)
}
export interface DemoSeedResult {
@@ -108,6 +143,7 @@ export async function seedDemoLogbookIfNeeded(): Promise<DemoSeedResult | null>
const title = i18n.t('demo.logbook_title')
return { logbookId: existingId, title, firstEntryId }
}
clearDemoLogbookRefs(userId, existingId)
}
if (!shouldSeed) return null
@@ -152,3 +188,66 @@ export function getStoredDemoFirstEntryId(): string | null {
if (!userId) return null
return localStorage.getItem(getDemoFirstEntryStorageKey(userId))
}
/** Remove persisted demo logbook pointers when the logbook no longer exists. */
export function clearDemoLogbookRefs(userId: string, logbookId?: string): void {
const storedId = localStorage.getItem(getDemoLogbookStorageKey(userId))
if (logbookId && storedId && storedId !== logbookId) return
localStorage.removeItem(getDemoLogbookStorageKey(userId))
localStorage.removeItem(getDemoFirstEntryStorageKey(userId))
}
export async function entryExistsInLogbook(logbookId: string, entryId: string): Promise<boolean> {
const entry = await db.entries.get(entryId)
return entry?.logbookId === logbookId
}
export interface TourLogbookContext {
logbookId: string
title: string
firstEntryId: string | null
}
/** Pick a logbook + first entry for the onboarding tour (handles deleted demo data). */
export async function resolveTourLogbookContext(
preferLogbookId?: string | null
): Promise<TourLogbookContext | null> {
const userId = localStorage.getItem('active_userid')
if (!userId || !getActiveMasterKey()) return null
const demoId = localStorage.getItem(getDemoLogbookStorageKey(userId))
if (demoId && !(await db.logbooks.get(demoId))) {
clearDemoLogbookRefs(userId, demoId)
}
const { fetchLogbooks } = await import('./logbook.js')
const books = await fetchLogbooks()
if (books.length === 0) return null
const activeId = localStorage.getItem('active_logbook_id')
const pick =
(preferLogbookId ? books.find((b) => b.id === preferLogbookId) : undefined) ??
(activeId ? books.find((b) => b.id === activeId) : undefined) ??
(demoId ? books.find((b) => b.id === demoId) : undefined) ??
books[0]
const firstEntryId = await resolveTourFirstEntryId(pick.id, userId)
return { logbookId: pick.id, title: pick.title, firstEntryId }
}
async function resolveTourFirstEntryId(logbookId: string, userId: string): Promise<string | null> {
const stored = localStorage.getItem(getDemoFirstEntryStorageKey(userId))
if (stored && (await entryExistsInLogbook(logbookId, stored))) {
return stored
}
if (stored) {
localStorage.removeItem(getDemoFirstEntryStorageKey(userId))
}
const localEntries = await db.entries.where({ logbookId }).toArray()
if (localEntries.length === 0) return null
localEntries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
return localEntries[0]?.payloadId ?? null
}
+100 -6
View File
@@ -1,6 +1,7 @@
import { parseTrackFile } from './trackUpload.js'
import { computeTrackStats } from '../utils/trackStats.js'
import i18n from '../i18n/index.js'
import { isGermanLocale } from '../utils/i18nLanguages.js'
import kielLaboeGpx from '../assets/demo/kiel-laboe.gpx?raw'
import laboeDampGpx from '../assets/demo/laboe-damp.gpx?raw'
@@ -15,6 +16,8 @@ const PUBLIC_DEMO_ENTRY_IDS = [
'a0000001-0000-4000-8000-000000000003'
] as const
export const PUBLIC_DEMO_SKIPPER_ID = 'skipper'
export const PUBLIC_DEMO_VESSEL_ID = 'demo-vessel-1'
const PUBLIC_DEMO_CREW_MEMBER_ID = 'a0000001-0000-4000-8000-000000000010'
export interface DemoDaySpec {
@@ -26,6 +29,8 @@ export interface DemoDaySpec {
filename: string
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
fuel: { morning: number; refilled: number; evening: number; consumption: number }
greywaterLevel?: number
motorHours?: number
events: Array<Record<string, string>>
}
@@ -46,10 +51,27 @@ export interface DemoCrewRecord {
}
}
export interface DemoVesselRecord {
payloadId: string
data: Record<string, unknown> & { name: string }
}
export interface PublicDemoFixture {
title: string
yacht: Record<string, unknown>
vesselPool: DemoVesselRecord[]
logbookVesselSelection: {
activeVesselId: string | null
vesselSnapshot: Record<string, unknown> | null
}
/** @deprecated legacy share payload */
crews: DemoCrewRecord[]
personPool: DemoCrewRecord[]
logbookCrewSelection: {
activeSkipperId: string
activeCrewIds: string[]
snapshotsById: Record<string, DemoCrewRecord['data'] & { id: string }>
}
entries: Array<Record<string, unknown> & { payloadId: string }>
gpsTracks: Array<{ entryId: string; waypoints: unknown[]; filename: string; gpxContent?: string; fileType: string }>
photos: never[]
@@ -57,7 +79,7 @@ export interface PublicDemoFixture {
}
export function buildDemoDays(): DemoDaySpec[] {
const isDe = i18n.language.startsWith('de')
const isDe = isGermanLocale(i18n.language)
return [
{
date: '2026-05-29',
@@ -68,6 +90,7 @@ export function buildDemoDays(): DemoDaySpec[] {
filename: 'kiel-laboe.gpx',
freshwater: { morning: 120, refilled: 0, evening: 105, consumption: 15 },
fuel: { morning: 85, refilled: 0, evening: 78, consumption: 7 },
greywaterLevel: 25,
events: [
{
time: '10:15',
@@ -100,6 +123,8 @@ export function buildDemoDays(): DemoDaySpec[] {
filename: 'laboe-damp.gpx',
freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 },
fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 },
greywaterLevel: 38,
motorHours: 1.5,
events: [
{
time: '09:00',
@@ -132,6 +157,7 @@ export function buildDemoDays(): DemoDaySpec[] {
filename: 'damp-schleimuende.gpx',
freshwater: { morning: 110, refilled: 0, evening: 95, consumption: 15 },
fuel: { morning: 70, refilled: 15, evening: 80, consumption: 5 },
greywaterLevel: 52,
events: [
{
time: '08:30',
@@ -159,7 +185,7 @@ export function buildDemoDays(): DemoDaySpec[] {
}
export function buildDemoYachtData(): Record<string, unknown> {
const isDe = i18n.language.startsWith('de')
const isDe = isGermanLocale(i18n.language)
return {
name: 'Seeadler',
vesselType: isDe ? 'Segelyacht' : 'Sailing yacht',
@@ -174,15 +200,22 @@ export function buildDemoYachtData(): Record<string, unknown> {
atis: '',
mmsi: '',
sails: isDe ? ['Großsegel', 'Genua', 'Spinnaker'] : ['Mainsail', 'Genoa', 'Spinnaker'],
photo: null
photo: null,
freshwaterCapacityL: 200,
fuelCapacityL: 100,
greywaterCapacityL: 80
}
}
export function buildDemoPersonPool(): DemoCrewRecord[] {
return buildDemoCrewRecords()
}
export function buildDemoCrewRecords(): DemoCrewRecord[] {
const isDe = i18n.language.startsWith('de')
const isDe = isGermanLocale(i18n.language)
return [
{
payloadId: 'skipper',
payloadId: PUBLIC_DEMO_SKIPPER_ID,
data: {
name: 'Demo Skipper',
address: isDe ? 'Am Hafen 12, 24103 Kiel' : 'Harbour Quay 12, 24103 Kiel',
@@ -216,10 +249,46 @@ export function buildDemoCrewRecords(): DemoCrewRecord[] {
]
}
function buildDemoVesselPool(yacht: Record<string, unknown>): DemoVesselRecord[] {
return [
{
payloadId: PUBLIC_DEMO_VESSEL_ID,
data: { name: String(yacht.name ?? 'Demo'), ...yacht }
}
]
}
function buildDemoLogbookVesselSelection(
yacht: Record<string, unknown>
): PublicDemoFixture['logbookVesselSelection'] {
return {
activeVesselId: PUBLIC_DEMO_VESSEL_ID,
vesselSnapshot: { id: PUBLIC_DEMO_VESSEL_ID, ...yacht }
}
}
function buildDemoLogbookCrewSelection(pool: DemoCrewRecord[]) {
const skipper = pool.find((p) => p.data.role === 'skipper')
const crew = pool.filter((p) => p.data.role === 'crew')
const snapshotsById: Record<string, DemoCrewRecord['data'] & { id: string }> = {}
for (const p of pool) {
snapshotsById[p.payloadId] = { id: p.payloadId, ...p.data }
}
return {
activeSkipperId: skipper?.payloadId ?? PUBLIC_DEMO_SKIPPER_ID,
activeCrewIds: crew.map((c) => c.payloadId),
snapshotsById
}
}
export function buildPublicDemoFixture(): PublicDemoFixture {
const title = i18n.t('demo.logbook_title')
const yacht = buildDemoYachtData()
const crews = buildDemoCrewRecords()
const vesselPool = buildDemoVesselPool(yacht)
const logbookVesselSelection = buildDemoLogbookVesselSelection(yacht)
const personPool = buildDemoPersonPool()
const crews = personPool
const logbookCrewSelection = buildDemoLogbookCrewSelection(personPool)
const days = buildDemoDays()
const entries: PublicDemoFixture['entries'] = []
const gpsTracks: PublicDemoFixture['gpsTracks'] = []
@@ -237,16 +306,26 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
destination: day.destination,
freshwater: { ...day.freshwater },
fuel: { ...day.fuel },
selectedSkipperId: logbookCrewSelection.activeSkipperId,
selectedCrewIds: [...logbookCrewSelection.activeCrewIds],
crewSnapshotsById: { ...logbookCrewSelection.snapshotsById },
signSkipper: '',
signCrew: '',
events: day.events
}
if (day.greywaterLevel != null && day.greywaterLevel > 0) {
entryPayload.greywater = { level: day.greywaterLevel }
}
if (stats) {
entryPayload.trackDistanceNm = stats.distanceNm
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
entryPayload.trackSpeedAvgKn = stats.speedAvgKn
}
if (day.motorHours != null && day.motorHours > 0) {
entryPayload.motorHours = day.motorHours
}
entries.push(entryPayload as PublicDemoFixture['entries'][number])
@@ -262,7 +341,11 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
return {
title,
yacht,
vesselPool,
logbookVesselSelection,
crews,
personPool,
logbookCrewSelection,
entries,
gpsTracks,
photos: [],
@@ -280,6 +363,7 @@ export function buildDemoEntryPayloads(): Array<{
entryPayload: Record<string, unknown>
trackData: { waypoints: unknown[]; gpxContent: string; filename: string; fileType: string }
}> {
const logbookCrewSelection = buildDemoLogbookCrewSelection(buildDemoPersonPool())
const days = buildDemoDays()
return days.map((day) => {
const entryId = crypto.randomUUID()
@@ -293,16 +377,26 @@ export function buildDemoEntryPayloads(): Array<{
destination: day.destination,
freshwater: { ...day.freshwater },
fuel: { ...day.fuel },
selectedSkipperId: logbookCrewSelection.activeSkipperId,
selectedCrewIds: [...logbookCrewSelection.activeCrewIds],
crewSnapshotsById: { ...logbookCrewSelection.snapshotsById },
signSkipper: '',
signCrew: '',
events: day.events
}
if (day.greywaterLevel != null && day.greywaterLevel > 0) {
entryPayload.greywater = { level: day.greywaterLevel }
}
if (stats) {
entryPayload.trackDistanceNm = stats.distanceNm
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
entryPayload.trackSpeedAvgKn = stats.speedAvgKn
}
if (day.motorHours != null && day.motorHours > 0) {
entryPayload.motorHours = day.motorHours
}
return {
entryId,
+53
View File
@@ -0,0 +1,53 @@
import { db } from './db.js'
import { encryptJson, decryptJson } from './crypto.js'
import { getActiveMasterKey } from './auth.js'
export interface EntryDraftRecord {
logbookId: string
entryId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export async function saveEntryDraft(
logbookId: string,
entryId: string,
payload: unknown
): Promise<void> {
const masterKey = getActiveMasterKey()
if (!masterKey) return
const { ciphertext, iv, tag } = await encryptJson(payload, masterKey)
await db.entryDrafts.put({
logbookId,
entryId,
encryptedData: ciphertext,
iv,
tag,
updatedAt: new Date().toISOString()
})
}
export async function loadEntryDraft<T = unknown>(
logbookId: string,
entryId: string
): Promise<T | null> {
const masterKey = getActiveMasterKey()
if (!masterKey) return null
const row = await db.entryDrafts.get([logbookId, entryId])
if (!row) return null
try {
return (await decryptJson(row.encryptedData, row.iv, row.tag, masterKey)) as T
} catch {
await db.entryDrafts.delete([logbookId, entryId])
return null
}
}
export async function clearEntryDraft(logbookId: string, entryId: string): Promise<void> {
await db.entryDrafts.delete([logbookId, entryId])
}
+9 -25
View File
@@ -1,5 +1,6 @@
import { startAuthentication } from '@simplewebauthn/browser'
import type { PasskeySignature } from '../types/signatures.js'
import { apiJson } from './api.js'
export async function signLogEntry(params: {
logbookId: string
@@ -7,32 +8,22 @@ export async function signLogEntry(params: {
entryHash: string
role: 'skipper' | 'crew'
}): Promise<PasskeySignature> {
const userId = localStorage.getItem('active_userid')
if (!userId) throw new Error('User not authenticated')
if (!localStorage.getItem('active_userid')) throw new Error('User not authenticated')
const optionsRes = await fetch('/api/sign/options', {
const options = await apiJson<any>('/api/sign/options', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify(params)
})
if (!optionsRes.ok) {
const err = await optionsRes.json().catch(() => ({}))
throw new Error(err.error || 'Failed to start passkey signing')
}
const options = await optionsRes.json()
const credentialResponse = await startAuthentication({ optionsJSON: options })
const verifyRes = await fetch('/api/sign/verify', {
const result = await apiJson<{
userId: string
username: string
credentialId: string
signedAt: string
}>('/api/sign/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify({
credentialResponse,
challenge: options.challenge,
@@ -43,13 +34,6 @@ export async function signLogEntry(params: {
})
})
if (!verifyRes.ok) {
const err = await verifyRes.json().catch(() => ({}))
throw new Error(err.error || 'Passkey signature verification failed')
}
const result = await verifyRes.json()
return {
kind: 'passkey',
version: 1,
@@ -0,0 +1,106 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.js'
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
import type { LogEventPayload } from '../utils/logEntryPayload.js'
import { LIVE_EVENT_CODES } from '../utils/liveEventCodes.js'
export interface EventSeriesPoint {
entryId: string
date: string
dayOfTravel: string
time: string
summary: string
}
export interface EventSeriesSummary {
pressure: EventSeriesPoint[]
wind: EventSeriesPoint[]
motor: EventSeriesPoint[]
}
function sortPoints(points: EventSeriesPoint[]): EventSeriesPoint[] {
return [...points].sort((a, b) => {
const dateCompare = a.date.localeCompare(b.date)
if (dateCompare !== 0) return dateCompare
return a.time.localeCompare(b.time)
})
}
export async function loadLogbookEventSeries(logbookId: string): Promise<EventSeriesSummary> {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const local = await db.entries.where({ logbookId }).toArray()
const decryptedEntries: Array<{
entryId: string
date: string
dayOfTravel: string
events: LogEventPayload[]
}> = []
for (const entry of local) {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
if (!decrypted) continue
decryptedEntries.push({
entryId: entry.payloadId,
date: String(decrypted.date || ''),
dayOfTravel: String(decrypted.dayOfTravel || ''),
events: (decrypted.events as LogEventPayload[]) || []
})
}
decryptedEntries.sort((a, b) =>
compareTravelDaysChronological(
{ date: a.date, dayOfTravel: a.dayOfTravel },
{ date: b.date, dayOfTravel: b.dayOfTravel }
)
)
const pressure: EventSeriesPoint[] = []
const wind: EventSeriesPoint[] = []
const motor: EventSeriesPoint[] = []
for (const entry of decryptedEntries) {
for (const event of entry.events) {
const base = {
entryId: entry.entryId,
date: entry.date,
dayOfTravel: entry.dayOfTravel,
time: event.time
}
if (event.windPressure?.trim()) {
pressure.push({
...base,
summary: `${event.windPressure} hPa`
})
}
if (event.windDirection?.trim() || event.windStrength?.trim()) {
wind.push({
...base,
summary: [event.windDirection, event.windStrength].filter(Boolean).join(' ')
})
}
const code = event.remarks?.trim() ?? ''
if (
code === LIVE_EVENT_CODES.MOTOR_START ||
code === LIVE_EVENT_CODES.MOTOR_STOP
) {
motor.push({
...base,
summary: code === LIVE_EVENT_CODES.MOTOR_START ? 'start' : 'stop'
})
}
}
}
return {
pressure: sortPoints(pressure),
wind: sortPoints(wind),
motor: sortPoints(motor)
}
}
+69
View File
@@ -0,0 +1,69 @@
import { apiFetch } from './api.js'
export type FeedbackCategory = 'bug' | 'feature' | 'general' | 'translation'
export class FeedbackApiError extends Error {
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' | 'INVALID_EMAIL' | 'RATE_LIMITED' | 'SPAM_DETECTED'
constructor(
message: string,
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' | 'INVALID_EMAIL' | 'RATE_LIMITED' | 'SPAM_DETECTED' = 'REQUEST_FAILED'
) {
super(message)
this.name = 'FeedbackApiError'
this.code = code
}
}
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export function isValidFeedbackEmail(email: string): boolean {
return EMAIL_PATTERN.test(email.trim())
}
export async function sendFeedback(payload: {
category: FeedbackCategory
message: string
contactEmail?: string | null
logbookId?: string | null
logbookTitle?: string | null
openedAt: number
website?: string
}): Promise<void> {
const contactEmail = payload.contactEmail?.trim()
if (contactEmail && !isValidFeedbackEmail(contactEmail)) {
throw new FeedbackApiError('Invalid email address', 'INVALID_EMAIL')
}
const res = await apiFetch('/api/feedback', {
method: 'POST',
body: JSON.stringify({
category: payload.category,
message: payload.message,
contactEmail: contactEmail || undefined,
username: localStorage.getItem('active_username') || undefined,
logbookId: payload.logbookId || undefined,
logbookTitle: payload.logbookTitle || undefined,
appVersion: typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : undefined,
pageUrl: window.location.href,
openedAt: payload.openedAt,
website: payload.website || undefined
})
})
if (res.status === 503) {
throw new FeedbackApiError('Feedback is not configured on this server', 'NOT_CONFIGURED')
}
if (res.status === 429) {
throw new FeedbackApiError('Too many feedback submissions', 'RATE_LIMITED')
}
const data = await res.json().catch(() => ({}))
if (!res.ok) {
throw new FeedbackApiError(
data.error || 'Failed to send feedback',
data.code === 'SPAM_DETECTED' ? 'SPAM_DETECTED' : 'REQUEST_FAILED'
)
}
}
+73 -18
View File
@@ -3,6 +3,8 @@ import { getActiveMasterKey } from './auth.js'
import { encryptJson, decryptJson, encryptBuffer, decryptBuffer } from './crypto.js'
import { getLogbookKey, saveLogbookKey, generateLogbookKey } from './logbookKeys.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
import { apiFetch } from './api.js'
import { clearDemoLogbookRefs, getDemoLogbookStorageKey } from './demoLogbook.js'
const API_BASE = '/api/logbooks'
@@ -66,13 +68,7 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
if (navigator.onLine) {
try {
const response = await fetch(API_BASE, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
}
})
const response = await apiFetch(API_BASE, { method: 'GET' })
if (response.ok) {
const serverLogbooks = await response.json()
@@ -208,12 +204,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
if (navigator.onLine) {
try {
const response = await fetch(API_BASE, {
const response = await apiFetch(API_BASE, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify({
id: localId,
...payloadData
@@ -222,6 +214,10 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
if (response.ok) {
const serverLb = await response.json()
if (serverLb.id !== localId) {
await saveLogbookKey(serverLb.id, logbookKey)
await db.logbookKeys.delete(localId)
}
await db.logbooks.put({
id: serverLb.id,
encryptedTitle: serverLb.encryptedTitle,
@@ -301,12 +297,7 @@ export async function deleteLogbook(id: string): Promise<void> {
if (navigator.onLine) {
try {
const response = await fetch(`${API_BASE}/${id}`, {
method: 'DELETE',
headers: {
'X-User-Id': userId
}
})
const response = await apiFetch(`${API_BASE}/${id}`, { method: 'DELETE' })
if (!response.ok) {
console.warn('Server deletion failed or was rejected')
}
@@ -334,5 +325,69 @@ export async function deleteLogbook(id: string): Promise<void> {
// Perform local cascading cleanup
await deleteLocalLogbookCache(id)
if (userId && id === localStorage.getItem(getDemoLogbookStorageKey(userId))) {
clearDemoLogbookRefs(userId, id)
}
trackPlausibleEvent(PlausibleEvents.LOGBOOK_DELETED)
}
// Update the title of a logbook. Encrypts the title and updates locally + on server
export async function updateLogbookTitle(id: string, newTitle: string): Promise<void> {
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.')
}
const logbookKey = await getLogbookKey(id) || masterKey
// E2E Encrypt the new title using the Logbook Key (or master key fallback)
const encrypted = await encryptJson(newTitle, logbookKey)
const encryptedTitleStr = JSON.stringify(encrypted)
const now = new Date().toISOString()
const payloadData = {
encryptedTitle: encryptedTitleStr
}
if (navigator.onLine) {
try {
const response = await apiFetch(`${API_BASE}/${id}`, {
method: 'PUT',
body: JSON.stringify(payloadData)
})
if (response.ok) {
// Update local IndexedDB cache as synced
await db.logbooks.update(id, {
encryptedTitle: encryptedTitleStr,
updatedAt: now,
isSynced: 1
})
return
}
} catch (error) {
console.warn('Failed to update logbook on server, saving locally instead:', error)
}
}
// If offline or request failed, store locally as unsynced and add to queue
await db.logbooks.update(id, {
encryptedTitle: encryptedTitleStr,
updatedAt: now,
isSynced: 0
})
await db.syncQueue.put({
action: 'update',
type: 'logbook',
payloadId: id,
logbookId: id,
data: JSON.stringify(payloadData),
updatedAt: now
})
}
+4 -7
View File
@@ -1,3 +1,5 @@
import { apiJson } from './api.js'
export interface LogbookAccess {
isOwner: boolean
role: 'OWNER' | 'READ' | 'WRITE'
@@ -5,15 +7,10 @@ export interface LogbookAccess {
}
export async function getLogbookAccess(logbookId: string): Promise<LogbookAccess | null> {
const userId = localStorage.getItem('active_userid')
if (!userId || !navigator.onLine) return null
if (!localStorage.getItem('active_userid') || !navigator.onLine) return null
try {
const res = await fetch(`/api/logbooks/${logbookId}/access`, {
headers: { 'X-User-Id': userId }
})
if (!res.ok) return null
return res.json()
return await apiJson<LogbookAccess>(`/api/logbooks/${logbookId}/access`)
} catch {
return null
}
@@ -0,0 +1,75 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson, encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
import type { LogbookCrewSelectionData } from '../types/person.js'
import { emptyLogbookCrewSelection } from '../types/person.js'
import { buildLogbookCrewSelection } from '../utils/personSnapshots.js'
import type { PersonData } from '../types/person.js'
import { loadPersonPoolMap } from './personPool.js'
async function resolveLogbookKey(logbookId: string): Promise<ArrayBuffer> {
const key = (await getLogbookKey(logbookId)) || getActiveMasterKey()
if (!key) throw new Error('Encryption key not found. Please log in.')
return key
}
export async function loadLogbookCrewSelection(
logbookId: string
): Promise<LogbookCrewSelectionData> {
const record = await db.logbookCrewSelections.get(logbookId)
if (!record) return emptyLogbookCrewSelection()
const key = await resolveLogbookKey(logbookId)
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, key)) as
| LogbookCrewSelectionData
| null
if (!data) return emptyLogbookCrewSelection()
return {
activeSkipperId: data.activeSkipperId ?? null,
activeCrewIds: Array.isArray(data.activeCrewIds) ? data.activeCrewIds : [],
snapshotsById: data.snapshotsById && typeof data.snapshotsById === 'object' ? data.snapshotsById : {}
}
}
export async function saveLogbookCrewSelection(
logbookId: string,
selection: LogbookCrewSelectionData
): Promise<void> {
const key = await resolveLogbookKey(logbookId)
const encrypted = await encryptJson(selection, key)
const now = new Date().toISOString()
await db.logbookCrewSelections.put({
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'update',
type: 'logbookCrew',
payloadId: logbookId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
export async function saveLogbookCrewSelectionFromIds(
logbookId: string,
activeSkipperId: string | null,
activeCrewIds: string[],
poolOverride?: Map<string, PersonData>
): Promise<LogbookCrewSelectionData> {
const pool = poolOverride ?? (await loadPersonPoolMap())
const selection = buildLogbookCrewSelection(activeSkipperId, activeCrewIds, pool)
await saveLogbookCrewSelection(logbookId, selection)
return selection
}
+81
View File
@@ -0,0 +1,81 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { decryptJson } from './crypto.js'
import { getLogbookKey } from './logbookKeys.js'
import type { PersonData } from '../types/person.js'
import { loadLogbookCrewSelection } from './logbookCrewSelection.js'
import { loadPersonPoolMap } from './personPool.js'
import { resolveVesselForLogbook } from './resolveVessel.js'
import type { LogbookSearchFields } from '../utils/logbookFilter.js'
async function loadLegacyCrewNames(logbookId: string): Promise<string[]> {
const records = await db.crews.where({ logbookId }).toArray()
if (records.length === 0) return []
const key = (await getLogbookKey(logbookId)) || getActiveMasterKey()
if (!key) return []
const names: string[] = []
for (const record of records) {
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, key)) as PersonData | null
const name = data?.name?.trim()
if (name) names.push(name)
}
return names
}
function collectCrewNamesFromSelection(
selection: Awaited<ReturnType<typeof loadLogbookCrewSelection>>,
pool: Map<string, PersonData>
): string[] {
const names = new Set<string>()
for (const snapshot of Object.values(selection.snapshotsById)) {
const name = snapshot.name?.trim()
if (name) names.add(name)
}
const ids = [
...(selection.activeSkipperId ? [selection.activeSkipperId] : []),
...selection.activeCrewIds
]
for (const id of ids) {
const fromSnapshot = selection.snapshotsById[id]?.name?.trim()
if (fromSnapshot) {
names.add(fromSnapshot)
continue
}
const fromPool = pool.get(id)?.name?.trim()
if (fromPool) names.add(fromPool)
}
return [...names]
}
export async function loadLogbookSearchFields(logbookId: string): Promise<LogbookSearchFields> {
const [vessel, crewSelection, pool] = await Promise.all([
resolveVesselForLogbook(logbookId),
loadLogbookCrewSelection(logbookId),
loadPersonPoolMap()
])
let crewNames = collectCrewNamesFromSelection(crewSelection, pool)
if (crewNames.length === 0) {
crewNames = await loadLegacyCrewNames(logbookId)
}
return {
vesselName: vessel?.name?.trim() ?? '',
crewNames
}
}
export async function loadLogbookSearchFieldsBatch(
logbookIds: string[]
): Promise<Map<string, LogbookSearchFields>> {
const uniqueIds = [...new Set(logbookIds)]
const entries = await Promise.all(
uniqueIds.map(async (id) => [id, await loadLogbookSearchFields(id)] as const)
)
return new Map(entries)
}
@@ -0,0 +1,73 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson, encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
import type { LogbookVesselSelectionData } from '../types/vessel.js'
import { emptyLogbookVesselSelection } from '../types/vessel.js'
import { buildLogbookVesselSelection } from '../utils/vesselSnapshot.js'
import type { VesselData } from '../types/vessel.js'
import { loadVesselPoolMap } from './vesselPool.js'
async function resolveLogbookKey(logbookId: string): Promise<ArrayBuffer> {
const key = (await getLogbookKey(logbookId)) || getActiveMasterKey()
if (!key) throw new Error('Encryption key not found. Please log in.')
return key
}
export async function loadLogbookVesselSelection(
logbookId: string
): Promise<LogbookVesselSelectionData> {
const record = await db.logbookVesselSelections.get(logbookId)
if (!record) return emptyLogbookVesselSelection()
const key = await resolveLogbookKey(logbookId)
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, key)) as
| LogbookVesselSelectionData
| null
if (!data) return emptyLogbookVesselSelection()
return {
activeVesselId: data.activeVesselId ?? null,
vesselSnapshot: data.vesselSnapshot ?? null
}
}
export async function saveLogbookVesselSelection(
logbookId: string,
selection: LogbookVesselSelectionData
): Promise<void> {
const key = await resolveLogbookKey(logbookId)
const encrypted = await encryptJson(selection, key)
const now = new Date().toISOString()
await db.logbookVesselSelections.put({
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'update',
type: 'logbookVessel',
payloadId: logbookId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
export async function saveLogbookVesselSelectionFromId(
logbookId: string,
activeVesselId: string | null,
poolOverride?: Map<string, VesselData>
): Promise<LogbookVesselSelectionData> {
const pool = poolOverride ?? (await loadVesselPoolMap())
const selection = buildLogbookVesselSelection(activeVesselId, pool)
await saveLogbookVesselSelection(logbookId, selection)
return selection
}
@@ -0,0 +1,31 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parseNmeaFile } from './nmeaParse.js'
import { detectNmeaChanges } from './nmeaChangeDetection.js'
import { generateNmeaJournalCandidates } from './nmeaJournalGenerator.js'
const nmeaPath = resolve(import.meta.dirname, '../../../../testdata/tracks/kieler-foerde-5sm.nmea')
describe('kieler-foerde testdata', () => {
it('parses the sample NMEA log and yields journal candidates', () => {
const text = readFileSync(nmeaPath, 'utf8')
const result = parseNmeaFile(text, 'kieler-foerde-5sm.nmea')
expect(result.stats.checksumErrors).toBe(0)
expect(result.points.length).toBeGreaterThan(30)
expect(result.stats.sentenceTypes).toEqual(expect.arrayContaining(['RMC', 'GGA', 'MWV', 'DPT', 'MDA']))
const changes = detectNmeaChanges(result.points)
expect(changes.length).toBeGreaterThan(0)
expect(changes.some((c) => ['wind', 'engine_start', 'departure', 'speed', 'depth'].includes(c.type))).toBe(true)
const journal = generateNmeaJournalCandidates({
points: result.points,
mode: 'both',
intervalMinutes: 60,
t: (key) => key
})
expect(journal.candidates.length).toBeGreaterThanOrEqual(3)
})
})
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import type { NmeaTimePoint } from './nmeaTypes.js'
import { detectNmeaChanges } from './nmeaChangeDetection.js'
function point(
timestamp: number,
overrides: Partial<NmeaTimePoint> = {}
): NmeaTimePoint {
return { timestamp, ...overrides }
}
describe('detectNmeaChanges', () => {
it('detects significant course changes while underway', () => {
const points = [
point(0, { cog: 0, sog: 5 }),
point(60_000, { cog: 45, sog: 5 })
]
const events = detectNmeaChanges(points, {
courseDeltaDeg: 30,
windDirDeltaDeg: 30,
windSpeedDeltaKnots: 5,
pressureDeltaHpa: 2,
depthDeltaM: 1,
depthDeltaPercent: 25,
rpmIdle: 400,
rpmRunning: 800,
sogUnderWayKn: 2,
sogStoppedKn: 0.5,
anchorMinutes: 10,
speedDeltaKn: 2,
dedupeWindowMs: 60_000
})
expect(events.some((e) => e.type === 'course')).toBe(true)
const course = events.find((e) => e.type === 'course')
expect(course?.summaryParams).toMatchObject({ from: 0, to: 45 })
})
it('detects engine start when RPM rises above threshold', () => {
const points = [
point(0, { sog: 0, rpm: 0 }),
point(30_000, { sog: 3, rpm: 1200 })
]
const events = detectNmeaChanges(points)
expect(events.some((e) => e.type === 'engine_start')).toBe(true)
})
it('dedupes repeated events within the configured window', () => {
const points = [
point(0, { cog: 0, sog: 5 }),
point(10_000, { cog: 50, sog: 5 }),
point(20_000, { cog: 100, sog: 5 })
]
const events = detectNmeaChanges(points, {
courseDeltaDeg: 30,
windDirDeltaDeg: 30,
windSpeedDeltaKnots: 5,
pressureDeltaHpa: 2,
depthDeltaM: 1,
depthDeltaPercent: 25,
rpmIdle: 400,
rpmRunning: 800,
sogUnderWayKn: 2,
sogStoppedKn: 0.5,
anchorMinutes: 10,
speedDeltaKn: 2,
dedupeWindowMs: 120_000
})
const courseEvents = events.filter((e) => e.type === 'course')
expect(courseEvents.length).toBe(1)
})
})
@@ -0,0 +1,211 @@
import type { NmeaChangeEvent, NmeaDetectionConfig, NmeaTimePoint } from './nmeaTypes.js'
import { DEFAULT_NMEA_DETECTION_CONFIG } from './nmeaTypes.js'
import { angularDelta } from './nmeaTimeSeries.js'
function pushUnique(events: NmeaChangeEvent[], event: NmeaChangeEvent, minGapMs: number) {
const last = events[events.length - 1]
if (last && last.type === event.type && event.timestamp - last.timestamp < minGapMs) return
events.push(event)
}
export function detectNmeaChanges(
points: NmeaTimePoint[],
config: NmeaDetectionConfig = DEFAULT_NMEA_DETECTION_CONFIG
): NmeaChangeEvent[] {
const events: NmeaChangeEvent[] = []
if (points.length < 2) return events
let lastCourse: number | undefined
let lastWindDir: number | undefined
let lastWindSpeed: number | undefined
let lastPressure: number | undefined
let lastDepth: number | undefined
let lastWaterTemp: number | undefined
let lastFix: boolean | undefined
let engineRunning = false
let autopilot: boolean | undefined
let underWay = false
let stoppedSince: number | null = null
let lastSog: number | undefined
for (const p of points) {
const course = p.cog ?? p.hdt ?? p.hdm
if (course != null && lastCourse != null && (p.sog ?? 0) > 1) {
if (angularDelta(course, lastCourse) >= config.courseDeltaDeg) {
pushUnique(events, {
type: 'course',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: 'logs.nmea_change_course',
summaryParams: { from: Math.round(lastCourse), to: Math.round(course) },
data: p
}, config.dedupeWindowMs)
}
}
if (course != null) lastCourse = course
if (p.windDir != null && lastWindDir != null) {
if (angularDelta(p.windDir, lastWindDir) >= config.windDirDeltaDeg) {
pushUnique(events, {
type: 'wind',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: 'logs.nmea_change_wind',
summaryParams: { from: Math.round(lastWindDir), to: Math.round(p.windDir) },
data: p
}, config.dedupeWindowMs)
} else if (
p.windSpeedKnots != null &&
lastWindSpeed != null &&
Math.abs(p.windSpeedKnots - lastWindSpeed) >= config.windSpeedDeltaKnots
) {
pushUnique(events, {
type: 'wind',
timestamp: p.timestamp,
confidence: 'medium',
summaryKey: 'logs.nmea_change_wind_speed',
summaryParams: { from: lastWindSpeed.toFixed(1), to: p.windSpeedKnots.toFixed(1) },
data: p
}, config.dedupeWindowMs)
}
}
if (p.windDir != null) lastWindDir = p.windDir
if (p.windSpeedKnots != null) lastWindSpeed = p.windSpeedKnots
if (p.pressureHpa != null && lastPressure != null) {
if (Math.abs(p.pressureHpa - lastPressure) >= config.pressureDeltaHpa) {
pushUnique(events, {
type: 'pressure',
timestamp: p.timestamp,
confidence: 'medium',
summaryKey: 'logs.nmea_change_pressure',
summaryParams: { from: lastPressure.toFixed(1), to: p.pressureHpa.toFixed(1) },
data: p
}, config.dedupeWindowMs)
}
}
if (p.pressureHpa != null) lastPressure = p.pressureHpa
if (p.depthM != null && lastDepth != null) {
const delta = Math.abs(p.depthM - lastDepth)
const rel = lastDepth > 0 ? (delta / lastDepth) * 100 : 100
if (delta >= config.depthDeltaM || rel >= config.depthDeltaPercent) {
pushUnique(events, {
type: 'depth',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: 'logs.nmea_change_depth',
summaryParams: { from: lastDepth.toFixed(1), to: p.depthM.toFixed(1) },
data: p
}, config.dedupeWindowMs)
}
}
if (p.depthM != null) lastDepth = p.depthM
if (p.rpm != null) {
const running = p.rpm >= config.rpmRunning
const idle = p.rpm <= config.rpmIdle
if (running && !engineRunning) {
pushUnique(events, {
type: 'engine_start',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: 'logs.nmea_change_engine_start',
summaryParams: { rpm: Math.round(p.rpm) },
data: p
}, config.dedupeWindowMs)
engineRunning = true
} else if (idle && engineRunning) {
pushUnique(events, {
type: 'engine_stop',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: 'logs.nmea_change_engine_stop',
data: p
}, config.dedupeWindowMs)
engineRunning = false
}
}
if (p.autopilotEngaged != null && autopilot != null && p.autopilotEngaged !== autopilot) {
pushUnique(events, {
type: p.autopilotEngaged ? 'autopilot_on' : 'autopilot_off',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: p.autopilotEngaged ? 'logs.nmea_change_autopilot_on' : 'logs.nmea_change_autopilot_off',
data: p
}, config.dedupeWindowMs)
}
if (p.autopilotEngaged != null) autopilot = p.autopilotEngaged
if (p.fixValid != null && lastFix != null && p.fixValid !== lastFix) {
pushUnique(events, {
type: p.fixValid ? 'gps_fix_regained' : 'gps_fix_lost',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: p.fixValid ? 'logs.nmea_change_gps_regained' : 'logs.nmea_change_gps_lost',
data: p
}, config.dedupeWindowMs)
}
if (p.fixValid != null) lastFix = p.fixValid
if (p.waterTempC != null && lastWaterTemp != null) {
if (Math.abs(p.waterTempC - lastWaterTemp) >= 2) {
pushUnique(events, {
type: 'water_temp',
timestamp: p.timestamp,
confidence: 'medium',
summaryKey: 'logs.nmea_change_water_temp',
summaryParams: { from: lastWaterTemp.toFixed(1), to: p.waterTempC.toFixed(1) },
data: p
}, config.dedupeWindowMs)
}
}
if (p.waterTempC != null) lastWaterTemp = p.waterTempC
const sog = p.sog ?? 0
if (sog >= config.sogUnderWayKn && !underWay) {
if (stoppedSince != null && p.timestamp - stoppedSince >= config.anchorMinutes * 60 * 1000) {
pushUnique(events, {
type: 'departure',
timestamp: p.timestamp,
confidence: 'medium',
summaryKey: 'logs.nmea_change_departure',
data: p
}, config.dedupeWindowMs)
}
underWay = true
stoppedSince = null
}
if (sog <= config.sogStoppedKn && underWay) {
underWay = false
stoppedSince = p.timestamp
}
if (sog <= config.sogStoppedKn && stoppedSince != null && !underWay) {
if (p.timestamp - stoppedSince >= config.anchorMinutes * 60 * 1000) {
pushUnique(events, {
type: 'anchor',
timestamp: p.timestamp,
confidence: 'medium',
summaryKey: 'logs.nmea_change_anchor',
data: p
}, config.dedupeWindowMs)
stoppedSince = null
}
}
if (lastSog != null && Math.abs(sog - lastSog) >= config.speedDeltaKn) {
pushUnique(events, {
type: 'speed',
timestamp: p.timestamp,
confidence: 'low',
summaryKey: 'logs.nmea_change_speed',
summaryParams: { from: lastSog.toFixed(1), to: sog.toFixed(1) },
data: p
}, config.dedupeWindowMs)
}
lastSog = sog
}
return events.sort((a, b) => a.timestamp - b.timestamp)
}

Some files were not shown because too many files have changed in this diff Show More