Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77a7072b77 | |||
| bd1edd89f3 | |||
| ffe6b19818 | |||
| eb1f87f57e | |||
| 13cb03646b | |||
| 1bc0d7fb2a | |||
| 5f3d76b30f | |||
| b48545e943 | |||
| 3749f87c1d | |||
| 2e656dc6b2 | |||
| 484ed66b7b | |||
| 49d77f08a2 | |||
| 951b5b3f1c | |||
| abb708c3d0 | |||
| cc87b0f8e6 | |||
| 58984594b0 | |||
| 61675e1085 | |||
| 2082218f78 | |||
| 5882edcbdf | |||
| b7a47a1d90 | |||
| 48c408302f | |||
| 2b5c5d4a36 | |||
| 7cf04b3357 | |||
| bbd4281dcb | |||
| d2833f7664 | |||
| 2a14080b5b | |||
| 2457fa41e3 | |||
| 87b0fa7bde | |||
| d90f292a21 | |||
| 9e42f828a0 | |||
| 4197e77b1e | |||
| 1373c11de8 | |||
| 0bae3b29dc | |||
| 73e86d28b3 | |||
| ad4721e694 | |||
| 8037b3b63e | |||
| c4cd566da0 | |||
| 3a267905b0 | |||
| c856c2e903 | |||
| b3256d1685 | |||
| 23fc940324 | |||
| 25e1bdded3 | |||
| 6a61c9e06c | |||
| d3683ad6aa |
@@ -1,5 +1,9 @@
|
||||
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
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
<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" />
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ server {
|
||||
client_max_body_size 50M;
|
||||
|
||||
# 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";
|
||||
}
|
||||
|
||||
+6
-1
@@ -10,7 +10,12 @@
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview",
|
||||
"generate:flyer": "node ../scripts/generate-beta-flyer.mjs",
|
||||
"generate:flyer:setup": "playwright install chromium"
|
||||
"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",
|
||||
|
||||
@@ -1371,6 +1371,140 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.dashboard-list-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dashboard-filter-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dashboard-sort-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dashboard-sort-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.dashboard-sort-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.dashboard-sort-group {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dashboard-sort-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--app-border-subtle);
|
||||
background: var(--app-surface-alt);
|
||||
color: var(--app-text-muted);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.dashboard-sort-btn:hover {
|
||||
border-color: var(--app-border);
|
||||
color: var(--app-text-heading);
|
||||
}
|
||||
|
||||
.dashboard-sort-btn.is-active {
|
||||
border-color: var(--app-accent-border);
|
||||
background: var(--app-accent-bg);
|
||||
color: var(--app-accent-light);
|
||||
}
|
||||
|
||||
.dashboard-sort-btn:focus-visible {
|
||||
outline: 2px solid var(--app-accent-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.dashboard-filter-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--app-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dashboard-filter-input-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboard-filter-icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
color: var(--app-text-muted);
|
||||
pointer-events: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dashboard-filter-input {
|
||||
width: 100%;
|
||||
padding-left: 42px;
|
||||
padding-right: 42px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.dashboard-filter-input::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-filter-clear {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--app-text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s, background-color 0.2s;
|
||||
}
|
||||
|
||||
.dashboard-filter-clear:hover {
|
||||
color: var(--app-text-heading);
|
||||
background: var(--app-accent-bg);
|
||||
}
|
||||
|
||||
.dashboard-filter-clear:focus-visible {
|
||||
outline: 2px solid var(--app-accent-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.dashboard-filter-meta {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.section-title-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -2322,6 +2456,37 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.dashboard-sort-bar {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-sort-label {
|
||||
flex-shrink: 0;
|
||||
min-width: 4.75rem;
|
||||
}
|
||||
|
||||
.dashboard-sort-row {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-wrap: nowrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-sort-group {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dashboard-sort-btn {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.logbooks-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
@@ -3068,6 +3233,98 @@ html.theme-cupertino .events-scroll-container {
|
||||
}
|
||||
}
|
||||
|
||||
.tank-liter-input .tank-liter-slider {
|
||||
--tank-slider-track-h: 10px;
|
||||
--tank-slider-thumb: 26px;
|
||||
width: 100%;
|
||||
height: var(--tank-slider-thumb);
|
||||
margin: 10px 0 6px;
|
||||
padding: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
accent-color: #4ade80;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.tank-liter-input .tank-liter-slider::-webkit-slider-runnable-track {
|
||||
height: var(--tank-slider-track-h);
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.35);
|
||||
}
|
||||
|
||||
.tank-liter-input .tank-liter-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: var(--tank-slider-thumb);
|
||||
height: var(--tank-slider-thumb);
|
||||
margin-top: calc((var(--tank-slider-track-h) - var(--tank-slider-thumb)) / 2);
|
||||
border-radius: 50%;
|
||||
background: #4ade80;
|
||||
border: 2px solid rgba(15, 23, 42, 0.85);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.tank-liter-input .tank-liter-slider::-moz-range-track {
|
||||
height: var(--tank-slider-track-h);
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.35);
|
||||
}
|
||||
|
||||
.tank-liter-input .tank-liter-slider::-moz-range-thumb {
|
||||
width: var(--tank-slider-thumb);
|
||||
height: var(--tank-slider-thumb);
|
||||
border-radius: 50%;
|
||||
background: #4ade80;
|
||||
border: 2px solid rgba(15, 23, 42, 0.85);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.tank-liter-input .tank-liter-slider:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tank-liter-input .tank-liter-slider-hint {
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.tank-liter-input .tank-liter-slider {
|
||||
--tank-slider-track-h: 12px;
|
||||
--tank-slider-thumb: 32px;
|
||||
margin: 12px 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.vessel-tanks-section {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.vessel-tanks-section h3 {
|
||||
font-size: 1rem;
|
||||
margin: 0 0 4px;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.vessel-tanks-help {
|
||||
font-size: 0.85rem;
|
||||
color: #94a3b8;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.vessel-tanks-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* GPS Track Upload & Map Styling */
|
||||
.track-upload-zone {
|
||||
display: flex;
|
||||
@@ -3540,6 +3797,59 @@ html.theme-cupertino .events-scroll-container {
|
||||
.stats-kpi-value {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.profile-stats-section.form-card {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.profile-stats-section .form-header {
|
||||
margin-bottom: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.profile-stats-section .form-header h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.profile-stats-section .stats-subtitle {
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.profile-stats-kpi-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.profile-stats-kpi-grid .stats-kpi-card {
|
||||
padding: 10px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.profile-stats-kpi-grid .stats-kpi-icon {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.profile-stats-kpi-grid .stats-kpi-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.profile-stats-kpi-grid .stats-kpi-label {
|
||||
font-size: 11px;
|
||||
margin-bottom: 2px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.profile-stats-kpi-grid .stats-kpi-value {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.profile-stats-kpi-grid .stats-kpi-unit {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.signature-grid {
|
||||
|
||||
+10
-3
@@ -1,5 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import './App.css'
|
||||
import { DialogProvider } from './components/ModalDialog.tsx'
|
||||
import AuthOnboarding from './components/AuthOnboarding.tsx'
|
||||
import UserProfilePage from './components/UserProfilePage.tsx'
|
||||
@@ -29,6 +28,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'
|
||||
@@ -45,6 +45,7 @@ import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, La
|
||||
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
|
||||
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage } from './utils/i18nLanguages.js'
|
||||
import {
|
||||
resolveTourLogbookContext,
|
||||
seedDemoLogbookIfNeeded
|
||||
@@ -151,6 +152,13 @@ function App() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return
|
||||
void syncAppearancePrefs(userId)
|
||||
}, [isAuthenticated])
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => {
|
||||
setOnline(true)
|
||||
@@ -489,8 +497,7 @@ function App() {
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
|
||||
i18n.changeLanguage(nextLang)
|
||||
cycleAppLanguage(i18n)
|
||||
}
|
||||
|
||||
const handleExitDemo = () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
||||
import {
|
||||
registerUser,
|
||||
loginUser,
|
||||
@@ -209,8 +210,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 = () => {
|
||||
@@ -379,16 +379,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>}
|
||||
|
||||
@@ -575,7 +596,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
<div className="auth-footer">
|
||||
<button type="button" className="btn-icon-text" onClick={toggleLanguage}>
|
||||
<Languages size={18} />
|
||||
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
|
||||
{t(`languages.${getNextLanguage(i18n.language)}`)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
||||
import VesselForm from './VesselForm.tsx'
|
||||
import CrewForm from './CrewForm.tsx'
|
||||
import LogEntriesList from './LogEntriesList.tsx'
|
||||
@@ -31,7 +32,9 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
||||
registerNavigation({
|
||||
setActiveTab,
|
||||
setSelectedEntryId: setTourSelectedEntryId,
|
||||
setFeedbackOpen: () => {}
|
||||
setFeedbackOpen: () => {},
|
||||
setLogbookActive: () => {},
|
||||
setProfileOpen: () => {}
|
||||
})
|
||||
registerDemoTourContext({ firstEntryId: fixture.firstEntryId })
|
||||
|
||||
@@ -46,8 +49,7 @@ 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
|
||||
@@ -85,7 +87,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>
|
||||
|
||||
@@ -172,6 +172,7 @@ export default function FeedbackModal({
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -308,7 +309,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
i18n.changeLanguage(i18n.language.startsWith('de') ? 'en' : 'de')
|
||||
cycleAppLanguage(i18n)
|
||||
}
|
||||
|
||||
if (recoveryPhrase) {
|
||||
@@ -344,15 +345,36 @@ 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)}>
|
||||
{t('auth.back')}
|
||||
@@ -490,7 +512,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
<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} />
|
||||
{i18n.language.startsWith('de') ? t('invitation.switch_language_en') : t('invitation.switch_language_de')}
|
||||
{t(`languages.${getNextLanguage(i18n.language)}`)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -241,14 +241,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 +258,7 @@ export default function LogEntriesList({
|
||||
if (!confirmed) {
|
||||
freshwater = emptyTankLevels()
|
||||
fuel = emptyTankLevels()
|
||||
greywaterLevel = 0
|
||||
departure = ''
|
||||
}
|
||||
}
|
||||
@@ -274,6 +276,7 @@ export default function LogEntriesList({
|
||||
destination: '',
|
||||
freshwater,
|
||||
fuel,
|
||||
...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}),
|
||||
signSkipper: '',
|
||||
signCrew: '',
|
||||
events: []
|
||||
|
||||
@@ -42,6 +42,14 @@ import {
|
||||
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
|
||||
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
||||
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
|
||||
import TankLiterInput from './TankLiterInput.tsx'
|
||||
import {
|
||||
computeEveningTankMaxLiters,
|
||||
computeRefilledTankMaxLiters,
|
||||
extractTankCapacitiesFromYacht,
|
||||
formatTankLitersForInput,
|
||||
type VesselTankCapacities
|
||||
} from '../utils/tankCapacity.js'
|
||||
|
||||
function emptyTankLevels() {
|
||||
return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
|
||||
@@ -50,6 +58,7 @@ function emptyTankLevels() {
|
||||
function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string {
|
||||
const fw = (decrypted.freshwater as Record<string, number> | undefined) ?? emptyTankLevels()
|
||||
const fuel = (decrypted.fuel as Record<string, number> | undefined) ?? emptyTankLevels()
|
||||
const gw = decrypted.greywater as { level?: number } | undefined
|
||||
const trackDistance = decrypted.trackDistanceNm
|
||||
const trackSpeedMax = decrypted.trackSpeedMaxKn
|
||||
const trackSpeedAvg = decrypted.trackSpeedAvgKn
|
||||
@@ -72,6 +81,7 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
|
||||
evening: fuel.evening || 0,
|
||||
consumption: fuel.consumption ?? 0
|
||||
},
|
||||
greywater: gw ? { level: gw.level || 0 } : undefined,
|
||||
trackDistanceNm:
|
||||
trackDistance != null && trackDistance !== ''
|
||||
? parseFloat(String(trackDistance))
|
||||
@@ -145,6 +155,9 @@ export default function LogEntryEditor({
|
||||
const [fuelEvening, setFuelEvening] = useState('0')
|
||||
const [fuelConsumption, setFuelConsumption] = useState('0')
|
||||
|
||||
const [greywaterLevel, setGreywaterLevel] = useState('0')
|
||||
const [tankCapacities, setTankCapacities] = useState<VesselTankCapacities>({})
|
||||
|
||||
// Signatures
|
||||
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
|
||||
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
|
||||
@@ -249,6 +262,7 @@ export default function LogEntryEditor({
|
||||
evening: parseFloat(fuelEvening) || 0,
|
||||
consumption: parseFloat(fuelConsumption) || 0
|
||||
},
|
||||
greywater: { level: parseFloat(greywaterLevel) || 0 },
|
||||
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
|
||||
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
|
||||
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
|
||||
@@ -259,6 +273,7 @@ export default function LogEntryEditor({
|
||||
date, dayOfTravel, departure, destination,
|
||||
fwMorning, fwRefilled, fwEvening, fwConsumption,
|
||||
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
|
||||
greywaterLevel,
|
||||
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
|
||||
events
|
||||
])
|
||||
@@ -268,6 +283,38 @@ export default function LogEntryEditor({
|
||||
[fuelConsumption, motorHours]
|
||||
)
|
||||
|
||||
const tankCapacityTooltip = t('logs.tank_capacity_tooltip')
|
||||
|
||||
const fwRefilledMax = useMemo(
|
||||
() => computeRefilledTankMaxLiters(fwMorning, tankCapacities.freshwaterCapacityL),
|
||||
[fwMorning, tankCapacities.freshwaterCapacityL]
|
||||
)
|
||||
|
||||
const fwEveningMax = useMemo(
|
||||
() =>
|
||||
computeEveningTankMaxLiters(
|
||||
fwMorning,
|
||||
fwRefilled,
|
||||
tankCapacities.freshwaterCapacityL
|
||||
),
|
||||
[fwMorning, fwRefilled, tankCapacities.freshwaterCapacityL]
|
||||
)
|
||||
|
||||
const fuelRefilledMax = useMemo(
|
||||
() => computeRefilledTankMaxLiters(fuelMorning, tankCapacities.fuelCapacityL),
|
||||
[fuelMorning, tankCapacities.fuelCapacityL]
|
||||
)
|
||||
|
||||
const fuelEveningMax = useMemo(
|
||||
() =>
|
||||
computeEveningTankMaxLiters(
|
||||
fuelMorning,
|
||||
fuelRefilled,
|
||||
tankCapacities.fuelCapacityL
|
||||
),
|
||||
[fuelMorning, fuelRefilled, tankCapacities.fuelCapacityL]
|
||||
)
|
||||
|
||||
const currentFingerprint = useMemo(() => {
|
||||
const payload = buildPayloadForSigning()
|
||||
return JSON.stringify({
|
||||
@@ -527,11 +574,59 @@ export default function LogEntryEditor({
|
||||
setFuelConsumption(cons >= 0 ? String(cons) : '0')
|
||||
}, [fuelMorning, fuelRefilled, fuelEvening])
|
||||
|
||||
// Load Yacht Sails
|
||||
const fwRefilledNoCapacity =
|
||||
(tankCapacities.freshwaterCapacityL ?? 0) > 0 && fwRefilledMax == null
|
||||
const fuelRefilledNoCapacity =
|
||||
(tankCapacities.fuelCapacityL ?? 0) > 0 && fuelRefilledMax == null
|
||||
|
||||
useEffect(() => {
|
||||
async function loadYachtSails() {
|
||||
if (readOnly && preloadedYacht?.sails) {
|
||||
setYachtSails(preloadedYacht.sails)
|
||||
const refilled = parseFloat(fwRefilled) || 0
|
||||
if (fwRefilledMax == null) {
|
||||
if (fwRefilledNoCapacity && refilled > 0) {
|
||||
setFwRefilled(formatTankLitersForInput(0))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (refilled > fwRefilledMax) {
|
||||
setFwRefilled(formatTankLitersForInput(fwRefilledMax))
|
||||
}
|
||||
}, [fwRefilledMax, fwRefilled, fwRefilledNoCapacity])
|
||||
|
||||
useEffect(() => {
|
||||
if (fwEveningMax == null) return
|
||||
const evening = parseFloat(fwEvening) || 0
|
||||
if (evening > fwEveningMax) {
|
||||
setFwEvening(formatTankLitersForInput(fwEveningMax))
|
||||
}
|
||||
}, [fwEveningMax, fwEvening])
|
||||
|
||||
useEffect(() => {
|
||||
const refilled = parseFloat(fuelRefilled) || 0
|
||||
if (fuelRefilledMax == null) {
|
||||
if (fuelRefilledNoCapacity && refilled > 0) {
|
||||
setFuelRefilled(formatTankLitersForInput(0))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (refilled > fuelRefilledMax) {
|
||||
setFuelRefilled(formatTankLitersForInput(fuelRefilledMax))
|
||||
}
|
||||
}, [fuelRefilledMax, fuelRefilled, fuelRefilledNoCapacity])
|
||||
|
||||
useEffect(() => {
|
||||
if (fuelEveningMax == null) return
|
||||
const evening = parseFloat(fuelEvening) || 0
|
||||
if (evening > fuelEveningMax) {
|
||||
setFuelEvening(formatTankLitersForInput(fuelEveningMax))
|
||||
}
|
||||
}, [fuelEveningMax, fuelEvening])
|
||||
|
||||
// Load yacht sails and tank capacities
|
||||
useEffect(() => {
|
||||
async function loadYachtMeta() {
|
||||
if (readOnly && preloadedYacht) {
|
||||
if (preloadedYacht.sails) setYachtSails(preloadedYacht.sails)
|
||||
setTankCapacities(extractTankCapacitiesFromYacht(preloadedYacht))
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -541,16 +636,19 @@ export default function LogEntryEditor({
|
||||
const yacht = await db.yachts.get(logbookId)
|
||||
if (yacht) {
|
||||
const decrypted = await decryptJson(yacht.encryptedData, yacht.iv, yacht.tag, masterKey)
|
||||
if (decrypted && decrypted.sails && Array.isArray(decrypted.sails)) {
|
||||
setYachtSails(decrypted.sails)
|
||||
if (decrypted) {
|
||||
if (decrypted.sails && Array.isArray(decrypted.sails)) {
|
||||
setYachtSails(decrypted.sails)
|
||||
}
|
||||
setTankCapacities(extractTankCapacitiesFromYacht(decrypted))
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load yacht sails in editor:', err)
|
||||
console.error('Failed to load yacht meta in editor:', err)
|
||||
}
|
||||
}
|
||||
loadYachtSails()
|
||||
}, [logbookId, preloadedYacht])
|
||||
loadYachtMeta()
|
||||
}, [logbookId, preloadedYacht, readOnly])
|
||||
|
||||
// Load entry details
|
||||
useEffect(() => {
|
||||
@@ -580,6 +678,11 @@ export default function LogEntryEditor({
|
||||
setFuelEvening(String(preloadedEntry.fuel.evening || 0))
|
||||
setFuelConsumption(String(preloadedEntry.fuel.consumption ?? 0))
|
||||
}
|
||||
if (preloadedEntry.greywater) {
|
||||
setGreywaterLevel(String(preloadedEntry.greywater.level || 0))
|
||||
} else {
|
||||
setGreywaterLevel('0')
|
||||
}
|
||||
|
||||
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
|
||||
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
||||
@@ -613,6 +716,11 @@ export default function LogEntryEditor({
|
||||
setFuelEvening(String(decrypted.fuel.evening || 0))
|
||||
setFuelConsumption(String(decrypted.fuel.consumption ?? 0))
|
||||
}
|
||||
if (decrypted.greywater) {
|
||||
setGreywaterLevel(String(decrypted.greywater.level || 0))
|
||||
} else {
|
||||
setGreywaterLevel('0')
|
||||
}
|
||||
|
||||
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
|
||||
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
||||
@@ -1210,41 +1318,35 @@ export default function LogEntryEditor({
|
||||
<h3>{t('logs.freshwater')}</h3>
|
||||
</div>
|
||||
<div className="consumption-grid">
|
||||
<TankLiterInput
|
||||
id="fw-morning"
|
||||
label={t('logs.morning')}
|
||||
value={fwMorning}
|
||||
onChange={setFwMorning}
|
||||
maxLiters={tankCapacities.freshwaterCapacityL}
|
||||
disabled={saving || readOnly}
|
||||
titleTooltip={tankCapacityTooltip}
|
||||
/>
|
||||
<TankLiterInput
|
||||
id="fw-refilled"
|
||||
label={t('logs.refilled')}
|
||||
value={fwRefilled}
|
||||
onChange={setFwRefilled}
|
||||
maxLiters={fwRefilledMax}
|
||||
disabled={saving || readOnly || fwRefilledNoCapacity}
|
||||
titleTooltip={tankCapacityTooltip}
|
||||
/>
|
||||
<TankLiterInput
|
||||
id="fw-evening"
|
||||
label={t('logs.evening')}
|
||||
value={fwEvening}
|
||||
onChange={setFwEvening}
|
||||
maxLiters={fwEveningMax}
|
||||
disabled={saving || readOnly}
|
||||
titleTooltip={tankCapacityTooltip}
|
||||
/>
|
||||
<div className="input-group">
|
||||
<label>{t('logs.morning')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text"
|
||||
value={fwMorning}
|
||||
onChange={(e) => setFwMorning(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.refilled')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text"
|
||||
value={fwRefilled}
|
||||
onChange={(e) => setFwRefilled(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.evening')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text"
|
||||
value={fwEvening}
|
||||
onChange={(e) => setFwEvening(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.consumption')} (L)</label>
|
||||
<label title={tankCapacityTooltip}>{t('logs.consumption')} (L)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text consumption-value"
|
||||
@@ -1252,6 +1354,7 @@ export default function LogEntryEditor({
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
aria-readonly="true"
|
||||
title={tankCapacityTooltip}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1264,41 +1367,35 @@ export default function LogEntryEditor({
|
||||
<h3>{t('logs.fuel')}</h3>
|
||||
</div>
|
||||
<div className="consumption-grid">
|
||||
<TankLiterInput
|
||||
id="fuel-morning"
|
||||
label={t('logs.morning')}
|
||||
value={fuelMorning}
|
||||
onChange={setFuelMorning}
|
||||
maxLiters={tankCapacities.fuelCapacityL}
|
||||
disabled={saving || readOnly}
|
||||
titleTooltip={tankCapacityTooltip}
|
||||
/>
|
||||
<TankLiterInput
|
||||
id="fuel-refilled"
|
||||
label={t('logs.refilled')}
|
||||
value={fuelRefilled}
|
||||
onChange={setFuelRefilled}
|
||||
maxLiters={fuelRefilledMax}
|
||||
disabled={saving || readOnly || fuelRefilledNoCapacity}
|
||||
titleTooltip={tankCapacityTooltip}
|
||||
/>
|
||||
<TankLiterInput
|
||||
id="fuel-evening"
|
||||
label={t('logs.evening')}
|
||||
value={fuelEvening}
|
||||
onChange={setFuelEvening}
|
||||
maxLiters={fuelEveningMax}
|
||||
disabled={saving || readOnly}
|
||||
titleTooltip={tankCapacityTooltip}
|
||||
/>
|
||||
<div className="input-group">
|
||||
<label>{t('logs.morning')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text"
|
||||
value={fuelMorning}
|
||||
onChange={(e) => setFuelMorning(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.refilled')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text"
|
||||
value={fuelRefilled}
|
||||
onChange={(e) => setFuelRefilled(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.evening')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text"
|
||||
value={fuelEvening}
|
||||
onChange={(e) => setFuelEvening(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.consumption')} (L)</label>
|
||||
<label title={tankCapacityTooltip}>{t('logs.consumption')} (L)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text consumption-value"
|
||||
@@ -1306,11 +1403,12 @@ export default function LogEntryEditor({
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
aria-readonly="true"
|
||||
title={tankCapacityTooltip}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.fuel_per_motor_hour')}</label>
|
||||
<label title={tankCapacityTooltip}>{t('logs.fuel_per_motor_hour')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text consumption-value"
|
||||
@@ -1322,10 +1420,30 @@ export default function LogEntryEditor({
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
aria-readonly="true"
|
||||
title={tankCapacityTooltip}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Greywater card */}
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
<Compass size={20} className="form-icon" />
|
||||
<h3>{t('logs.greywater')}</h3>
|
||||
</div>
|
||||
<div className="consumption-grid">
|
||||
<TankLiterInput
|
||||
id="greywater-level"
|
||||
label={t('logs.greywater_level')}
|
||||
value={greywaterLevel}
|
||||
onChange={setGreywaterLevel}
|
||||
maxLiters={tankCapacities.greywaterCapacityL}
|
||||
disabled={saving || readOnly}
|
||||
titleTooltip={tankCapacityTooltip}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 3: Event Journal Entries */}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage } from '../utils/i18nLanguages.js'
|
||||
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
|
||||
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
|
||||
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
|
||||
@@ -7,7 +8,7 @@ import BetaBadge from './BetaBadge.tsx'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { logoutUser } from '../services/auth.js'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
|
||||
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
|
||||
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
||||
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
||||
|
||||
@@ -17,6 +18,46 @@ interface LogbookDashboardProps {
|
||||
onOpenProfile: () => void
|
||||
}
|
||||
|
||||
function logbookMatchesFilter(lb: DecryptedLogbook, query: string, locale: string): boolean {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return true
|
||||
|
||||
if (lb.title.toLowerCase().includes(q)) return true
|
||||
|
||||
const updated = new Date(lb.updatedAt)
|
||||
const year = updated.getFullYear().toString()
|
||||
if (year.includes(q)) return true
|
||||
|
||||
const dateLabel = updated.toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}).toLowerCase()
|
||||
if (dateLabel.includes(q)) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -28,6 +69,10 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filterQuery, setFilterQuery] = useState('')
|
||||
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')
|
||||
|
||||
@@ -149,13 +194,31 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
}
|
||||
|
||||
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 filterActive = filterQuery.trim().length > 0
|
||||
const filteredOwnedLogbooks = useMemo(
|
||||
() => ownedLogbooks.filter((lb) => logbookMatchesFilter(lb, filterQuery, i18n.language)),
|
||||
[ownedLogbooks, filterQuery, i18n.language]
|
||||
)
|
||||
const filteredSharedLogbooks = useMemo(
|
||||
() => sharedLogbooks.filter((lb) => logbookMatchesFilter(lb, filterQuery, i18n.language)),
|
||||
[sharedLogbooks, filterQuery, i18n.language]
|
||||
)
|
||||
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
|
||||
|
||||
@@ -376,17 +439,115 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
) : 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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import VesselForm from './VesselForm.tsx'
|
||||
@@ -48,9 +49,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()
|
||||
@@ -136,15 +137,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>
|
||||
)
|
||||
}
|
||||
@@ -153,10 +153,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>
|
||||
)
|
||||
@@ -173,7 +173,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>
|
||||
@@ -181,7 +181,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>
|
||||
|
||||
@@ -6,6 +6,12 @@ import LogbookBackupPanel from './LogbookBackupPanel.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { apiFetch } from '../services/api.js'
|
||||
import {
|
||||
enableCollaboratorChangePush,
|
||||
isCollaboratorPushActive,
|
||||
isPushSupported
|
||||
} from '../services/pushNotifications.js'
|
||||
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
|
||||
|
||||
interface SettingsFormProps {
|
||||
logbookId?: string | null
|
||||
@@ -151,6 +157,43 @@ 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)
|
||||
@@ -175,6 +218,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
|
||||
setInviteLink(link)
|
||||
trackPlausibleEvent(PlausibleEvents.INVITE_GENERATED)
|
||||
await promptPushAfterInviteCreated()
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to generate invite:', err)
|
||||
showAlert(err instanceof Error ? err.message : 'Failed to generate invite link.')
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -726,7 +726,7 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="form-card">
|
||||
<section className="form-card profile-stats-section">
|
||||
<div className="form-header">
|
||||
<BarChart2 size={24} className="form-icon" />
|
||||
<div>
|
||||
@@ -736,7 +736,7 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
||||
</div>
|
||||
|
||||
{(statsTotals || profile) && (
|
||||
<div className="stats-kpi-grid">
|
||||
<div className="stats-kpi-grid profile-stats-kpi-grid">
|
||||
<KpiCard
|
||||
icon={<BookOpen size={20} />}
|
||||
label={t('profile.stats_logbooks')}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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,
|
||||
@@ -32,6 +33,9 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
|
||||
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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,13 +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
|
||||
/** Prevent Android PWA cold-start reload loops from onNeedReload. */
|
||||
const PWA_INITIAL_RELOAD_KEY = 'pwa_sw_initial_reload_done'
|
||||
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')
|
||||
@@ -22,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 = () => {
|
||||
@@ -34,17 +46,44 @@ 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],
|
||||
@@ -52,39 +91,56 @@ export function usePwaUpdate() {
|
||||
} = useRegisterSW({
|
||||
immediate: !import.meta.env.DEV,
|
||||
onNeedReload() {
|
||||
// First SW takeover requires one reload; guard against repeated reloads on Android PWA resume.
|
||||
if (sessionStorage.getItem(PWA_INITIAL_RELOAD_KEY)) {
|
||||
return
|
||||
}
|
||||
sessionStorage.setItem(PWA_INITIAL_RELOAD_KEY, '1')
|
||||
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])
|
||||
|
||||
@@ -93,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 = () => {
|
||||
|
||||
@@ -3,12 +3,19 @@ import { initReactI18next } from 'react-i18next'
|
||||
import LanguageDetector from 'i18next-browser-languagedetector'
|
||||
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 }
|
||||
de: { translation: deJson.translation },
|
||||
da: { translation: daJson.translation },
|
||||
sv: { translation: svJson.translation },
|
||||
nb: { translation: nbJson.translation }
|
||||
}
|
||||
|
||||
i18n
|
||||
@@ -18,7 +25,7 @@ i18n
|
||||
resources,
|
||||
defaultNS: 'translation',
|
||||
fallbackLng: 'en',
|
||||
supportedLngs: ['de', 'en'],
|
||||
supportedLngs: [...SUPPORTED_LANGUAGES],
|
||||
nonExplicitSupportedLngs: true,
|
||||
load: 'languageOnly',
|
||||
interpolation: {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,736 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"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_leave": "Forladelse",
|
||||
"unsaved_changes_stay": "Bliv her"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"vessel": "Skibsdata",
|
||||
"crew": "Besætningsliste",
|
||||
"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."
|
||||
},
|
||||
"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..."
|
||||
},
|
||||
"sync": {
|
||||
"status_synced": "Synkroniseret",
|
||||
"status_syncing": "Synkroniser...",
|
||||
"status_offline": "Offline-cache",
|
||||
"status_unsynced": "Usynkroniserede ændringer"
|
||||
},
|
||||
"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 / rejsedag",
|
||||
"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": "Besætningsmedlemmer 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 besætning skal skrive under igen.",
|
||||
"sign_lock_active": "Denne post er underskrevet. Ændringer i logbogen (undtagen fotos) fjerner automatisk skipperens og besætningens 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 besætning 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 besætningens 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.",
|
||||
"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_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 besætningen",
|
||||
"invite_link_copied": "Invitationslink kopieret til udklipsholderen!",
|
||||
"invite_link_desc": "Del dette link med besætningsmedlemmer for at give dem skriveadgang til denne logbog.",
|
||||
"collaborators_list": "Medlemmer / besætning",
|
||||
"revoke": "Fjerne",
|
||||
"revoke_confirm": "Er du sikker på, at du vil tilbagekalde dette besætningsmedlems adgang?",
|
||||
"invite_role": "Rolle",
|
||||
"invite_expires": "Linket er gyldigt i 48 timer"
|
||||
},
|
||||
"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 besætningsmedlem. 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 besætning",
|
||||
"role_crew_hint": "Inviteret logbog - du kan arbejde som besætning og underskrive den",
|
||||
"role_read": "Læs kun",
|
||||
"role_read_hint": "Opdelt logbog - kun visning, ingen redigering",
|
||||
"open_profile": "Åben profil af {{name}}",
|
||||
"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 eller dato ...",
|
||||
"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 besætningsmedlemmer synkroniserer ændringer. Intet indhold overføres i ren tekst.",
|
||||
"push_enable": "Giv os besked om ændringer i besætningen",
|
||||
"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."
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper- og besætningsprofiler",
|
||||
"skipper_section": "Skipper-profil",
|
||||
"skipper_read_only_hint": "Skipperprofilen kan kun redigeres af logbogens ejer.",
|
||||
"crew_section": "Besætningsliste",
|
||||
"add_crew": "Tilføj besætningsmedlem",
|
||||
"edit_crew": "Rediger besætningsmedlem",
|
||||
"no_crew": "Ingen besætningsmedlemmer tilføjet endnu.",
|
||||
"max_crew": "Det maksimale antal på 5 besætningsmedlemmer 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": "Besætningsfilerne er indlæst.",
|
||||
"delete_confirm": "Er du sikker på, at du vil fjerne dette besætningsmedlem?"
|
||||
},
|
||||
"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 besætning. 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",
|
||||
"danger_zone_title": "Farezone",
|
||||
"danger_zone_desc": "Når du sletter din konto, slettes alle dine Passkey'er, logbøger, skibsdata, besætningsprofiler, 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 besætningsmedlemmer synkroniserer ændringer, kan du blive informeret via push. Intet logbogsindhold sendes i almindelig tekst.",
|
||||
"invite_push_prompt_ios_message": "Så snart besætningsmedlemmerne 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, besætning, 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}} Besætningens 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"
|
||||
},
|
||||
"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 en konto. Denne korte tur viser dig skibsdata, besætning og logbogsposter."
|
||||
},
|
||||
"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": "Skibsdata",
|
||||
"body": "Indtast navn, dimensioner og tekniske data for din yacht - udfyld én gang, tilgængelig for alle rejsedage."
|
||||
},
|
||||
"nav_crew": {
|
||||
"title": "Besætningsliste",
|
||||
"body": "Administrer besætningsmedlemmer og tildel dem rejsedage senere."
|
||||
},
|
||||
"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, besætnings- 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,13 @@
|
||||
"beta": "Beta",
|
||||
"beta_hint": "Beta-Version — Funktionen können sich noch ändern"
|
||||
},
|
||||
"languages": {
|
||||
"de": "Deutsch",
|
||||
"en": "English",
|
||||
"da": "Dansk",
|
||||
"sv": "Svenska",
|
||||
"nb": "Norsk"
|
||||
},
|
||||
"common": {
|
||||
"unsaved_changes_title": "Ungespeicherte Änderungen",
|
||||
"unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.",
|
||||
@@ -117,7 +124,13 @@
|
||||
"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",
|
||||
@@ -138,6 +151,10 @@
|
||||
"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",
|
||||
@@ -183,7 +200,7 @@
|
||||
"delete_entry": "Tag löschen",
|
||||
"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",
|
||||
@@ -294,7 +311,23 @@
|
||||
"edit_title": "Logbuch umbenennen",
|
||||
"edit_placeholder": "Neuer Name des Logbuchs",
|
||||
"edit_success": "Logbuch erfolgreich umbenannt",
|
||||
"edit_btn": "Umbenennen"
|
||||
"edit_btn": "Umbenennen",
|
||||
"filter_label": "Logbücher filtern",
|
||||
"filter_placeholder": "Name, Jahr oder Datum …",
|
||||
"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",
|
||||
@@ -472,6 +505,12 @@
|
||||
"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…",
|
||||
"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",
|
||||
@@ -535,6 +574,7 @@
|
||||
"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",
|
||||
|
||||
@@ -6,6 +6,13 @@
|
||||
"beta": "Beta",
|
||||
"beta_hint": "Beta release — features may still change"
|
||||
},
|
||||
"languages": {
|
||||
"de": "Deutsch",
|
||||
"en": "English",
|
||||
"da": "Dansk",
|
||||
"sv": "Svenska",
|
||||
"nb": "Norsk"
|
||||
},
|
||||
"common": {
|
||||
"unsaved_changes_title": "Unsaved changes",
|
||||
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.",
|
||||
@@ -117,7 +124,13 @@
|
||||
"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",
|
||||
@@ -138,6 +151,10 @@
|
||||
"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",
|
||||
@@ -183,7 +200,7 @@
|
||||
"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",
|
||||
@@ -294,7 +311,23 @@
|
||||
"edit_title": "Rename Logbook",
|
||||
"edit_placeholder": "New name of the logbook",
|
||||
"edit_success": "Logbook renamed successfully",
|
||||
"edit_btn": "Rename"
|
||||
"edit_btn": "Rename",
|
||||
"filter_label": "Filter logbooks",
|
||||
"filter_placeholder": "Name, year or date …",
|
||||
"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",
|
||||
@@ -472,6 +505,12 @@
|
||||
"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…",
|
||||
"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",
|
||||
@@ -535,6 +574,7 @@
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,736 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"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_leave": "Oppgivelse",
|
||||
"unsaved_changes_stay": "Bli"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashbord",
|
||||
"vessel": "Skipsdata",
|
||||
"crew": "Mannskapsliste",
|
||||
"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."
|
||||
},
|
||||
"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..."
|
||||
},
|
||||
"sync": {
|
||||
"status_synced": "Synkronisert",
|
||||
"status_syncing": "Synkroniser...",
|
||||
"status_offline": "Frakoblet hurtigbuffer",
|
||||
"status_unsynced": "Usynkroniserte endringer"
|
||||
},
|
||||
"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": "Reisens dag / reisedag",
|
||||
"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": "Mannskapets 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": "Besetningsmedlemmer 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 mannskap må signere på nytt.",
|
||||
"sign_lock_active": "Denne oppføringen er signert. Endringer i loggboken (unntatt bilder) fjerner automatisk skipperens og mannskapets 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 mannskap 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 mannskapets 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...",
|
||||
"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_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 mannskapet",
|
||||
"invite_link_copied": "Invitasjonslenke kopiert til utklippstavlen!",
|
||||
"invite_link_desc": "Del denne lenken med besetningsmedlemmene for å gi dem skrivetilgang til loggboken.",
|
||||
"collaborators_list": "Medlemmer / Besetning",
|
||||
"revoke": "Fjern",
|
||||
"revoke_confirm": "Er du sikker på at du vil oppheve dette besetningsmedlemmets tilgang?",
|
||||
"invite_role": "Rolle",
|
||||
"invite_expires": "Lenken er gyldig i 48 timer"
|
||||
},
|
||||
"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 besetningsmedlem. 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 mannskapet",
|
||||
"role_crew_hint": "Loggbok med invitasjon - du kan jobbe som mannskap og signere den",
|
||||
"role_read": "Bare les",
|
||||
"role_read_hint": "Delt loggbok - kun visning, ingen redigering",
|
||||
"open_profile": "Åpne profilen til {{name}}",
|
||||
"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 eller dato ...",
|
||||
"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 besetningsmedlemmer synkroniserer endringer. Ingen innhold overføres i ren tekst.",
|
||||
"push_enable": "Gi oss beskjed om endringer i mannskapet",
|
||||
"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."
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper- og mannskapsprofiler",
|
||||
"skipper_section": "Skipperprofil",
|
||||
"skipper_read_only_hint": "Skipperprofilen kan bare redigeres av eieren av loggboken.",
|
||||
"crew_section": "Mannskapsliste",
|
||||
"add_crew": "Legg til besetningsmedlem",
|
||||
"edit_crew": "Rediger besetningsmedlem",
|
||||
"no_crew": "Ingen besetningsmedlemmer er lagt til ennå.",
|
||||
"max_crew": "Maksimalt antall på 5 besetningsmedlemmer 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": "Mannskapsfilene er lastet inn...",
|
||||
"delete_confirm": "Er du sikker på at du vil fjerne dette besetningsmedlemmet?"
|
||||
},
|
||||
"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 mannskapet 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",
|
||||
"danger_zone_title": "Faresone",
|
||||
"danger_zone_desc": "Hvis du sletter kontoen din, slettes alle dine Passkeys, loggbøker, skipsdata, mannskapsprofiler, 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 besetningsmedlemmer synkroniserer endringer, kan du bli informert via push. Ingen loggbokinnhold sendes i ren tekst.",
|
||||
"invite_push_prompt_ios_message": "Så snart besetningsmedlemmene 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, mannskap, 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}} Mannskapsposter",
|
||||
"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"
|
||||
},
|
||||
"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. Denne korte omvisningen viser deg skipsdata, mannskap og loggbokoppføringer."
|
||||
},
|
||||
"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": "Skipsdata",
|
||||
"body": "Skriv inn navn, dimensjoner og tekniske data for båten din - fyll inn én gang, tilgjengelig for alle reisedager."
|
||||
},
|
||||
"nav_crew": {
|
||||
"title": "Mannskapsliste",
|
||||
"body": "Administrer mannskapet og tilordne dem til reisedager senere."
|
||||
},
|
||||
"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, mannskaps- 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"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_leave": "Övergivande",
|
||||
"unsaved_changes_stay": "Stanna kvar"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Instrumentpanel",
|
||||
"vessel": "Fartygsdata",
|
||||
"crew": "Besättningslista",
|
||||
"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."
|
||||
},
|
||||
"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..."
|
||||
},
|
||||
"sync": {
|
||||
"status_synced": "Synkroniserad",
|
||||
"status_syncing": "Synkronisera...",
|
||||
"status_offline": "Offline-cache",
|
||||
"status_unsynced": "Osynkroniserade förändringar"
|
||||
},
|
||||
"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 / resedag",
|
||||
"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": "Besättningens 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": "Besättningsmedlemmar 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 besättningen måste underteckna på nytt.",
|
||||
"sign_lock_active": "Denna post är signerad. Ändringar i loggboken (utom foton) tar automatiskt bort skepparens och besättningens 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 besättningen 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 besättningens 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...",
|
||||
"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_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 besättningen",
|
||||
"invite_link_copied": "Länk till inbjudan kopierad till urklipp!",
|
||||
"invite_link_desc": "Dela den här länken med besättningsmedlemmar för att ge dem skrivrättigheter till loggboken.",
|
||||
"collaborators_list": "Medlemmar / Besättning",
|
||||
"revoke": "Ta bort",
|
||||
"revoke_confirm": "Är du säker på att du vill återkalla den här besättningsmedlemmens åtkomst?",
|
||||
"invite_role": "Roll",
|
||||
"invite_expires": "Länken är giltig i 48 timmar"
|
||||
},
|
||||
"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 besättningsmedlem. 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 besättningen",
|
||||
"role_crew_hint": "Inbjuden loggbok - du kan arbeta som besättning och underteckna den",
|
||||
"role_read": "Endast läsning",
|
||||
"role_read_hint": "Delad loggbok - endast visning, ingen redigering",
|
||||
"open_profile": "Öppna profil för {{name}}",
|
||||
"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 eller datum ...",
|
||||
"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 besättningsmedlemmar synkroniserar ändringar. Inget innehåll överförs i klartext.",
|
||||
"push_enable": "Meddela oss om förändringar i besättningen",
|
||||
"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."
|
||||
},
|
||||
"crew": {
|
||||
"title": "Profiler för skeppare och besättning",
|
||||
"skipper_section": "Skepparens profil",
|
||||
"skipper_read_only_hint": "Skepparens profil kan endast redigeras av loggbokens ägare.",
|
||||
"crew_section": "Besättningslista",
|
||||
"add_crew": "Lägg till besättningsmedlem",
|
||||
"edit_crew": "Redigera besättningsmedlem",
|
||||
"no_crew": "Inga besättningsmedlemmar har lagts till ännu.",
|
||||
"max_crew": "Maximalt antal på 5 besättningsmedlemmar uppnås.",
|
||||
"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": "Besättningsfilerna är laddade...",
|
||||
"delete_confirm": "Är du säker på att du vill ta bort den här besättningsmedlemmen?"
|
||||
},
|
||||
"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 besättning. 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",
|
||||
"danger_zone_title": "Farlig zon",
|
||||
"danger_zone_desc": "Om du raderar ditt konto raderas oåterkalleligen alla dina Passkey, loggböcker, fartygsdata, besättningsprofiler, 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 besättningsmedlemmar synkroniserar ändringar kan du bli informerad via push. Inget loggboksinnehåll skickas i klartext.",
|
||||
"invite_push_prompt_ios_message": "Så snart besättningsmedlemmar 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, besättning, 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}} Besättningens uppgifter",
|
||||
"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 besättningen",
|
||||
"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"
|
||||
},
|
||||
"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. Den här korta rundturen visar dig fartygsdata, besättning och loggboksanteckningar."
|
||||
},
|
||||
"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": "Fartygsdata",
|
||||
"body": "Ange namn, dimensioner och tekniska data för din yacht - fyll i en gång, tillgänglig för alla resdagar."
|
||||
},
|
||||
"nav_crew": {
|
||||
"title": "Besättningslista",
|
||||
"body": "Hantera besättningsmedlemmar och tilldela dem resdagar senare."
|
||||
},
|
||||
"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, besättnings- 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
-100
@@ -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,44 +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;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
#root {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,15 @@ import { createRoot } from 'react-dom/client'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import './themes.css'
|
||||
import './index.css'
|
||||
import './App.css'
|
||||
import './i18n'
|
||||
import App from './App.tsx'
|
||||
import { applyAppearanceToDocument } from './services/appearance.ts'
|
||||
import {
|
||||
installStaleAssetRecovery,
|
||||
markReloadAttempt,
|
||||
reconcileVersionOnStartup
|
||||
} from './services/pwaStartup.ts'
|
||||
|
||||
/** Stale PWA precache on localhost can shadow Vite dev modules. */
|
||||
async function clearDevServiceWorkerCaches(): Promise<void> {
|
||||
@@ -35,8 +41,19 @@ function renderBootstrapError(message: string): void {
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
applyAppearanceToDocument()
|
||||
installStaleAssetRecovery()
|
||||
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')
|
||||
|
||||
@@ -34,7 +34,8 @@ export const PlausibleEvents = {
|
||||
LOCAL_PIN_SET: 'Local PIN Set',
|
||||
LOCAL_PIN_REMOVED: 'Local PIN Removed',
|
||||
DEVICE_FORGOTTEN: 'Device Forgotten',
|
||||
RECOVERY_ROTATED: 'Recovery Rotated'
|
||||
RECOVERY_ROTATED: 'Recovery Rotated',
|
||||
LANGUAGE_CHANGED: 'Language Changed'
|
||||
} as const
|
||||
|
||||
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -88,6 +88,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
'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'
|
||||
];
|
||||
|
||||
@@ -123,6 +124,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) {
|
||||
@@ -137,6 +139,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
'', '', '',
|
||||
fwM, fwR, fwE, fwCons,
|
||||
fuelM, fuelR, fuelE, fuelCons,
|
||||
greywaterLevel,
|
||||
yachtName, homePort, owner, charter, registration, callsign, atis, mmsi
|
||||
].map(escapeCsvValue));
|
||||
} else {
|
||||
@@ -153,6 +156,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -26,6 +27,7 @@ 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>>
|
||||
}
|
||||
@@ -58,7 +60,7 @@ export interface PublicDemoFixture {
|
||||
}
|
||||
|
||||
export function buildDemoDays(): DemoDaySpec[] {
|
||||
const isDe = i18n.language.startsWith('de')
|
||||
const isDe = isGermanLocale(i18n.language)
|
||||
return [
|
||||
{
|
||||
date: '2026-05-29',
|
||||
@@ -69,6 +71,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',
|
||||
@@ -101,6 +104,7 @@ 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: [
|
||||
{
|
||||
@@ -134,6 +138,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',
|
||||
@@ -161,7 +166,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',
|
||||
@@ -176,12 +181,15 @@ 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 buildDemoCrewRecords(): DemoCrewRecord[] {
|
||||
const isDe = i18n.language.startsWith('de')
|
||||
const isDe = isGermanLocale(i18n.language)
|
||||
return [
|
||||
{
|
||||
payloadId: 'skipper',
|
||||
@@ -244,6 +252,10 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
|
||||
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
|
||||
@@ -303,6 +315,10 @@ export function buildDemoEntryPayloads(): Array<{
|
||||
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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { apiFetch } from './api.js'
|
||||
|
||||
export type FeedbackCategory = 'bug' | 'feature' | 'general'
|
||||
export type FeedbackCategory = 'bug' | 'feature' | 'general' | 'translation'
|
||||
|
||||
export class FeedbackApiError extends Error {
|
||||
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' | 'INVALID_EMAIL' | 'RATE_LIMITED' | 'SPAM_DETECTED'
|
||||
|
||||
@@ -197,13 +197,15 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
||||
doc.text('VERBRAUCHSWERTE / CONSUMPTON STATS', 10, footerY + 3);
|
||||
|
||||
let fwY = footerY + 5;
|
||||
doc.rect(10, fwY, 110, rowHeight * 3, 'S');
|
||||
const tankRows = 4;
|
||||
doc.rect(10, fwY, 110, rowHeight * tankRows, 'S');
|
||||
doc.line(10, fwY + rowHeight, 120, fwY + rowHeight);
|
||||
doc.line(10, fwY + rowHeight * 2, 120, fwY + rowHeight * 2);
|
||||
doc.line(40, fwY, 40, fwY + rowHeight * 3);
|
||||
doc.line(60, fwY, 60, fwY + rowHeight * 3);
|
||||
doc.line(80, fwY, 80, fwY + rowHeight * 3);
|
||||
doc.line(100, fwY, 100, fwY + rowHeight * 3);
|
||||
doc.line(10, fwY + rowHeight * 3, 120, fwY + rowHeight * 3);
|
||||
doc.line(40, fwY, 40, fwY + rowHeight * tankRows);
|
||||
doc.line(60, fwY, 60, fwY + rowHeight * tankRows);
|
||||
doc.line(80, fwY, 80, fwY + rowHeight * tankRows);
|
||||
doc.line(100, fwY, 100, fwY + rowHeight * tankRows);
|
||||
|
||||
doc.setFont('Helvetica', 'bold');
|
||||
doc.setFontSize(7.5);
|
||||
@@ -226,6 +228,12 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
||||
doc.text(String(entry.fuel?.evening ?? '0'), 81, fwY + rowHeight * 2 + 4.2);
|
||||
doc.text(String(entry.fuel?.consumption ?? '0'), 101, fwY + rowHeight * 2 + 4.2);
|
||||
|
||||
doc.text('Grauwasser', 11, fwY + rowHeight * 3 + 4.2);
|
||||
doc.text('—', 41, fwY + rowHeight * 3 + 4.2);
|
||||
doc.text('—', 61, fwY + rowHeight * 3 + 4.2);
|
||||
doc.text(String(entry.greywater?.level ?? '0'), 81, fwY + rowHeight * 3 + 4.2);
|
||||
doc.text('—', 101, fwY + rowHeight * 3 + 4.2);
|
||||
|
||||
// Signatures Box
|
||||
let sigX = 130;
|
||||
let sigY = footerY + 5;
|
||||
|
||||
@@ -43,6 +43,18 @@ async function fetchVapidPublicKey(): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/** True when crew-change push is enabled and notification permission is granted. */
|
||||
export async function isCollaboratorPushActive(): Promise<boolean> {
|
||||
if (!isPushSupported()) return false
|
||||
if (getNotificationPermission() !== 'granted') return false
|
||||
try {
|
||||
const prefs = await fetchPushPrefs()
|
||||
return prefs.collaboratorChangesEnabled
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPushPrefs(): Promise<{ collaboratorChangesEnabled: boolean }> {
|
||||
if (!localStorage.getItem('active_userid')) {
|
||||
return { collaboratorChangesEnabled: false }
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
forcePwaRecovery,
|
||||
markReloadAttempt,
|
||||
recentlyAttemptedReload,
|
||||
reconcileServiceWorkerOnStartup,
|
||||
reconcileVersionOnStartup
|
||||
} from './pwaStartup.js'
|
||||
|
||||
const STALE_RECOVERY_COUNT_KEY = 'pwa_stale_recovery_count'
|
||||
const STALE_RECOVERY_LAST_KEY = 'pwa_stale_recovery_last_ts'
|
||||
|
||||
describe('pwaStartup reload guards', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
it('blocks repeated reload attempts within the debounce window', () => {
|
||||
expect(recentlyAttemptedReload(10_000)).toBe(false)
|
||||
markReloadAttempt(10_000)
|
||||
expect(recentlyAttemptedReload(12_000)).toBe(true)
|
||||
expect(recentlyAttemptedReload(15_000)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('forcePwaRecovery stale counter reset', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
vi.unstubAllEnvs()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('clears stale recovery counter before hard recovery reload', async () => {
|
||||
vi.stubEnv('DEV', false)
|
||||
sessionStorage.setItem(STALE_RECOVERY_COUNT_KEY, '2')
|
||||
sessionStorage.setItem(STALE_RECOVERY_LAST_KEY, String(Date.now()))
|
||||
|
||||
const reload = vi.fn()
|
||||
vi.stubGlobal('location', { reload })
|
||||
vi.stubGlobal('caches', {
|
||||
keys: vi.fn().mockResolvedValue([]),
|
||||
delete: vi.fn()
|
||||
})
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getRegistrations: vi.fn().mockResolvedValue([])
|
||||
}
|
||||
})
|
||||
|
||||
await forcePwaRecovery()
|
||||
|
||||
expect(sessionStorage.getItem(STALE_RECOVERY_COUNT_KEY)).toBeNull()
|
||||
expect(sessionStorage.getItem(STALE_RECOVERY_LAST_KEY)).toBeNull()
|
||||
expect(reload).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('returns false when hard recovery was just attempted', async () => {
|
||||
sessionStorage.setItem('pwa_hard_recovery_ts', String(Date.now()))
|
||||
const result = await forcePwaRecovery()
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconcileServiceWorkerOnStartup', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('returns false in dev mode', async () => {
|
||||
vi.stubEnv('DEV', true)
|
||||
await expect(reconcileServiceWorkerOnStartup()).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when no waiting worker exists', async () => {
|
||||
vi.stubEnv('DEV', false)
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
value: {
|
||||
controller: {},
|
||||
getRegistration: vi.fn().mockResolvedValue({
|
||||
waiting: null,
|
||||
installing: null,
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
addEventListener: vi.fn()
|
||||
}),
|
||||
addEventListener: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
await expect(reconcileServiceWorkerOnStartup()).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when waiting worker activation never takes over', async () => {
|
||||
vi.useFakeTimers()
|
||||
const postMessage = vi.fn()
|
||||
const addEventListener = vi.fn()
|
||||
vi.stubEnv('DEV', false)
|
||||
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
value: {
|
||||
controller: { scriptURL: '/sw.js?v=1' },
|
||||
getRegistration: vi.fn().mockResolvedValue({
|
||||
waiting: { postMessage },
|
||||
installing: null,
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
addEventListener: vi.fn()
|
||||
}),
|
||||
addEventListener
|
||||
}
|
||||
})
|
||||
|
||||
const reconcilePromise = reconcileServiceWorkerOnStartup()
|
||||
await vi.advanceTimersByTimeAsync(4_000)
|
||||
|
||||
await expect(reconcilePromise).resolves.toBe(false)
|
||||
expect(postMessage).toHaveBeenCalledWith({ type: 'SKIP_WAITING' })
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconcileVersionOnStartup', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
vi.unstubAllEnvs()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns noop in dev mode', async () => {
|
||||
vi.stubEnv('DEV', true)
|
||||
await expect(reconcileVersionOnStartup()).resolves.toBe('noop')
|
||||
})
|
||||
|
||||
it('returns noop when deployed version matches bundled version', async () => {
|
||||
vi.stubEnv('DEV', false)
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ version: '0.1.0.57' })
|
||||
}))
|
||||
vi.stubGlobal('__APP_VERSION__', '0.1.0.57')
|
||||
|
||||
await expect(reconcileVersionOnStartup()).resolves.toBe('noop')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,289 @@
|
||||
import { isNewerAppVersion, fetchDeployedVersion, getAppVersion } from './pwaVersion.js'
|
||||
|
||||
const RELOAD_ATTEMPT_KEY = 'pwa_reload_attempt_ts'
|
||||
const COLD_START_UPDATE_KEY = 'pwa_coldstart_update_ts'
|
||||
const HARD_RECOVERY_KEY = 'pwa_hard_recovery_ts'
|
||||
const STALE_RECOVERY_COUNT_KEY = 'pwa_stale_recovery_count'
|
||||
const STALE_RECOVERY_LAST_KEY = 'pwa_stale_recovery_last_ts'
|
||||
const STALE_RECOVERY_WINDOW_MS = 60_000
|
||||
const RELOAD_DEBOUNCE_MS = 4_000
|
||||
const COLD_START_UPDATE_DEBOUNCE_MS = 15_000
|
||||
const HARD_RECOVERY_DEBOUNCE_MS = 30_000
|
||||
|
||||
export function recentlyAttemptedReload(now = Date.now()): boolean {
|
||||
const last = Number(sessionStorage.getItem(RELOAD_ATTEMPT_KEY) || '0')
|
||||
return now - last < RELOAD_DEBOUNCE_MS
|
||||
}
|
||||
|
||||
export function markReloadAttempt(now = Date.now()): void {
|
||||
sessionStorage.setItem(RELOAD_ATTEMPT_KEY, String(now))
|
||||
}
|
||||
|
||||
function recentlyAttemptedColdStartUpdate(now = Date.now()): boolean {
|
||||
const last = Number(sessionStorage.getItem(COLD_START_UPDATE_KEY) || '0')
|
||||
return now - last < COLD_START_UPDATE_DEBOUNCE_MS
|
||||
}
|
||||
|
||||
function markColdStartUpdateAttempt(now = Date.now()): void {
|
||||
sessionStorage.setItem(COLD_START_UPDATE_KEY, String(now))
|
||||
}
|
||||
|
||||
function recentlyAttemptedHardRecovery(now = Date.now()): boolean {
|
||||
const last = Number(sessionStorage.getItem(HARD_RECOVERY_KEY) || '0')
|
||||
return now - last < HARD_RECOVERY_DEBOUNCE_MS
|
||||
}
|
||||
|
||||
function markHardRecoveryAttempt(now = Date.now()): void {
|
||||
sessionStorage.setItem(HARD_RECOVERY_KEY, String(now))
|
||||
}
|
||||
|
||||
function resetStaleRecoveryCount(): void {
|
||||
sessionStorage.removeItem(STALE_RECOVERY_COUNT_KEY)
|
||||
sessionStorage.removeItem(STALE_RECOVERY_LAST_KEY)
|
||||
}
|
||||
|
||||
function incrementStaleRecoveryCount(now = Date.now()): number {
|
||||
const last = Number(sessionStorage.getItem(STALE_RECOVERY_LAST_KEY) || '0')
|
||||
let current = Number(sessionStorage.getItem(STALE_RECOVERY_COUNT_KEY) || '0')
|
||||
|
||||
if (now - last > STALE_RECOVERY_WINDOW_MS) {
|
||||
current = 0
|
||||
}
|
||||
|
||||
const next = current + 1
|
||||
sessionStorage.setItem(STALE_RECOVERY_COUNT_KEY, String(next))
|
||||
sessionStorage.setItem(STALE_RECOVERY_LAST_KEY, String(now))
|
||||
return next
|
||||
}
|
||||
|
||||
function isStaleModuleLoadError(error: unknown): boolean {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === 'string'
|
||||
? error
|
||||
: ''
|
||||
|
||||
return (
|
||||
message.includes('Failed to fetch dynamically imported module') ||
|
||||
message.includes('Importing a module script failed') ||
|
||||
message.includes('error loading dynamically imported module') ||
|
||||
message.includes('Loading chunk') ||
|
||||
message.includes('ChunkLoadError') ||
|
||||
message.includes('Unable to preload CSS')
|
||||
)
|
||||
}
|
||||
|
||||
export async function clearPwaCachesAndWorkers(): Promise<void> {
|
||||
if ('serviceWorker' in navigator) {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||
await Promise.all(registrations.map((registration) => registration.unregister()))
|
||||
}
|
||||
|
||||
if ('caches' in window) {
|
||||
const keys = await caches.keys()
|
||||
await Promise.all(keys.map((key) => caches.delete(key)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Last-resort recovery when soft reloads cannot escape a stale precache.
|
||||
* Equivalent to manually clearing site data / reinstalling the PWA.
|
||||
*/
|
||||
export async function forcePwaRecovery(): Promise<boolean> {
|
||||
if (recentlyAttemptedHardRecovery()) return false
|
||||
|
||||
markHardRecoveryAttempt()
|
||||
markReloadAttempt()
|
||||
resetStaleRecoveryCount()
|
||||
await clearPwaCachesAndWorkers()
|
||||
window.location.reload()
|
||||
return true
|
||||
}
|
||||
|
||||
async function waitForWaitingWorker(
|
||||
registration: ServiceWorkerRegistration,
|
||||
timeoutMs: number
|
||||
): Promise<ServiceWorker | null> {
|
||||
if (registration.waiting) {
|
||||
return registration.waiting
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeoutId = window.setTimeout(() => resolve(null), timeoutMs)
|
||||
|
||||
const inspectWorker = (worker: ServiceWorker | null) => {
|
||||
if (!worker) return
|
||||
|
||||
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
window.clearTimeout(timeoutId)
|
||||
resolve(worker)
|
||||
return
|
||||
}
|
||||
|
||||
worker.addEventListener(
|
||||
'statechange',
|
||||
() => {
|
||||
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
window.clearTimeout(timeoutId)
|
||||
resolve(worker)
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
}
|
||||
|
||||
inspectWorker(registration.installing)
|
||||
|
||||
registration.addEventListener(
|
||||
'updatefound',
|
||||
() => {
|
||||
inspectWorker(registration.installing)
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export async function triggerServiceWorkerUpdate(timeoutMs = 5_000): Promise<boolean> {
|
||||
if (import.meta.env.DEV || !('serviceWorker' in navigator)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.getRegistration()
|
||||
if (!registration) return false
|
||||
|
||||
try {
|
||||
await registration.update()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
const waiting = await waitForWaitingWorker(registration, timeoutMs)
|
||||
return waiting !== null
|
||||
}
|
||||
|
||||
async function activateWaitingWorker(waiting: ServiceWorker): Promise<boolean> {
|
||||
const currentController = navigator.serviceWorker.controller?.scriptURL ?? null
|
||||
waiting.postMessage({ type: 'SKIP_WAITING' })
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const timeoutId = window.setTimeout(() => resolve(false), 4_000)
|
||||
navigator.serviceWorker.addEventListener(
|
||||
'controllerchange',
|
||||
() => {
|
||||
window.clearTimeout(timeoutId)
|
||||
const nextController = navigator.serviceWorker.controller?.scriptURL ?? null
|
||||
resolve(nextController !== null && nextController !== currentController)
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* After missed deploys, a waiting SW may exist while the page still runs an old bundle.
|
||||
* Apply the waiting worker once on cold start (one controlled reload) instead of hanging.
|
||||
*/
|
||||
export async function reconcileServiceWorkerOnStartup(): Promise<boolean> {
|
||||
if (import.meta.env.DEV || !('serviceWorker' in navigator)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (recentlyAttemptedColdStartUpdate()) {
|
||||
return false
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.getRegistration()
|
||||
let waiting = registration?.waiting ?? null
|
||||
|
||||
if (!waiting && registration) {
|
||||
await registration.update().catch(() => {})
|
||||
waiting = await waitForWaitingWorker(registration, 4_000)
|
||||
}
|
||||
|
||||
if (!waiting || !navigator.serviceWorker.controller) {
|
||||
return false
|
||||
}
|
||||
|
||||
const activated = await activateWaitingWorker(waiting)
|
||||
if (activated) {
|
||||
markColdStartUpdateAttempt()
|
||||
}
|
||||
return activated
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare deployed version.json with the bundled app version.
|
||||
* When the server is ahead, try a soft SW takeover before hard recovery.
|
||||
*/
|
||||
export async function reconcileVersionOnStartup(): Promise<'reload' | 'recovered' | 'noop'> {
|
||||
if (import.meta.env.DEV || !navigator.onLine) {
|
||||
return 'noop'
|
||||
}
|
||||
|
||||
const deployedVersion = await fetchDeployedVersion()
|
||||
if (!deployedVersion || !isNewerAppVersion(deployedVersion, getAppVersion())) {
|
||||
return 'noop'
|
||||
}
|
||||
|
||||
const reconciled = await reconcileServiceWorkerOnStartup()
|
||||
if (reconciled) {
|
||||
return 'reload'
|
||||
}
|
||||
|
||||
const updated = await triggerServiceWorkerUpdate()
|
||||
if (updated) {
|
||||
const registration = await navigator.serviceWorker.getRegistration()
|
||||
const waiting = registration?.waiting
|
||||
if (waiting) {
|
||||
const activated = await activateWaitingWorker(waiting)
|
||||
if (activated) {
|
||||
markColdStartUpdateAttempt()
|
||||
return 'reload'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!recentlyAttemptedHardRecovery()) {
|
||||
const recovered = await forcePwaRecovery()
|
||||
if (recovered) {
|
||||
return 'recovered'
|
||||
}
|
||||
}
|
||||
|
||||
return 'noop'
|
||||
}
|
||||
|
||||
export function installStaleAssetRecovery(): void {
|
||||
if (import.meta.env.DEV) return
|
||||
|
||||
const recoverFromStaleAssets = () => {
|
||||
if (recentlyAttemptedReload()) return
|
||||
|
||||
const attempts = incrementStaleRecoveryCount()
|
||||
markReloadAttempt()
|
||||
|
||||
if (attempts >= 2) {
|
||||
void forcePwaRecovery()
|
||||
return
|
||||
}
|
||||
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
if (!isStaleModuleLoadError(event.reason)) return
|
||||
event.preventDefault()
|
||||
recoverFromStaleAssets()
|
||||
})
|
||||
|
||||
window.addEventListener(
|
||||
'error',
|
||||
(event) => {
|
||||
if (!isStaleModuleLoadError(event.message)) return
|
||||
recoverFromStaleAssets()
|
||||
},
|
||||
true
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
compareAppVersions,
|
||||
isNewerAppVersion,
|
||||
parseAppVersion
|
||||
} from './pwaVersion.js'
|
||||
|
||||
describe('pwaVersion', () => {
|
||||
it('parses semantic build versions', () => {
|
||||
expect(parseAppVersion('v0.1.0.57')).toEqual([0, 1, 0, 57])
|
||||
})
|
||||
|
||||
it('compares build numbers numerically', () => {
|
||||
expect(compareAppVersions('0.1.0.65', '0.1.0.57')).toBeGreaterThan(0)
|
||||
expect(compareAppVersions('0.1.0.57', '0.1.0.65')).toBeLessThan(0)
|
||||
expect(compareAppVersions('0.1.0.57', '0.1.0.57')).toBe(0)
|
||||
})
|
||||
|
||||
it('detects newer deployed versions', () => {
|
||||
expect(isNewerAppVersion('0.1.0.66', '0.1.0.57')).toBe(true)
|
||||
expect(isNewerAppVersion('0.1.0.57', '0.1.0.57')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
const APP_VERSION =
|
||||
typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0.0-dev'
|
||||
|
||||
export function getAppVersion(): string {
|
||||
return APP_VERSION
|
||||
}
|
||||
|
||||
export function parseAppVersion(version: string): number[] {
|
||||
return version
|
||||
.replace(/^v/i, '')
|
||||
.split('.')
|
||||
.map((part) => Number.parseInt(part, 10) || 0)
|
||||
}
|
||||
|
||||
/** Positive when `a` is newer than `b`. */
|
||||
export function compareAppVersions(a: string, b: string): number {
|
||||
const partsA = parseAppVersion(a)
|
||||
const partsB = parseAppVersion(b)
|
||||
const length = Math.max(partsA.length, partsB.length)
|
||||
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const diff = (partsA[index] ?? 0) - (partsB[index] ?? 0)
|
||||
if (diff !== 0) return diff
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
export function isNewerAppVersion(serverVersion: string, clientVersion: string): boolean {
|
||||
return compareAppVersions(serverVersion, clientVersion) > 0
|
||||
}
|
||||
|
||||
export async function fetchDeployedVersion(timeoutMs = 4_000): Promise<string | null> {
|
||||
if (!navigator.onLine) return null
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/version.json?_=${Date.now()}`, {
|
||||
cache: 'no-store',
|
||||
signal: controller.signal
|
||||
})
|
||||
if (!response.ok) return null
|
||||
|
||||
const payload = (await response.json()) as { version?: unknown }
|
||||
return typeof payload.version === 'string' ? payload.version.trim() : null
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
export async function isDeployedVersionNewer(): Promise<boolean> {
|
||||
const deployedVersion = await fetchDeployedVersion()
|
||||
if (!deployedVersion) return false
|
||||
return isNewerAppVersion(deployedVersion, getAppVersion())
|
||||
}
|
||||
+28
-1
@@ -1,10 +1,37 @@
|
||||
/// <reference lib="webworker" />
|
||||
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
|
||||
import { clientsClaim } from 'workbox-core'
|
||||
import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching'
|
||||
import { registerRoute } from 'workbox-routing'
|
||||
import { NetworkFirst, NetworkOnly } from 'workbox-strategies'
|
||||
|
||||
declare let self: ServiceWorkerGlobalScope
|
||||
|
||||
const appShellFallback = createHandlerBoundToURL('/index.html')
|
||||
const navigationStrategy = new NetworkFirst({
|
||||
cacheName: 'app-shell',
|
||||
networkTimeoutSeconds: 3
|
||||
})
|
||||
|
||||
registerRoute(({ request }) => request.mode === 'navigate', async (context) => {
|
||||
try {
|
||||
return await navigationStrategy.handle(context)
|
||||
} catch {
|
||||
return appShellFallback(context)
|
||||
}
|
||||
})
|
||||
|
||||
precacheAndRoute(self.__WB_MANIFEST)
|
||||
cleanupOutdatedCaches()
|
||||
clientsClaim()
|
||||
|
||||
// Always fetch the live deploy version, even under an older precache.
|
||||
registerRoute(({ url }) => url.pathname === '/version.json', new NetworkOnly())
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data?.type === 'SKIP_WAITING') {
|
||||
void self.skipWaiting()
|
||||
}
|
||||
})
|
||||
|
||||
interface PushPayload {
|
||||
title?: string
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { normalizeAppLanguage } from './i18nLanguages.js'
|
||||
|
||||
const INTL_LOCALES: Record<string, string> = {
|
||||
de: 'de-DE',
|
||||
en: 'en-GB',
|
||||
da: 'da-DK',
|
||||
sv: 'sv-SE',
|
||||
nb: 'nb-NO'
|
||||
}
|
||||
|
||||
/** BCP 47 locales that use 24-hour clock for Intl formatting. */
|
||||
export function resolveIntlLocale(language?: string): string {
|
||||
const lng = (language ?? 'en').toLowerCase()
|
||||
return lng.startsWith('de') ? 'de-DE' : 'en-GB'
|
||||
const lng = normalizeAppLanguage(language)
|
||||
return INTL_LOCALES[lng] ?? 'en-GB'
|
||||
}
|
||||
|
||||
const APP_DATE_TIME_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { i18n as I18nInstance } from 'i18next'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PlausibleEvents } from '../services/analytics.js'
|
||||
import {
|
||||
changeAppLanguage,
|
||||
cycleAppLanguage,
|
||||
getNextLanguage,
|
||||
normalizeAppLanguage,
|
||||
SUPPORTED_LANGUAGES
|
||||
} from './i18nLanguages.js'
|
||||
|
||||
const trackPlausibleEvent = vi.fn()
|
||||
|
||||
vi.mock('../services/analytics.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../services/analytics.js')>()
|
||||
return {
|
||||
...actual,
|
||||
trackPlausibleEvent: (...args: unknown[]) => trackPlausibleEvent(...args)
|
||||
}
|
||||
})
|
||||
|
||||
function createMockI18n(language: string): I18nInstance {
|
||||
let current = language
|
||||
return {
|
||||
language: current,
|
||||
changeLanguage: vi.fn(async (lng: string) => {
|
||||
current = lng
|
||||
;(this as { language: string }).language = lng
|
||||
})
|
||||
} as unknown as I18nInstance
|
||||
}
|
||||
|
||||
describe('i18nLanguages', () => {
|
||||
beforeEach(() => {
|
||||
trackPlausibleEvent.mockReset()
|
||||
})
|
||||
|
||||
it('normalizes regional tags to supported base codes', () => {
|
||||
expect(normalizeAppLanguage('de-DE')).toBe('de')
|
||||
expect(normalizeAppLanguage('nb-NO')).toBe('nb')
|
||||
expect(normalizeAppLanguage('xx')).toBe('en')
|
||||
})
|
||||
|
||||
it('cycles through all supported languages', () => {
|
||||
let current: string = 'de'
|
||||
const seen = new Set<string>()
|
||||
for (let i = 0; i < SUPPORTED_LANGUAGES.length; i++) {
|
||||
seen.add(current)
|
||||
current = getNextLanguage(current)
|
||||
}
|
||||
expect(seen.size).toBe(SUPPORTED_LANGUAGES.length)
|
||||
expect(current).toBe('de')
|
||||
})
|
||||
|
||||
it('tracks explicit language changes', () => {
|
||||
const i18n = createMockI18n('de')
|
||||
changeAppLanguage(i18n, 'sv')
|
||||
|
||||
expect(i18n.changeLanguage).toHaveBeenCalledWith('sv')
|
||||
expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.LANGUAGE_CHANGED, {
|
||||
from: 'de',
|
||||
to: 'sv'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not track when language stays the same', () => {
|
||||
const i18n = createMockI18n('en')
|
||||
changeAppLanguage(i18n, 'en')
|
||||
|
||||
expect(i18n.changeLanguage).not.toHaveBeenCalled()
|
||||
expect(trackPlausibleEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cycleAppLanguage tracks the next language', () => {
|
||||
const i18n = createMockI18n('nb')
|
||||
cycleAppLanguage(i18n)
|
||||
|
||||
expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.LANGUAGE_CHANGED, {
|
||||
from: 'nb',
|
||||
to: 'de'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { i18n as I18nInstance } from 'i18next'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
|
||||
/** Supported UI languages (ISO 639-1, language-only). */
|
||||
export const SUPPORTED_LANGUAGES = ['de', 'en', 'da', 'sv', 'nb'] as const
|
||||
|
||||
export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number]
|
||||
|
||||
export function normalizeAppLanguage(language?: string): AppLanguage {
|
||||
const base = (language ?? 'en').split('-')[0].toLowerCase()
|
||||
if ((SUPPORTED_LANGUAGES as readonly string[]).includes(base)) {
|
||||
return base as AppLanguage
|
||||
}
|
||||
return 'en'
|
||||
}
|
||||
|
||||
export function getNextLanguage(current?: string): AppLanguage {
|
||||
const active = normalizeAppLanguage(current)
|
||||
const index = SUPPORTED_LANGUAGES.indexOf(active)
|
||||
return SUPPORTED_LANGUAGES[(index + 1) % SUPPORTED_LANGUAGES.length]
|
||||
}
|
||||
|
||||
export function isGermanLocale(language?: string): boolean {
|
||||
return normalizeAppLanguage(language) === 'de'
|
||||
}
|
||||
|
||||
/** Switch UI language and track explicit user choice (not auto-detection). */
|
||||
export function changeAppLanguage(i18n: I18nInstance, language: AppLanguage): void {
|
||||
const from = normalizeAppLanguage(i18n.language)
|
||||
const to = normalizeAppLanguage(language)
|
||||
if (from === to) return
|
||||
|
||||
void i18n.changeLanguage(to)
|
||||
trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from, to })
|
||||
}
|
||||
|
||||
export function cycleAppLanguage(i18n: I18nInstance): void {
|
||||
changeAppLanguage(i18n, getNextLanguage(i18n.language))
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { resolveIntlLocale } from './dateTimeFormat.js'
|
||||
import { initSeo, normalizeSeoLang, updatePageSeo } from './seo.js'
|
||||
|
||||
const HTML_LANG = /^de|en$/
|
||||
const SUPPORTED_HTML_LANG = /^de|en|da|sv|nb$/
|
||||
|
||||
function createMockI18n(language: string): I18nInstance {
|
||||
return {
|
||||
@@ -20,7 +20,9 @@ describe('normalizeSeoLang', () => {
|
||||
['de-DE', 'de'],
|
||||
['en', 'en'],
|
||||
['en-US', 'en'],
|
||||
['en-GB', 'en']
|
||||
['da', 'da'],
|
||||
['sv-SE', 'sv'],
|
||||
['nb-NO', 'nb']
|
||||
] as const)('maps %s to short code %s', (input, expected) => {
|
||||
expect(normalizeSeoLang(input)).toBe(expected)
|
||||
})
|
||||
@@ -35,13 +37,15 @@ describe('updatePageSeo html lang', () => {
|
||||
it.each([
|
||||
['de', 'de'],
|
||||
['en', 'en'],
|
||||
['en-GB', 'en']
|
||||
['da', 'da'],
|
||||
['sv', 'sv'],
|
||||
['nb', 'nb']
|
||||
] as const)('sets html lang to %s when i18n language is %s', (i18nLanguage, expectedLang) => {
|
||||
initSeo(createMockI18n(i18nLanguage))
|
||||
updatePageSeo()
|
||||
|
||||
expect(document.documentElement.lang).toBe(expectedLang)
|
||||
expect(document.documentElement.lang).toMatch(HTML_LANG)
|
||||
expect(document.documentElement.lang).toMatch(SUPPORTED_HTML_LANG)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -49,14 +53,17 @@ describe('resolveIntlLocale', () => {
|
||||
it('uses full BCP 47 tags for Intl formatting only', () => {
|
||||
expect(resolveIntlLocale('de')).toBe('de-DE')
|
||||
expect(resolveIntlLocale('en')).toBe('en-GB')
|
||||
expect(resolveIntlLocale('da')).toBe('da-DK')
|
||||
expect(resolveIntlLocale('sv')).toBe('sv-SE')
|
||||
expect(resolveIntlLocale('nb')).toBe('nb-NO')
|
||||
})
|
||||
|
||||
it('does not reuse Intl locale tags for html lang', () => {
|
||||
const intlLocale = resolveIntlLocale('en')
|
||||
const htmlLang = normalizeSeoLang('en')
|
||||
const intlLocale = resolveIntlLocale('nb')
|
||||
const htmlLang = normalizeSeoLang('nb')
|
||||
|
||||
expect(intlLocale).toBe('en-GB')
|
||||
expect(htmlLang).toBe('en')
|
||||
expect(intlLocale).toBe('nb-NO')
|
||||
expect(htmlLang).toBe('nb')
|
||||
expect(htmlLang).not.toBe(intlLocale)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildLogEntryPayload,
|
||||
hasUnsavedEventDraft,
|
||||
isLogEventDraftEmpty,
|
||||
normalizeLogEvent,
|
||||
@@ -40,3 +41,25 @@ describe('logEntryPayload event drafts', () => {
|
||||
expect(hasUnsavedEventDraft(filledDraft(), 0, events)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildLogEntryPayload greywater', () => {
|
||||
const base = {
|
||||
date: '2026-05-31',
|
||||
dayOfTravel: '1',
|
||||
departure: 'Kiel',
|
||||
destination: 'Laboe',
|
||||
freshwater: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
|
||||
fuel: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
|
||||
events: [] as LogEventPayload[]
|
||||
}
|
||||
|
||||
it('includes greywater when level > 0', () => {
|
||||
const payload = buildLogEntryPayload({ ...base, greywater: { level: 45 } })
|
||||
expect(payload.greywater).toEqual({ level: 45 })
|
||||
})
|
||||
|
||||
it('omits greywater when level is 0', () => {
|
||||
const payload = buildLogEntryPayload({ ...base, greywater: { level: 0 } })
|
||||
expect(payload.greywater).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -144,6 +144,7 @@ export interface LogEntryPayloadInput {
|
||||
destination: string
|
||||
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
fuel: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
greywater?: { level: number }
|
||||
trackDistanceNm?: number
|
||||
trackSpeedMaxKn?: number
|
||||
trackSpeedAvgKn?: number
|
||||
@@ -169,5 +170,12 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string
|
||||
payload.motorHours = Number(input.motorHours.toFixed(2))
|
||||
}
|
||||
|
||||
if (input.greywater !== undefined) {
|
||||
const level = Number(input.greywater.level) || 0
|
||||
if (level > 0) {
|
||||
payload.greywater = { level: Number(level.toFixed(1)) }
|
||||
}
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
carryOverFromPreviousDay,
|
||||
getClosingGreywaterLevel,
|
||||
hasCarryOverFromPreviousDay
|
||||
} from './logEntryTankLevels.js'
|
||||
|
||||
describe('logEntryTankLevels greywater carry-over', () => {
|
||||
it('returns previous greywater level as starting value', () => {
|
||||
const carryOver = carryOverFromPreviousDay({
|
||||
destination: 'Oslo',
|
||||
freshwater: { morning: 100, refilled: 0, evening: 80, consumption: 20 },
|
||||
fuel: { morning: 200, refilled: 0, evening: 150, consumption: 50 },
|
||||
greywater: { level: 42 }
|
||||
})
|
||||
|
||||
expect(carryOver.greywaterLevel).toBe(42)
|
||||
expect(carryOver.freshwater.morning).toBe(80)
|
||||
expect(carryOver.fuel.morning).toBe(150)
|
||||
expect(carryOver.departure).toBe('Oslo')
|
||||
})
|
||||
|
||||
it('defaults greywater to 0 when previous day has none', () => {
|
||||
expect(carryOverFromPreviousDay(null).greywaterLevel).toBe(0)
|
||||
expect(getClosingGreywaterLevel(undefined)).toBe(0)
|
||||
})
|
||||
|
||||
it('treats greywater level as carry-over candidate', () => {
|
||||
expect(
|
||||
hasCarryOverFromPreviousDay({
|
||||
freshwater: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
|
||||
fuel: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
|
||||
greywaterLevel: 15,
|
||||
departure: ''
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -41,12 +41,14 @@ export function getClosingTankLevel(tank?: Partial<TankLevels> | null): number {
|
||||
export interface LogEntryTankSource {
|
||||
freshwater?: Partial<TankLevels>
|
||||
fuel?: Partial<TankLevels>
|
||||
greywater?: { level?: number }
|
||||
destination?: string
|
||||
}
|
||||
|
||||
export interface CarryOverFromPreviousDay {
|
||||
freshwater: TankLevels
|
||||
fuel: TankLevels
|
||||
greywaterLevel: number
|
||||
departure: string
|
||||
}
|
||||
|
||||
@@ -59,6 +61,10 @@ export function formatTankLiters(liters: number): string {
|
||||
return Number.isInteger(liters) ? String(liters) : liters.toFixed(1)
|
||||
}
|
||||
|
||||
export function getClosingGreywaterLevel(greywater?: { level?: number } | null): number {
|
||||
return Number(greywater?.level) || 0
|
||||
}
|
||||
|
||||
export function carryOverTankLevelsFromPreviousDay(previousEntry?: LogEntryTankSource | null): { freshwater: TankLevels; fuel: TankLevels } {
|
||||
if (!previousEntry) {
|
||||
return { freshwater: emptyTankLevels(), fuel: emptyTankLevels() }
|
||||
@@ -73,10 +79,16 @@ export function carryOverTankLevelsFromPreviousDay(previousEntry?: LogEntryTankS
|
||||
export function carryOverFromPreviousDay(previousEntry?: LogEntryTankSource | null): CarryOverFromPreviousDay {
|
||||
const { freshwater, fuel } = carryOverTankLevelsFromPreviousDay(previousEntry)
|
||||
const departure = previousEntry?.destination?.trim() || ''
|
||||
const greywaterLevel = getClosingGreywaterLevel(previousEntry?.greywater)
|
||||
|
||||
return { freshwater, fuel, departure }
|
||||
return { freshwater, fuel, greywaterLevel, departure }
|
||||
}
|
||||
|
||||
export function hasCarryOverFromPreviousDay(carryOver: CarryOverFromPreviousDay): boolean {
|
||||
return carryOver.freshwater.morning > 0 || carryOver.fuel.morning > 0 || carryOver.departure.length > 0
|
||||
return (
|
||||
carryOver.freshwater.morning > 0 ||
|
||||
carryOver.fuel.morning > 0 ||
|
||||
carryOver.greywaterLevel > 0 ||
|
||||
carryOver.departure.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
+12
-4
@@ -1,13 +1,22 @@
|
||||
import type { i18n as I18nInstance } from 'i18next'
|
||||
import { normalizeAppLanguage, type AppLanguage } from './i18nLanguages.js'
|
||||
|
||||
const SITE_ORIGIN = 'https://kapteins-daagbok.eu'
|
||||
|
||||
export type SeoLang = 'de' | 'en'
|
||||
export type SeoLang = AppLanguage
|
||||
|
||||
const OG_LOCALES: Record<SeoLang, string> = {
|
||||
de: 'de_DE',
|
||||
en: 'en_GB',
|
||||
da: 'da_DK',
|
||||
sv: 'sv_SE',
|
||||
nb: 'nb_NO'
|
||||
}
|
||||
|
||||
let i18nRef: I18nInstance | null = null
|
||||
|
||||
export function normalizeSeoLang(lng: string): SeoLang {
|
||||
return lng.startsWith('de') ? 'de' : 'en'
|
||||
return normalizeAppLanguage(lng)
|
||||
}
|
||||
|
||||
function setMeta(attr: 'name' | 'property', key: string, content: string) {
|
||||
@@ -47,8 +56,7 @@ export function updatePageSeo(lng?: string) {
|
||||
setMeta('name', 'keywords', keywords)
|
||||
setMeta('property', 'og:title', title)
|
||||
setMeta('property', 'og:description', description)
|
||||
setMeta('property', 'og:locale', lang === 'de' ? 'de_DE' : 'en_US')
|
||||
setMeta('property', 'og:locale:alternate', lang === 'de' ? 'en_US' : 'de_DE')
|
||||
setMeta('property', 'og:locale', OG_LOCALES[lang])
|
||||
setMeta('name', 'twitter:title', title)
|
||||
setMeta('name', 'twitter:description', description)
|
||||
setMeta('property', 'og:image:alt', imageAlt)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
clampTankLiters,
|
||||
computeEveningTankMaxLiters,
|
||||
computeRefilledTankMaxLiters,
|
||||
extractTankCapacitiesFromYacht,
|
||||
formatTankLitersForInput,
|
||||
parseOptionalTankLiters,
|
||||
tankCapacityInputFromStored
|
||||
} from './tankCapacity.js'
|
||||
|
||||
describe('tankCapacity', () => {
|
||||
it('parses optional liters with comma decimal', () => {
|
||||
expect(parseOptionalTankLiters('200')).toBe(200)
|
||||
expect(parseOptionalTankLiters('12,5')).toBe(12.5)
|
||||
expect(parseOptionalTankLiters('')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects negative or invalid liters', () => {
|
||||
expect(() => parseOptionalTankLiters('-1')).toThrow('invalid_tank_liters')
|
||||
expect(() => parseOptionalTankLiters('abc')).toThrow('invalid_tank_liters')
|
||||
})
|
||||
|
||||
it('extracts capacities from yacht payload', () => {
|
||||
expect(
|
||||
extractTankCapacitiesFromYacht({
|
||||
freshwaterCapacityL: 300,
|
||||
fuelCapacityL: 120,
|
||||
greywaterCapacityL: 80
|
||||
})
|
||||
).toEqual({
|
||||
freshwaterCapacityL: 300,
|
||||
fuelCapacityL: 120,
|
||||
greywaterCapacityL: 80
|
||||
})
|
||||
expect(extractTankCapacitiesFromYacht({ name: 'Test' })).toEqual({})
|
||||
})
|
||||
|
||||
it('formats stored capacity for input', () => {
|
||||
expect(tankCapacityInputFromStored(150)).toBe('150')
|
||||
expect(formatTankLitersForInput(12.5)).toBe('12.5')
|
||||
})
|
||||
|
||||
it('clamps liters to max when set', () => {
|
||||
expect(clampTankLiters(250, 200)).toBe(200)
|
||||
expect(clampTankLiters(-5, 200)).toBe(0)
|
||||
expect(clampTankLiters(50)).toBe(50)
|
||||
})
|
||||
|
||||
it('computes refilled max as capacity minus morning', () => {
|
||||
expect(computeRefilledTankMaxLiters('10', 60)).toBe(50)
|
||||
expect(computeRefilledTankMaxLiters('60', 60)).toBeUndefined()
|
||||
expect(computeRefilledTankMaxLiters('10', undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('computes evening max as morning plus refilled capped by capacity', () => {
|
||||
expect(computeEveningTankMaxLiters('10', '20', 60)).toBe(30)
|
||||
expect(computeEveningTankMaxLiters('40', '40', 60)).toBe(60)
|
||||
expect(computeEveningTankMaxLiters('10', '20')).toBe(30)
|
||||
expect(computeEveningTankMaxLiters('0', '0', 60)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import { formatTankLiters } from './logEntryTankLevels.js'
|
||||
|
||||
export interface VesselTankCapacities {
|
||||
freshwaterCapacityL?: number
|
||||
fuelCapacityL?: number
|
||||
greywaterCapacityL?: number
|
||||
}
|
||||
|
||||
export function parseOptionalTankLiters(input: string): number | undefined {
|
||||
const trimmed = input.trim().replace(',', '.')
|
||||
if (!trimmed) return undefined
|
||||
const parsed = Number(trimmed)
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
throw new Error('invalid_tank_liters')
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
export function formatTankLitersForInput(liters: number): string {
|
||||
return formatTankLiters(liters)
|
||||
}
|
||||
|
||||
function capacityFromStored(value: unknown): number | undefined {
|
||||
if (value == null || value === '') return undefined
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return value
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim().replace(',', '.')
|
||||
if (!trimmed) return undefined
|
||||
const parsed = Number(trimmed)
|
||||
if (Number.isFinite(parsed) && parsed >= 0) return parsed
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function tankCapacityInputFromStored(value: unknown): string {
|
||||
const n = capacityFromStored(value)
|
||||
return n != null ? formatTankLitersForInput(n) : ''
|
||||
}
|
||||
|
||||
export function extractTankCapacitiesFromYacht(decrypted: unknown): VesselTankCapacities {
|
||||
if (!decrypted || typeof decrypted !== 'object') return {}
|
||||
const y = decrypted as Record<string, unknown>
|
||||
const capacities: VesselTankCapacities = {}
|
||||
const fw = capacityFromStored(y.freshwaterCapacityL)
|
||||
const fuel = capacityFromStored(y.fuelCapacityL)
|
||||
const gw = capacityFromStored(y.greywaterCapacityL)
|
||||
if (fw != null) capacities.freshwaterCapacityL = fw
|
||||
if (fuel != null) capacities.fuelCapacityL = fuel
|
||||
if (gw != null) capacities.greywaterCapacityL = gw
|
||||
return capacities
|
||||
}
|
||||
|
||||
/** Parse a liter amount from form state (string). */
|
||||
export function parseTankLitersFromInput(input: string): number {
|
||||
const trimmed = input.trim().replace(',', '.')
|
||||
if (!trimmed) return 0
|
||||
const parsed = Number(trimmed)
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Max for refilled amount: remaining capacity after morning level.
|
||||
* Returns undefined when no positive max (no slider).
|
||||
*/
|
||||
export function computeRefilledTankMaxLiters(
|
||||
morningInput: string,
|
||||
tankCapacityL?: number
|
||||
): number | undefined {
|
||||
if (tankCapacityL == null || tankCapacityL <= 0) return undefined
|
||||
const remaining = tankCapacityL - parseTankLitersFromInput(morningInput)
|
||||
if (remaining <= 0) return undefined
|
||||
return remaining
|
||||
}
|
||||
|
||||
/**
|
||||
* Max for evening fill level: morning + refilled, capped by tank capacity when known.
|
||||
* Returns undefined when no positive max (no slider).
|
||||
*/
|
||||
export function computeEveningTankMaxLiters(
|
||||
morningInput: string,
|
||||
refilledInput: string,
|
||||
tankCapacityL?: number
|
||||
): number | undefined {
|
||||
const sum = parseTankLitersFromInput(morningInput) + parseTankLitersFromInput(refilledInput)
|
||||
if (sum <= 0) return undefined
|
||||
|
||||
if (tankCapacityL != null && tankCapacityL > 0) {
|
||||
return Math.min(tankCapacityL, sum)
|
||||
}
|
||||
|
||||
return sum
|
||||
}
|
||||
|
||||
/** Clamp numeric liter value to [0, max] when max is known. */
|
||||
export function clampTankLiters(value: number, maxLiters?: number): number {
|
||||
const clamped = Math.max(0, value)
|
||||
if (maxLiters != null && maxLiters > 0) {
|
||||
return Math.min(clamped, maxLiters)
|
||||
}
|
||||
return clamped
|
||||
}
|
||||
+16
-1
@@ -2,9 +2,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
@@ -19,6 +20,19 @@ function readAppVersion(): string {
|
||||
}
|
||||
}
|
||||
|
||||
function versionJsonPlugin(version: string): Plugin {
|
||||
return {
|
||||
name: 'version-json',
|
||||
writeBundle(options) {
|
||||
const outDir = options.dir ?? resolve(__dirname, 'dist')
|
||||
writeFileSync(
|
||||
resolve(outDir, 'version.json'),
|
||||
`${JSON.stringify({ version }, null, 2)}\n`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
test: {
|
||||
@@ -42,6 +56,7 @@ export default defineConfig({
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
versionJsonPlugin(readAppVersion()),
|
||||
VitePWA({
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="da">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Kapteins Daagbok - Beta-flyer</title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 210mm;
|
||||
height: 297mm;
|
||||
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||
color: #e2e8f0;
|
||||
background: #0f172a;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
.page {
|
||||
width: 210mm;
|
||||
height: 297mm;
|
||||
max-height: 297mm;
|
||||
padding: 12mm 15mm 10mm;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5mm;
|
||||
background:
|
||||
radial-gradient(ellipse 120% 80% at 100% 0%, rgba(56, 189, 248, 0.12) 0%, transparent 55%),
|
||||
radial-gradient(ellipse 90% 60% at 0% 100%, rgba(134, 59, 255, 0.14) 0%, transparent 50%),
|
||||
linear-gradient(165deg, #0f172a 0%, #1e293b 45%, #0f172a 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 8mm;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 4mm;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5mm;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 16mm;
|
||||
height: 16mm;
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.title-block h1 {
|
||||
font-size: 23pt;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: #f8fafc;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.title-block p {
|
||||
font-size: 12pt;
|
||||
color: #94a3b8;
|
||||
margin-top: 1.5mm;
|
||||
}
|
||||
|
||||
.badge {
|
||||
margin-left: auto;
|
||||
align-self: flex-start;
|
||||
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
|
||||
color: #1e293b;
|
||||
font-size: 11pt;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
padding: 2.5mm 4.5mm;
|
||||
border-radius: 2mm;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.intro {
|
||||
font-size: 12pt;
|
||||
line-height: 1.5;
|
||||
color: #cbd5e1;
|
||||
flex-shrink: 0;
|
||||
max-width: 95%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.intro strong {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.screenshots {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 3mm;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.screenshot-card {
|
||||
border-radius: 2.5mm;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.screenshot-card img {
|
||||
width: 100%;
|
||||
height: 50mm;
|
||||
object-fit: contain;
|
||||
object-position: top center;
|
||||
display: block;
|
||||
background: #0b1220;
|
||||
}
|
||||
|
||||
.screenshot-caption {
|
||||
font-size: 9pt;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
padding: 1.5mm 2mm;
|
||||
line-height: 1.3;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.features {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.8mm 6mm;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.feature {
|
||||
display: flex;
|
||||
gap: 2.5mm;
|
||||
align-items: flex-start;
|
||||
font-size: 10.5pt;
|
||||
line-height: 1.28;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
color: #38bdf8;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
width: 4mm;
|
||||
}
|
||||
|
||||
.lang-list {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1.5mm;
|
||||
}
|
||||
|
||||
.lang-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 1.2mm;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.feature-flag {
|
||||
display: inline-block;
|
||||
width: 5mm;
|
||||
height: 3.5mm;
|
||||
border-radius: 0.3mm;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 0 0.15mm rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.lang-sep {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.beta-box {
|
||||
background: rgba(30, 41, 59, 0.85);
|
||||
border: 1px solid rgba(251, 191, 36, 0.35);
|
||||
border-left: 3px solid #fbbf24;
|
||||
border-radius: 3mm;
|
||||
padding: 5mm 6mm;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.beta-box h2 {
|
||||
font-size: 12.5pt;
|
||||
color: #fbbf24;
|
||||
margin-bottom: 2mm;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.beta-box p {
|
||||
font-size: 10.5pt;
|
||||
line-height: 1.5;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7mm;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 4mm;
|
||||
padding: 5mm 6mm;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.qr {
|
||||
width: 32mm;
|
||||
height: 32mm;
|
||||
background: #fff;
|
||||
padding: 2mm;
|
||||
border-radius: 2mm;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.qr img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cta-text h3 {
|
||||
font-size: 14.5pt;
|
||||
color: #38bdf8;
|
||||
font-weight: 700;
|
||||
margin-bottom: 2mm;
|
||||
}
|
||||
|
||||
.cta-text p {
|
||||
font-size: 11pt;
|
||||
color: #94a3b8;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2mm;
|
||||
margin-top: 3mm;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 9.5pt;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
border: 1px solid rgba(100, 116, 139, 0.4);
|
||||
border-radius: 1.5mm;
|
||||
padding: 1mm 2.5mm;
|
||||
}
|
||||
|
||||
footer {
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.15);
|
||||
padding-top: 3mm;
|
||||
margin-top: auto;
|
||||
flex-shrink: 0;
|
||||
font-size: 9.5pt;
|
||||
line-height: 1.5;
|
||||
color: #64748b;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
footer strong {
|
||||
color: #94a3b8;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<article class="page">
|
||||
<header>
|
||||
<img class="logo" src="../../client/public/logo.png" alt="Kapteins Daagbok" />
|
||||
<div class="title-block">
|
||||
<h1>Kapteins Daagbok</h1>
|
||||
<p>Digital yachtlogbog - gratis og reklamefri</p>
|
||||
</div>
|
||||
<span class="badge">Beta</span>
|
||||
</header>
|
||||
|
||||
<p class="intro">
|
||||
Opbevar din logbog om bord digitalt: rejsedage, GPS-spor, besætnings- og skibsdata
|
||||
<strong>End-to-end-krypteret</strong>kan installeres som en app og
|
||||
<strong>også offline</strong> kan bruges til søs.
|
||||
</p>
|
||||
|
||||
<section class="features" aria-label="Funktioner">
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Rejsedage i nautisk logbogsformat (havn, vejr, sejl, besætning, brændstofniveauer)</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Offline-kompatibel PWA - kører på enhver smartphone og tablet</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Simpelt login uden adgangskode Passkey.</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Ende-til-ende-kryptering</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Upload af GPS-spor (GPX/KML) med kortvisning</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Rute-statistik</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Vedhæftede billeder pr. rejsedag</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Fotoavatarbilleder til skipper og besætning</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Inviter besætningen - arbejd sammen om logbogen</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>PDF- & CSV-Export</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Krypteret sikkerhedskopiering og gendannelse</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Del logbog med venner</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Et vilkårligt antal skibe og logbøger</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span class="lang-list"><span class="lang-item"><svg class="feature-flag" viewBox="0 0 5 3" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="5" height="1" fill="#000"/><rect y="1" width="5" height="1" fill="#D00"/><rect y="2" width="5" height="1" fill="#FFCE00"/></svg>Deutsch</span><span class="lang-sep">·</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 60 30" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><clipPath id="gb-a"><path d="M0 0v30h60V0z"/></clipPath><clipPath id="gb-b"><path d="M30 15h30v15zv15H0z"/></clipPath><g clip-path="url(#gb-a)"><path d="M0 0v30h60V0z" fill="#012169"/><path d="M0 0l60 30m0-30L0 30" stroke="#fff" stroke-width="6"/><path d="M0 0l60 30m0-30L0 30" clip-path="url(#gb-b)" stroke="#C8102E" stroke-width="4"/><path d="M30 0v30M0 15h60" stroke="#fff" stroke-width="10"/><path d="M30 0v30M0 15h60" stroke="#C8102E" stroke-width="6"/></g></svg>English</span><span class="lang-sep">·</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 37 28" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="37" height="28" fill="#C8102E"/><rect x="12" width="4" height="28" fill="#fff"/><rect y="12" width="37" height="4" fill="#fff"/></svg>Dansk</span><span class="lang-sep">·</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 16 10" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="16" height="10" fill="#006AA7"/><rect x="5" width="2" height="10" fill="#FECC00"/><rect y="4" width="16" height="2" fill="#FECC00"/></svg>Svenska</span><span class="lang-sep">·</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 22 16" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="22" height="16" fill="#BA0C2F"/><rect x="6" width="4" height="16" fill="#fff"/><rect y="6" width="22" height="4" fill="#fff"/><rect x="7" width="2" height="16" fill="#00205B"/><rect y="7" width="22" height="2" fill="#00205B"/></svg>Norsk</span></span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>3 temaer, hver med en lys og en mørk variant</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Fremstillet i Kiel.Sailing.City..</span></div>
|
||||
</section>
|
||||
|
||||
<section class="screenshots" aria-label="Skærmbilleder af appen">
|
||||
<figure class="screenshot-card">
|
||||
<img src="assets/screenshot-login.png" alt="Registrering med Passkey og demo" />
|
||||
<figcaption class="screenshot-caption">Registrering & Passkey</figcaption>
|
||||
</figure>
|
||||
<figure class="screenshot-card">
|
||||
<img src="assets/screenshot-logbook.png" alt="Logbogsdagbog med rejsedage" />
|
||||
<figcaption class="screenshot-caption">Logbogsdagbog</figcaption>
|
||||
</figure>
|
||||
<figure class="screenshot-card">
|
||||
<img src="assets/screenshot-vessel.png" alt="Skibsdata med foto af yacht" />
|
||||
<figcaption class="screenshot-caption">Skibsdata</figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section class="beta-box">
|
||||
<h2>Betafase - din feedback tæller</h2>
|
||||
<p>
|
||||
Kapteins Daagbok er en<strong>Privat hobbyprojekt uden fortjeneste for øje</strong>.
|
||||
Som betatester er du med til at forbedre appen for skippere og besætninger i hverdagen - feedback er meget velkommen.
|
||||
Feedback er yderst velkommen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="cta">
|
||||
<div class="qr">
|
||||
<img src="assets/qr-kapteins-daagbok.eu.png" alt="QR-kode: kapteins-daagbok.eu" />
|
||||
</div>
|
||||
<div class="cta-text">
|
||||
<h3>kapteins-daagbok.eu</h3>
|
||||
<p>Åbn i browseren, eller tilføj som en app til startskærmen. Registrer dig hos Passkey - der kræves ingen app store.</p>
|
||||
<div class="tags">
|
||||
<span class="tag">Helt gratis</span>
|
||||
<span class="tag">Gratis reklame</span>
|
||||
<span class="tag">E2E-krypteret</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<strong>Påtryk</strong><br />
|
||||
KnorrLabs · Markus F.J. Busche · Knorrstr. 16 · 24106 Kiel · elpatron+kd@mailbox.org
|
||||
</footer>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
@@ -328,7 +328,7 @@
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Verschlüsseltes Backup & Wiederherstellung</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Logbuch mit Freunden teilen</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Beliebig viele Schiffe und Logbücher</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span class="lang-list"><span class="lang-item"><svg class="feature-flag" viewBox="0 0 5 3" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="5" height="1" fill="#000"/><rect y="1" width="5" height="1" fill="#D00"/><rect y="2" width="5" height="1" fill="#FFCE00"/></svg>Deutsch</span><span class="lang-sep">&</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 60 30" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><clipPath id="gb-a"><path d="M0 0v30h60V0z"/></clipPath><clipPath id="gb-b"><path d="M30 15h30v15zv15H0z"/></clipPath><g clip-path="url(#gb-a)"><path d="M0 0v30h60V0z" fill="#012169"/><path d="M0 0l60 30m0-30L0 30" stroke="#fff" stroke-width="6"/><path d="M0 0l60 30m0-30L0 30" clip-path="url(#gb-b)" stroke="#C8102E" stroke-width="4"/><path d="M30 0v30M0 15h60" stroke="#fff" stroke-width="10"/><path d="M30 0v30M0 15h60" stroke="#C8102E" stroke-width="6"/></g></svg>Englisch</span></span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span class="lang-list"><span class="lang-item"><svg class="feature-flag" viewBox="0 0 5 3" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="5" height="1" fill="#000"/><rect y="1" width="5" height="1" fill="#D00"/><rect y="2" width="5" height="1" fill="#FFCE00"/></svg>Deutsch</span><span class="lang-sep">·</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 60 30" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><clipPath id="gb-a"><path d="M0 0v30h60V0z"/></clipPath><clipPath id="gb-b"><path d="M30 15h30v15zv15H0z"/></clipPath><g clip-path="url(#gb-a)"><path d="M0 0v30h60V0z" fill="#012169"/><path d="M0 0l60 30m0-30L0 30" stroke="#fff" stroke-width="6"/><path d="M0 0l60 30m0-30L0 30" clip-path="url(#gb-b)" stroke="#C8102E" stroke-width="4"/><path d="M30 0v30M0 15h60" stroke="#fff" stroke-width="10"/><path d="M30 0v30M0 15h60" stroke="#C8102E" stroke-width="6"/></g></svg>Englisch</span><span class="lang-sep">·</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 37 28" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="37" height="28" fill="#C8102E"/><rect x="12" width="4" height="28" fill="#fff"/><rect y="12" width="37" height="4" fill="#fff"/></svg>Dansk</span><span class="lang-sep">·</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 16 10" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="16" height="10" fill="#006AA7"/><rect x="5" width="2" height="10" fill="#FECC00"/><rect y="4" width="16" height="2" fill="#FECC00"/></svg>Svenska</span><span class="lang-sep">·</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 22 16" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="22" height="16" fill="#BA0C2F"/><rect x="6" width="4" height="16" fill="#fff"/><rect y="6" width="22" height="4" fill="#fff"/><rect x="7" width="2" height="16" fill="#00205B"/><rect y="7" width="22" height="2" fill="#00205B"/></svg>Norsk</span></span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>3 Themes, jeweils mit heller und dunkler Variante</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Crafted in Kiel.Sailing.City.</span></div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="nb">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Kapteins Daagbok - Beta-flygeblad</title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 210mm;
|
||||
height: 297mm;
|
||||
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||
color: #e2e8f0;
|
||||
background: #0f172a;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
.page {
|
||||
width: 210mm;
|
||||
height: 297mm;
|
||||
max-height: 297mm;
|
||||
padding: 12mm 15mm 10mm;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5mm;
|
||||
background:
|
||||
radial-gradient(ellipse 120% 80% at 100% 0%, rgba(56, 189, 248, 0.12) 0%, transparent 55%),
|
||||
radial-gradient(ellipse 90% 60% at 0% 100%, rgba(134, 59, 255, 0.14) 0%, transparent 50%),
|
||||
linear-gradient(165deg, #0f172a 0%, #1e293b 45%, #0f172a 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 8mm;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 4mm;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5mm;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 16mm;
|
||||
height: 16mm;
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.title-block h1 {
|
||||
font-size: 23pt;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: #f8fafc;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.title-block p {
|
||||
font-size: 12pt;
|
||||
color: #94a3b8;
|
||||
margin-top: 1.5mm;
|
||||
}
|
||||
|
||||
.badge {
|
||||
margin-left: auto;
|
||||
align-self: flex-start;
|
||||
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
|
||||
color: #1e293b;
|
||||
font-size: 11pt;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
padding: 2.5mm 4.5mm;
|
||||
border-radius: 2mm;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.intro {
|
||||
font-size: 12pt;
|
||||
line-height: 1.5;
|
||||
color: #cbd5e1;
|
||||
flex-shrink: 0;
|
||||
max-width: 95%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.intro strong {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.screenshots {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 3mm;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.screenshot-card {
|
||||
border-radius: 2.5mm;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.screenshot-card img {
|
||||
width: 100%;
|
||||
height: 50mm;
|
||||
object-fit: contain;
|
||||
object-position: top center;
|
||||
display: block;
|
||||
background: #0b1220;
|
||||
}
|
||||
|
||||
.screenshot-caption {
|
||||
font-size: 9pt;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
padding: 1.5mm 2mm;
|
||||
line-height: 1.3;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.features {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.8mm 6mm;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.feature {
|
||||
display: flex;
|
||||
gap: 2.5mm;
|
||||
align-items: flex-start;
|
||||
font-size: 10.5pt;
|
||||
line-height: 1.28;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
color: #38bdf8;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
width: 4mm;
|
||||
}
|
||||
|
||||
.lang-list {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1.5mm;
|
||||
}
|
||||
|
||||
.lang-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 1.2mm;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.feature-flag {
|
||||
display: inline-block;
|
||||
width: 5mm;
|
||||
height: 3.5mm;
|
||||
border-radius: 0.3mm;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 0 0.15mm rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.lang-sep {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.beta-box {
|
||||
background: rgba(30, 41, 59, 0.85);
|
||||
border: 1px solid rgba(251, 191, 36, 0.35);
|
||||
border-left: 3px solid #fbbf24;
|
||||
border-radius: 3mm;
|
||||
padding: 5mm 6mm;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.beta-box h2 {
|
||||
font-size: 12.5pt;
|
||||
color: #fbbf24;
|
||||
margin-bottom: 2mm;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.beta-box p {
|
||||
font-size: 10.5pt;
|
||||
line-height: 1.5;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7mm;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 4mm;
|
||||
padding: 5mm 6mm;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.qr {
|
||||
width: 32mm;
|
||||
height: 32mm;
|
||||
background: #fff;
|
||||
padding: 2mm;
|
||||
border-radius: 2mm;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.qr img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cta-text h3 {
|
||||
font-size: 14.5pt;
|
||||
color: #38bdf8;
|
||||
font-weight: 700;
|
||||
margin-bottom: 2mm;
|
||||
}
|
||||
|
||||
.cta-text p {
|
||||
font-size: 11pt;
|
||||
color: #94a3b8;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2mm;
|
||||
margin-top: 3mm;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 9.5pt;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
border: 1px solid rgba(100, 116, 139, 0.4);
|
||||
border-radius: 1.5mm;
|
||||
padding: 1mm 2.5mm;
|
||||
}
|
||||
|
||||
footer {
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.15);
|
||||
padding-top: 3mm;
|
||||
margin-top: auto;
|
||||
flex-shrink: 0;
|
||||
font-size: 9.5pt;
|
||||
line-height: 1.5;
|
||||
color: #64748b;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
footer strong {
|
||||
color: #94a3b8;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<article class="page">
|
||||
<header>
|
||||
<img class="logo" src="../../client/public/logo.png" alt="Kapteins Daagbok" />
|
||||
<div class="title-block">
|
||||
<h1>Kapteins Daagbok</h1>
|
||||
<p>Digital loggbok for fritidsbåter - gratis og reklamefri</p>
|
||||
</div>
|
||||
<span class="badge">Beta</span>
|
||||
</header>
|
||||
|
||||
<p class="intro">
|
||||
Oppbevar loggboken om bord digitalt: reisedager, GPS-spor, mannskaps- og skipsdata
|
||||
<strong>Ende-til-ende-kryptert</strong>kan installeres som en app og
|
||||
<strong>også offline</strong> kan brukes til sjøs.
|
||||
</p>
|
||||
|
||||
<section class="features" aria-label="Funksjoner">
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Reisedager i nautisk loggbokformat (havn, vær, seil, mannskap, drivstoffnivå)</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Offline-kompatibel PWA - kjører på alle smarttelefoner og nettbrett</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Enkel passordfri Passkey-pålogging</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Ende-til-ende-kryptering</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Opplasting av GPS-spor (GPX/KML) med kartvisning</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Rutestatistikk</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Fotobilag per reisedag</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Avatarbilder for skipper og mannskap</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Inviter mannskapet - samarbeid om loggboken</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>PDF- & CSV-Export</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Kryptert sikkerhetskopiering og gjenoppretting</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Del loggboken med venner</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Et hvilket som helst antall skip og loggbøker</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span class="lang-list"><span class="lang-item"><svg class="feature-flag" viewBox="0 0 5 3" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="5" height="1" fill="#000"/><rect y="1" width="5" height="1" fill="#D00"/><rect y="2" width="5" height="1" fill="#FFCE00"/></svg>Deutsch</span><span class="lang-sep">·</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 60 30" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><clipPath id="gb-a"><path d="M0 0v30h60V0z"/></clipPath><clipPath id="gb-b"><path d="M30 15h30v15zv15H0z"/></clipPath><g clip-path="url(#gb-a)"><path d="M0 0v30h60V0z" fill="#012169"/><path d="M0 0l60 30m0-30L0 30" stroke="#fff" stroke-width="6"/><path d="M0 0l60 30m0-30L0 30" clip-path="url(#gb-b)" stroke="#C8102E" stroke-width="4"/><path d="M30 0v30M0 15h60" stroke="#fff" stroke-width="10"/><path d="M30 0v30M0 15h60" stroke="#C8102E" stroke-width="6"/></g></svg>English</span><span class="lang-sep">·</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 37 28" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="37" height="28" fill="#C8102E"/><rect x="12" width="4" height="28" fill="#fff"/><rect y="12" width="37" height="4" fill="#fff"/></svg>Dansk</span><span class="lang-sep">·</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 16 10" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="16" height="10" fill="#006AA7"/><rect x="5" width="2" height="10" fill="#FECC00"/><rect y="4" width="16" height="2" fill="#FECC00"/></svg>Svenska</span><span class="lang-sep">·</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 22 16" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="22" height="16" fill="#BA0C2F"/><rect x="6" width="4" height="16" fill="#fff"/><rect y="6" width="22" height="4" fill="#fff"/><rect x="7" width="2" height="16" fill="#00205B"/><rect y="7" width="22" height="2" fill="#00205B"/></svg>Norsk</span></span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>3 temaer, hvert med en lys og en mørk variant</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Laget i Kiel.Sailing.City..</span></div>
|
||||
</section>
|
||||
|
||||
<section class="screenshots" aria-label="Skjermbilder av appen">
|
||||
<figure class="screenshot-card">
|
||||
<img src="assets/screenshot-login.png" alt="Registrering med Passkey og demo" />
|
||||
<figcaption class="screenshot-caption">Registrering & Passkey</figcaption>
|
||||
</figure>
|
||||
<figure class="screenshot-card">
|
||||
<img src="assets/screenshot-logbook.png" alt="Loggbok med reisedager" />
|
||||
<figcaption class="screenshot-caption">Loggbokdagbok</figcaption>
|
||||
</figure>
|
||||
<figure class="screenshot-card">
|
||||
<img src="assets/screenshot-vessel.png" alt="Skipsdata med bilde av båten" />
|
||||
<figcaption class="screenshot-caption">Skipsdata</figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section class="beta-box">
|
||||
<h2>Betafasen - dine tilbakemeldinger teller</h2>
|
||||
<p>
|
||||
Kapteins Daagbok er en<strong>Privat hobbyprosjekt uten profitthensikt</strong>.
|
||||
Som betatester bidrar du til å forbedre appen for skippere og mannskap i hverdagen - tilbakemeldinger er hjertelig velkomne.
|
||||
Tilbakemeldinger er hjertelig velkomne.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="cta">
|
||||
<div class="qr">
|
||||
<img src="assets/qr-kapteins-daagbok.eu.png" alt="QR-kode: kapteins-daagbok.eu" />
|
||||
</div>
|
||||
<div class="cta-text">
|
||||
<h3>kapteins-daagbok.eu</h3>
|
||||
<p>Åpne i nettleseren eller legg til som en app på startskjermen. Registrer deg med Passkey - ingen appbutikk er nødvendig.</p>
|
||||
<div class="tags">
|
||||
<span class="tag">Kostnadsfritt</span>
|
||||
<span class="tag">Gratis annonsering</span>
|
||||
<span class="tag">E2E-kryptert</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<strong>Avtrykk</strong><br />
|
||||
KnorrLabs · Markus F.J. Busche · Knorrstr. 16 · 24106 Kiel · elpatron+kd@mailbox.org
|
||||
</footer>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,381 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="sv">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Kapteins Daagbok - Beta-flygblad</title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 210mm;
|
||||
height: 297mm;
|
||||
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||
color: #e2e8f0;
|
||||
background: #0f172a;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
.page {
|
||||
width: 210mm;
|
||||
height: 297mm;
|
||||
max-height: 297mm;
|
||||
padding: 12mm 15mm 10mm;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5mm;
|
||||
background:
|
||||
radial-gradient(ellipse 120% 80% at 100% 0%, rgba(56, 189, 248, 0.12) 0%, transparent 55%),
|
||||
radial-gradient(ellipse 90% 60% at 0% 100%, rgba(134, 59, 255, 0.14) 0%, transparent 50%),
|
||||
linear-gradient(165deg, #0f172a 0%, #1e293b 45%, #0f172a 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 8mm;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 4mm;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5mm;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 16mm;
|
||||
height: 16mm;
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.title-block h1 {
|
||||
font-size: 23pt;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: #f8fafc;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.title-block p {
|
||||
font-size: 12pt;
|
||||
color: #94a3b8;
|
||||
margin-top: 1.5mm;
|
||||
}
|
||||
|
||||
.badge {
|
||||
margin-left: auto;
|
||||
align-self: flex-start;
|
||||
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
|
||||
color: #1e293b;
|
||||
font-size: 11pt;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
padding: 2.5mm 4.5mm;
|
||||
border-radius: 2mm;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.intro {
|
||||
font-size: 12pt;
|
||||
line-height: 1.5;
|
||||
color: #cbd5e1;
|
||||
flex-shrink: 0;
|
||||
max-width: 95%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.intro strong {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.screenshots {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 3mm;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.screenshot-card {
|
||||
border-radius: 2.5mm;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.screenshot-card img {
|
||||
width: 100%;
|
||||
height: 50mm;
|
||||
object-fit: contain;
|
||||
object-position: top center;
|
||||
display: block;
|
||||
background: #0b1220;
|
||||
}
|
||||
|
||||
.screenshot-caption {
|
||||
font-size: 9pt;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
padding: 1.5mm 2mm;
|
||||
line-height: 1.3;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.features {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.8mm 6mm;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.feature {
|
||||
display: flex;
|
||||
gap: 2.5mm;
|
||||
align-items: flex-start;
|
||||
font-size: 10.5pt;
|
||||
line-height: 1.28;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
color: #38bdf8;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
width: 4mm;
|
||||
}
|
||||
|
||||
.lang-list {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1.5mm;
|
||||
}
|
||||
|
||||
.lang-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 1.2mm;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.feature-flag {
|
||||
display: inline-block;
|
||||
width: 5mm;
|
||||
height: 3.5mm;
|
||||
border-radius: 0.3mm;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 0 0.15mm rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.lang-sep {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.beta-box {
|
||||
background: rgba(30, 41, 59, 0.85);
|
||||
border: 1px solid rgba(251, 191, 36, 0.35);
|
||||
border-left: 3px solid #fbbf24;
|
||||
border-radius: 3mm;
|
||||
padding: 5mm 6mm;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.beta-box h2 {
|
||||
font-size: 12.5pt;
|
||||
color: #fbbf24;
|
||||
margin-bottom: 2mm;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.beta-box p {
|
||||
font-size: 10.5pt;
|
||||
line-height: 1.5;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7mm;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 4mm;
|
||||
padding: 5mm 6mm;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.qr {
|
||||
width: 32mm;
|
||||
height: 32mm;
|
||||
background: #fff;
|
||||
padding: 2mm;
|
||||
border-radius: 2mm;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.qr img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cta-text h3 {
|
||||
font-size: 14.5pt;
|
||||
color: #38bdf8;
|
||||
font-weight: 700;
|
||||
margin-bottom: 2mm;
|
||||
}
|
||||
|
||||
.cta-text p {
|
||||
font-size: 11pt;
|
||||
color: #94a3b8;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2mm;
|
||||
margin-top: 3mm;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 9.5pt;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
border: 1px solid rgba(100, 116, 139, 0.4);
|
||||
border-radius: 1.5mm;
|
||||
padding: 1mm 2.5mm;
|
||||
}
|
||||
|
||||
footer {
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.15);
|
||||
padding-top: 3mm;
|
||||
margin-top: auto;
|
||||
flex-shrink: 0;
|
||||
font-size: 9.5pt;
|
||||
line-height: 1.5;
|
||||
color: #64748b;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
footer strong {
|
||||
color: #94a3b8;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<article class="page">
|
||||
<header>
|
||||
<img class="logo" src="../../client/public/logo.png" alt="Kapteins Daagbok" />
|
||||
<div class="title-block">
|
||||
<h1>Kapteins Daagbok</h1>
|
||||
<p>Digital loggbok för båtar - gratis & reklamfri</p>
|
||||
</div>
|
||||
<span class="badge">Beta</span>
|
||||
</header>
|
||||
|
||||
<p class="intro">
|
||||
Förvara din loggbok ombord digitalt: resdagar, GPS-spår, besättnings- och fartygsdata
|
||||
<strong>End-to-end-kryptering</strong>kan installeras som en app och
|
||||
<strong>också offline</strong> användbar till sjöss.
|
||||
</p>
|
||||
|
||||
<section class="features" aria-label="Funktioner">
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Resdagar i nautiskt loggboksformat (hamn, väder, segel, besättning, bränslenivåer)</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Offline-kompatibel PWA - körs på alla smartphones och surfplattor</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Enkel lösenordsfri Passkey-inloggning</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>End-to-end-kryptering</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Uppladdning av GPS-spår (GPX/KML) med kartvisning</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Statistik över rutter</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Fotobilagor per resdag</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Fotoavatarbilder för skeppare och besättning</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Bjud in besättningen - arbeta tillsammans med loggboken</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>PDF- & CSV-Export</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Krypterad säkerhetskopiering och återställning</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Dela loggbok med vänner</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Valfritt antal fartyg och loggböcker</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span class="lang-list"><span class="lang-item"><svg class="feature-flag" viewBox="0 0 5 3" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="5" height="1" fill="#000"/><rect y="1" width="5" height="1" fill="#D00"/><rect y="2" width="5" height="1" fill="#FFCE00"/></svg>Deutsch</span><span class="lang-sep">·</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 60 30" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><clipPath id="gb-a"><path d="M0 0v30h60V0z"/></clipPath><clipPath id="gb-b"><path d="M30 15h30v15zv15H0z"/></clipPath><g clip-path="url(#gb-a)"><path d="M0 0v30h60V0z" fill="#012169"/><path d="M0 0l60 30m0-30L0 30" stroke="#fff" stroke-width="6"/><path d="M0 0l60 30m0-30L0 30" clip-path="url(#gb-b)" stroke="#C8102E" stroke-width="4"/><path d="M30 0v30M0 15h60" stroke="#fff" stroke-width="10"/><path d="M30 0v30M0 15h60" stroke="#C8102E" stroke-width="6"/></g></svg>English</span><span class="lang-sep">·</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 37 28" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="37" height="28" fill="#C8102E"/><rect x="12" width="4" height="28" fill="#fff"/><rect y="12" width="37" height="4" fill="#fff"/></svg>Dansk</span><span class="lang-sep">·</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 16 10" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="16" height="10" fill="#006AA7"/><rect x="5" width="2" height="10" fill="#FECC00"/><rect y="4" width="16" height="2" fill="#FECC00"/></svg>Svenska</span><span class="lang-sep">·</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 22 16" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="22" height="16" fill="#BA0C2F"/><rect x="6" width="4" height="16" fill="#fff"/><rect y="6" width="22" height="4" fill="#fff"/><rect x="7" width="2" height="16" fill="#00205B"/><rect y="7" width="22" height="2" fill="#00205B"/></svg>Norsk</span></span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>3 teman, vart och ett med en ljus och en mörk variant</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Tillverkad i Kiel.Sailing.City..</span></div>
|
||||
</section>
|
||||
|
||||
<section class="screenshots" aria-label="Appens skärmdumpar">
|
||||
<figure class="screenshot-card">
|
||||
<img src="assets/screenshot-login.png" alt="Registrering med Passkey och demo" />
|
||||
<figcaption class="screenshot-caption">Registrering & Passkey</figcaption>
|
||||
</figure>
|
||||
<figure class="screenshot-card">
|
||||
<img src="assets/screenshot-logbook.png" alt="Loggboksdagbok med resedagar" />
|
||||
<figcaption class="screenshot-caption">Loggboksjournal</figcaption>
|
||||
</figure>
|
||||
<figure class="screenshot-card">
|
||||
<img src="assets/screenshot-vessel.png" alt="Fartygsdata med foto av yacht" />
|
||||
<figcaption class="screenshot-caption">Fartygsdata</figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section class="beta-box">
|
||||
<h2>Betafas - din feedback är viktig</h2>
|
||||
<p>
|
||||
Kapteins Daagbok är en<strong>Privat hobbyprojekt utan vinstsyfte</strong>.
|
||||
Som betatestare hjälper du till att förbättra appen för skeppare och besättning i vardagen - feedback är uttryckligen välkommen.
|
||||
Feedback är uttryckligen välkommen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="cta">
|
||||
<div class="qr">
|
||||
<img src="assets/qr-kapteins-daagbok.eu.png" alt="QR-kod: kapteins-daagbok.eu" />
|
||||
</div>
|
||||
<div class="cta-text">
|
||||
<h3>kapteins-daagbok.eu</h3>
|
||||
<p>Öppna i webbläsaren eller lägg till som en app på hemskärmen. Registrera dig med Passkey - ingen appbutik krävs.</p>
|
||||
<div class="tags">
|
||||
<span class="tag">Kostnadsfritt</span>
|
||||
<span class="tag">Reklamfri</span>
|
||||
<span class="tag">E2E-krypterad</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<strong>Avtryck</strong><br />
|
||||
KnorrLabs · Markus F.J. Busche · Knorrstr. 16 · 24106 Kiel · elpatron+kd@mailbox.org
|
||||
</footer>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -49,6 +49,7 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
|
||||
| Local PIN Removed | Lokaler PIN entfernt (`UserProfilePage.tsx`) | — |
|
||||
| Device Forgotten | Account aus Schnell-Login-Liste dieses Geräts entfernt (`UserProfilePage.tsx`) | — |
|
||||
| Recovery Rotated | Neuer 12-Wörter-Wiederherstellungsschlüssel erstellt (`UserProfilePage.tsx`) | — |
|
||||
| Language Changed | Sprache über UI-Wechsler gewählt (`i18nLanguages.ts` via Sprach-Button in App, Dashboard, Auth, Demo, Einladung, Share-Viewer) | `from`, `to`: ISO 639-1 (`de`, `en`, `da`, `sv`, `nb`) |
|
||||
|
||||
## Bewusst nicht getrackt
|
||||
|
||||
@@ -56,6 +57,7 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
|
||||
- **Manuelle Signaturen:** Nur Passkey-Signaturen lösen `Entry Signed` aus.
|
||||
- **PII:** Keine Inhalte aus verschlüsselten Logbüchern in Properties.
|
||||
- **Profil-KPIs:** Statistik-Karten und User-ID-Kopieren werden nicht getrackt (reine Anzeige bzw. zu granular).
|
||||
- **Sprache bei Erstbesuch:** Automatische Browser-/URL-Erkennung (`i18next-browser-languagedetector`, `?lng=`) löst kein `Language Changed` aus — nur explizite Klicks auf den Sprach-Button.
|
||||
- **Kontolöschung:** `Account Deleted` bleibt in `auth.ts` — unabhängig davon, ob die Gefahrenzone auf der Profilseite oder früher in den Einstellungen genutzt wurde.
|
||||
|
||||
## Typische Funnels (Plausible Goals)
|
||||
@@ -69,6 +71,7 @@ Empfohlene Goal-Ketten für Auswertung:
|
||||
5. **Export:** Travel Day Saved → PDF Exported / CSV Exported
|
||||
6. **Datensicherung:** Backup Exported → Backup Restored
|
||||
7. **Kontosicherheit:** Profile Opened → Passkey Added / Local PIN Set / Recovery Rotated; Last Passkey Remove Hinted → Account Deleted (selten, aber aussagekräftig)
|
||||
8. **Internationalisierung:** Language Changed (Verteilung `to`, Pfade mit Übersetzungs-Feedback)
|
||||
|
||||
## Entwicklung
|
||||
|
||||
@@ -77,6 +80,7 @@ import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
|
||||
|
||||
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
||||
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
|
||||
trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from: 'de', to: 'da' })
|
||||
```
|
||||
|
||||
Lokal ohne Plausible-Script ist `trackPlausibleEvent` ein No-Op. In Production im Browser-Netzwerk-Tab auf Requests an die Plausible-Instanz prüfen.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "kapteins-daagbok",
|
||||
"private": true,
|
||||
"description": "Root scripts — run from repo root. Client scripts also work via: cd client && npm run …",
|
||||
"scripts": {
|
||||
"translate:locales": "node scripts/translate-locales.mjs",
|
||||
"translate:flyer": "node scripts/translate-flyer.mjs",
|
||||
"validate:i18n": "node scripts/validate-i18n-keys.mjs",
|
||||
"generate:flyer": "node scripts/generate-beta-flyer.mjs",
|
||||
"generate:flyer:all": "node scripts/generate-beta-flyer.mjs --all"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generates the beta flyer PDF from docs/marketing/beta-flyer.html
|
||||
* Usage: npm run generate:flyer --prefix client
|
||||
* Generates beta flyer PDF/PNG from docs/marketing/beta-flyer*.html
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/generate-beta-flyer.mjs # German PDF
|
||||
* node scripts/generate-beta-flyer.mjs --png # German PNG
|
||||
* node scripts/generate-beta-flyer.mjs --all # all locales, PDF + PNG
|
||||
* node scripts/generate-beta-flyer.mjs --lang da,sv # selected locales
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process'
|
||||
@@ -15,13 +20,38 @@ const repoRoot = resolve(__dirname, '..')
|
||||
const clientDir = resolve(repoRoot, 'client')
|
||||
const marketingDir = resolve(repoRoot, 'docs/marketing')
|
||||
const assetsDir = resolve(marketingDir, 'assets')
|
||||
const htmlPath = resolve(marketingDir, 'beta-flyer.html')
|
||||
const qrPath = resolve(assetsDir, 'qr-kapteins-daagbok.eu.png')
|
||||
const pdfPath = resolve(marketingDir, 'kapteins-daagbok-beta-flyer.pdf')
|
||||
const appUrl = 'https://kapteins-daagbok.eu'
|
||||
|
||||
const LOCALES = {
|
||||
de: { html: 'beta-flyer.html', suffix: '' },
|
||||
da: { html: 'beta-flyer.da.html', suffix: '.da' },
|
||||
sv: { html: 'beta-flyer.sv.html', suffix: '.sv' },
|
||||
nb: { html: 'beta-flyer.nb.html', suffix: '.nb' }
|
||||
}
|
||||
|
||||
const require = createRequire(resolve(clientDir, 'package.json'))
|
||||
|
||||
function parseArgs(argv) {
|
||||
const all = argv.includes('--all')
|
||||
let langs = all ? Object.keys(LOCALES) : ['de']
|
||||
let pdf = !argv.includes('--png-only')
|
||||
let png = argv.includes('--png') || argv.includes('--all') || argv.includes('--png-only')
|
||||
|
||||
if (argv.includes('--pdf-only')) {
|
||||
pdf = true
|
||||
png = false
|
||||
}
|
||||
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
if (argv[i] === '--lang' && argv[i + 1]) {
|
||||
langs = argv[++i].split(',').map((l) => l.trim())
|
||||
}
|
||||
}
|
||||
|
||||
return { langs, pdf, png }
|
||||
}
|
||||
|
||||
function isMissingBrowserError(err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
return msg.includes("Executable doesn't exist") || msg.includes('browserType.launch')
|
||||
@@ -63,33 +93,79 @@ async function ensureQrCode() {
|
||||
console.log('QR code written:', qrPath)
|
||||
}
|
||||
|
||||
async function renderPdf() {
|
||||
let playwright
|
||||
function loadPlaywright() {
|
||||
try {
|
||||
playwright = require('playwright')
|
||||
return require('playwright')
|
||||
} catch {
|
||||
console.error('Fehlende Abhängigkeit: "npm install -D playwright" in client/ ausführen.')
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPdf(page, htmlPath, pdfPath) {
|
||||
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: 'networkidle' })
|
||||
await page.pdf({
|
||||
path: pdfPath,
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
preferCSSPageSize: true,
|
||||
margin: { top: 0, right: 0, bottom: 0, left: 0 }
|
||||
})
|
||||
console.log('PDF written:', pdfPath)
|
||||
}
|
||||
|
||||
async function renderPng(page, htmlPath, pngPath) {
|
||||
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: 'networkidle' })
|
||||
await page.screenshot({
|
||||
path: pngPath,
|
||||
fullPage: true,
|
||||
type: 'png'
|
||||
})
|
||||
console.log('PNG written:', pngPath)
|
||||
}
|
||||
|
||||
async function generateForLocale(playwright, lang, { pdf, png }) {
|
||||
const locale = LOCALES[lang]
|
||||
if (!locale) {
|
||||
console.error(`Unknown locale: ${lang}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const htmlPath = resolve(marketingDir, locale.html)
|
||||
const baseName = `kapteins-daagbok-beta-flyer${locale.suffix}`
|
||||
const pdfPath = resolve(marketingDir, `${baseName}.pdf`)
|
||||
const pngPath = resolve(marketingDir, `${baseName}.png`)
|
||||
|
||||
console.log(`\n→ ${lang.toUpperCase()} (${locale.html})`)
|
||||
|
||||
await ensurePlaywrightChromium(playwright)
|
||||
|
||||
const browser = await playwright.chromium.launch({ headless: true })
|
||||
|
||||
try {
|
||||
const page = await browser.newPage()
|
||||
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: 'networkidle' })
|
||||
await page.pdf({
|
||||
path: pdfPath,
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
preferCSSPageSize: true,
|
||||
margin: { top: 0, right: 0, bottom: 0, left: 0 }
|
||||
})
|
||||
console.log('PDF written:', pdfPath)
|
||||
if (pdf) {
|
||||
const page = await browser.newPage()
|
||||
await renderPdf(page, htmlPath, pdfPath)
|
||||
await page.close()
|
||||
}
|
||||
|
||||
if (png) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 794, height: 1123 },
|
||||
deviceScaleFactor: 2
|
||||
})
|
||||
const page = await context.newPage()
|
||||
await renderPng(page, htmlPath, pngPath)
|
||||
await context.close()
|
||||
}
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
}
|
||||
|
||||
const { langs, pdf, png } = parseArgs(process.argv)
|
||||
await ensureQrCode()
|
||||
await renderPdf()
|
||||
|
||||
const playwright = loadPlaywright()
|
||||
for (const lang of langs) {
|
||||
await generateForLocale(playwright, lang, { pdf, png })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Shared DeepL API helpers for batch translation scripts.
|
||||
* @see https://developers.deepl.com/docs/getting-started/quickstart
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = resolve(__dirname, '../..')
|
||||
|
||||
/** Terms that must not be translated (product, tech, brands). */
|
||||
export const NO_TRANSLATE_TERMS = [
|
||||
'Kapteins Daagbok',
|
||||
'Kiel.Sailing.City.',
|
||||
'KnorrLabs',
|
||||
'Markus F.J. Busche',
|
||||
'kapteins-daagbok.eu',
|
||||
'elpatron+kd@mailbox.org',
|
||||
'GPX/KML',
|
||||
'GPX',
|
||||
'KML',
|
||||
'PDF',
|
||||
'CSV',
|
||||
'PWA',
|
||||
'E2E',
|
||||
'Passkey',
|
||||
'OpenWeatherMap',
|
||||
'Safari',
|
||||
'iPad',
|
||||
'iPhone',
|
||||
'Android',
|
||||
'Knorrstr. 16 · 24106 Kiel',
|
||||
'KnorrLabs · Markus F.J. Busche · Knorrstr. 16 · 24106 Kiel · elpatron+kd@mailbox.org'
|
||||
]
|
||||
|
||||
const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g
|
||||
|
||||
export function loadEnvKey() {
|
||||
const fromEnv = process.env.DEEPL_API_KEY ?? process.env.DeepLAPIKey
|
||||
if (fromEnv) return fromEnv.trim()
|
||||
|
||||
try {
|
||||
const envPath = resolve(repoRoot, '.env')
|
||||
const content = readFileSync(envPath, 'utf8')
|
||||
for (const line of content.split('\n')) {
|
||||
const match = line.match(/^(?:DEEPL_API_KEY|DeepLAPIKey)\s*=\s*(.+)$/)
|
||||
if (match) return match[1].trim()
|
||||
}
|
||||
} catch {
|
||||
// .env optional when key is exported in shell
|
||||
}
|
||||
|
||||
throw new Error('DeepL API key missing. Set DEEPL_API_KEY or DeepLAPIKey in .env')
|
||||
}
|
||||
|
||||
export function resolveApiUrl(apiKey) {
|
||||
if (process.env.DEEPL_API_URL) return process.env.DEEPL_API_URL
|
||||
return apiKey.endsWith(':fx')
|
||||
? 'https://api-free.deepl.com/v2/translate'
|
||||
: 'https://api.deepl.com/v2/translate'
|
||||
}
|
||||
|
||||
export function protectText(text) {
|
||||
const segments = []
|
||||
let protectedText = text.replace(PLACEHOLDER_RE, (match) => {
|
||||
const id = segments.length
|
||||
segments.push(match)
|
||||
return `__X${id}__`
|
||||
})
|
||||
|
||||
for (const term of NO_TRANSLATE_TERMS) {
|
||||
if (!protectedText.includes(term)) continue
|
||||
const id = segments.length
|
||||
segments.push(term)
|
||||
protectedText = protectedText.split(term).join(`__X${id}__`)
|
||||
}
|
||||
|
||||
return { text: protectedText, segments }
|
||||
}
|
||||
|
||||
export function restoreText(text, segments) {
|
||||
let restored = text
|
||||
segments.forEach((value, id) => {
|
||||
restored = restored.replaceAll(`__X${id}__`, value)
|
||||
restored = restored.replaceAll(`__ X ${id} __`, value)
|
||||
restored = restored.replaceAll(`__X ${id}__`, value)
|
||||
})
|
||||
return restored
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms))
|
||||
}
|
||||
|
||||
export async function translateBatch(texts, targetLang, { sourceLang = 'DE', apiKey, retries = 3 } = {}) {
|
||||
if (texts.length === 0) return []
|
||||
|
||||
const key = apiKey ?? loadEnvKey()
|
||||
const url = resolveApiUrl(key)
|
||||
const protectedEntries = texts.map((text) => protectText(text))
|
||||
|
||||
const body = {
|
||||
text: protectedEntries.map((entry) => entry.text),
|
||||
source_lang: sourceLang,
|
||||
target_lang: targetLang
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < retries; attempt++) {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `DeepL-Auth-Key ${key}`,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'kapteins-daagbok-translate/1.0'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
if (response.status === 429 && attempt < retries - 1) {
|
||||
const waitMs = 1500 * (attempt + 1)
|
||||
console.warn(`Rate limited — waiting ${waitMs}ms…`)
|
||||
await sleep(waitMs)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text()
|
||||
throw new Error(`DeepL error ${response.status}: ${detail}`)
|
||||
}
|
||||
|
||||
const payload = await response.json()
|
||||
return payload.translations.map((item, index) =>
|
||||
restoreText(item.text, protectedEntries[index].segments)
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error('DeepL translation failed after retries')
|
||||
}
|
||||
|
||||
export async function translateTexts(texts, targetLang, options = {}) {
|
||||
const batchSize = options.batchSize ?? 40
|
||||
const results = []
|
||||
|
||||
for (let i = 0; i < texts.length; i += batchSize) {
|
||||
const batch = texts.slice(i, i + batchSize)
|
||||
const translated = await translateBatch(batch, targetLang, options)
|
||||
results.push(...translated)
|
||||
if (i + batchSize < texts.length) {
|
||||
process.stdout.write(` ${Math.min(i + batchSize, texts.length)}/${texts.length}\r`)
|
||||
await sleep(300)
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(` ${texts.length}/${texts.length}\n`)
|
||||
return results
|
||||
}
|
||||
|
||||
export function flattenTranslation(obj, prefix = '') {
|
||||
const entries = []
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
entries.push(...flattenTranslation(value, path))
|
||||
} else if (typeof value === 'string') {
|
||||
entries.push([path, value])
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
export function unflattenTranslation(entries) {
|
||||
const root = {}
|
||||
for (const [path, value] of entries) {
|
||||
const parts = path.split('.')
|
||||
let node = root
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
node[parts[i]] ??= {}
|
||||
node = node[parts[i]]
|
||||
}
|
||||
node[parts[parts.length - 1]] = value
|
||||
}
|
||||
return root
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generate localized beta flyer HTML from the German master via DeepL.
|
||||
* Only visible body text and <title> are translated — CSS/HTML structure stay intact.
|
||||
*
|
||||
* Usage: node scripts/translate-flyer.mjs [--lang da,sv,nb]
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { loadEnvKey, translateTexts } from './lib/deepl-translate.mjs'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = resolve(__dirname, '..')
|
||||
const sourcePath = resolve(repoRoot, 'docs/marketing/beta-flyer.html')
|
||||
|
||||
const TARGETS = {
|
||||
da: { code: 'DA', htmlLang: 'da', file: 'beta-flyer.da.html' },
|
||||
sv: { code: 'SV', htmlLang: 'sv', file: 'beta-flyer.sv.html' },
|
||||
nb: { code: 'NB', htmlLang: 'nb', file: 'beta-flyer.nb.html' }
|
||||
}
|
||||
|
||||
const LANG_LIST_BLOCK = `<span class="lang-list"><span class="lang-item"><svg class="feature-flag" viewBox="0 0 5 3" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="5" height="1" fill="#000"/><rect y="1" width="5" height="1" fill="#D00"/><rect y="2" width="5" height="1" fill="#FFCE00"/></svg>Deutsch</span><span class="lang-sep">·</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 60 30" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><clipPath id="gb-a"><path d="M0 0v30h60V0z"/></clipPath><clipPath id="gb-b"><path d="M30 15h30v15zv15H0z"/></clipPath><g clip-path="url(#gb-a)"><path d="M0 0v30h60V0z" fill="#012169"/><path d="M0 0l60 30m0-30L0 30" stroke="#fff" stroke-width="6"/><path d="M0 0l60 30m0-30L0 30" clip-path="url(#gb-b)" stroke="#C8102E" stroke-width="4"/><path d="M30 0v30M0 15h60" stroke="#fff" stroke-width="10"/><path d="M30 0v30M0 15h60" stroke="#C8102E" stroke-width="6"/></g></svg>English</span><span class="lang-sep">·</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 37 28" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="37" height="28" fill="#C8102E"/><rect x="12" width="4" height="28" fill="#fff"/><rect y="12" width="37" height="4" fill="#fff"/></svg>Dansk</span><span class="lang-sep">·</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 16 10" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="16" height="10" fill="#006AA7"/><rect x="5" width="2" height="10" fill="#FECC00"/><rect y="4" width="16" height="2" fill="#FECC00"/></svg>Svenska</span><span class="lang-sep">·</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 22 16" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="22" height="16" fill="#BA0C2F"/><rect x="6" width="4" height="16" fill="#fff"/><rect y="6" width="22" height="4" fill="#fff"/><rect x="7" width="2" height="16" fill="#00205B"/><rect y="7" width="22" height="2" fill="#00205B"/></svg>Norsk</span></span>`
|
||||
|
||||
/** Pull translatable strings from visible content only (never from <style>). */
|
||||
function collectSegments(html) {
|
||||
const segments = []
|
||||
|
||||
const titleMatch = html.match(/<title>([^<]*)<\/title>/i)
|
||||
if (titleMatch?.[1]?.trim()) {
|
||||
segments.push({ kind: 'title', original: titleMatch[1] })
|
||||
}
|
||||
|
||||
const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i)
|
||||
const bodyHtml = bodyMatch?.[1] ?? ''
|
||||
|
||||
const textRe = />([^<]+)</g
|
||||
let match
|
||||
while ((match = textRe.exec(bodyHtml)) !== null) {
|
||||
const text = match[1]
|
||||
if (!text.trim()) continue
|
||||
// Legal imprint stays identical in all locales
|
||||
if (text.includes('KnorrLabs')) continue
|
||||
segments.push({ kind: 'text', original: text })
|
||||
}
|
||||
|
||||
const ariaRe = /aria-label="([^"]+)"/g
|
||||
while ((match = ariaRe.exec(bodyHtml)) !== null) {
|
||||
segments.push({ kind: 'aria', original: match[1] })
|
||||
}
|
||||
|
||||
const altRe = /alt="([^"]+)"/g
|
||||
while ((match = altRe.exec(bodyHtml)) !== null) {
|
||||
segments.push({ kind: 'alt', original: match[1] })
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
function applySegments(html, segments, translated) {
|
||||
let result = html
|
||||
|
||||
segments.forEach((segment, index) => {
|
||||
const next = translated[index]
|
||||
if (segment.original === next) return
|
||||
|
||||
switch (segment.kind) {
|
||||
case 'title':
|
||||
result = result.replace(
|
||||
`<title>${segment.original}</title>`,
|
||||
`<title>${next}</title>`
|
||||
)
|
||||
break
|
||||
case 'text': {
|
||||
const needle = `>${segment.original}<`
|
||||
const pos = result.indexOf(needle)
|
||||
if (pos === -1) return
|
||||
result = `${result.slice(0, pos)}>${next}<${result.slice(pos + needle.length)}`
|
||||
break
|
||||
}
|
||||
case 'aria':
|
||||
result = result.replace(
|
||||
`aria-label="${segment.original}"`,
|
||||
`aria-label="${next}"`
|
||||
)
|
||||
break
|
||||
case 'alt':
|
||||
result = result.replace(
|
||||
`alt="${segment.original}"`,
|
||||
`alt="${next}"`
|
||||
)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function patchLanguageFeature(html) {
|
||||
return html.replace(
|
||||
/(<div class="feature"><span class="feature-icon">✦<\/span>)<span class="lang-list">[\s\S]*?<\/span>(<\/div>)/,
|
||||
`$1${LANG_LIST_BLOCK}$2`
|
||||
)
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
let langs = Object.keys(TARGETS)
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
if (argv[i] === '--lang' && argv[i + 1]) {
|
||||
langs = argv[++i].split(',').map((l) => l.trim())
|
||||
}
|
||||
}
|
||||
return langs
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const langs = parseArgs(process.argv)
|
||||
const apiKey = loadEnvKey()
|
||||
const sourceHtml = await readFile(sourcePath, 'utf8')
|
||||
const segments = collectSegments(sourceHtml)
|
||||
const texts = segments.map((s) => s.original)
|
||||
|
||||
console.log(`Translating ${texts.length} visible strings per locale…`)
|
||||
|
||||
for (const lang of langs) {
|
||||
const target = TARGETS[lang]
|
||||
if (!target) {
|
||||
console.error(`Unknown language: ${lang}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`\n→ ${target.file}`)
|
||||
const translated = await translateTexts(texts, target.code, {
|
||||
sourceLang: 'DE',
|
||||
apiKey,
|
||||
batchSize: 20
|
||||
})
|
||||
|
||||
let html = applySegments(sourceHtml, segments, translated)
|
||||
html = html.replace(/<html lang="de">/, `<html lang="${target.htmlLang}">`)
|
||||
html = patchLanguageFeature(html)
|
||||
|
||||
const outPath = resolve(repoRoot, 'docs/marketing', target.file)
|
||||
await writeFile(outPath, html, 'utf8')
|
||||
console.log(`Wrote ${outPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err.message ?? err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Translate i18n locale JSON from German master via DeepL.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/translate-locales.mjs [--lang da,sv,nb] [--source client/src/i18n/locales/de.json]
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
flattenTranslation,
|
||||
loadEnvKey,
|
||||
translateTexts,
|
||||
unflattenTranslation
|
||||
} from './lib/deepl-translate.mjs'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = resolve(__dirname, '..')
|
||||
const defaultSource = resolve(repoRoot, 'client/src/i18n/locales/de.json')
|
||||
|
||||
const TARGETS = {
|
||||
da: 'DA',
|
||||
sv: 'SV',
|
||||
nb: 'NB'
|
||||
}
|
||||
|
||||
/** Keys whose values stay identical to source (language names, brand). */
|
||||
const COPY_AS_IS_PREFIXES = ['languages.', 'app.name']
|
||||
|
||||
function parseArgs(argv) {
|
||||
let langs = Object.keys(TARGETS)
|
||||
let sourcePath = defaultSource
|
||||
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
if (argv[i] === '--lang' && argv[i + 1]) {
|
||||
langs = argv[++i].split(',').map((l) => l.trim())
|
||||
} else if (argv[i] === '--source' && argv[i + 1]) {
|
||||
sourcePath = resolve(repoRoot, argv[++i])
|
||||
}
|
||||
}
|
||||
|
||||
return { langs, sourcePath }
|
||||
}
|
||||
|
||||
function shouldCopyAsIs(path) {
|
||||
return COPY_AS_IS_PREFIXES.some((prefix) => path === prefix || path.startsWith(prefix))
|
||||
}
|
||||
|
||||
async function translateLocale(sourceJson, langCode, apiKey) {
|
||||
const entries = flattenTranslation(sourceJson.translation)
|
||||
const toTranslate = []
|
||||
const indices = []
|
||||
|
||||
entries.forEach(([path, value], index) => {
|
||||
if (shouldCopyAsIs(path)) return
|
||||
toTranslate.push(value)
|
||||
indices.push(index)
|
||||
})
|
||||
|
||||
console.log(`Translating ${toTranslate.length} strings to ${langCode.toUpperCase()}…`)
|
||||
const translated = await translateTexts(toTranslate, TARGETS[langCode], {
|
||||
sourceLang: 'DE',
|
||||
apiKey
|
||||
})
|
||||
|
||||
const resultEntries = [...entries]
|
||||
indices.forEach((entryIndex, i) => {
|
||||
resultEntries[entryIndex][1] = translated[i]
|
||||
})
|
||||
|
||||
return { translation: unflattenTranslation(resultEntries) }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { langs, sourcePath } = parseArgs(process.argv)
|
||||
const apiKey = loadEnvKey()
|
||||
const sourceRaw = await readFile(sourcePath, 'utf8')
|
||||
const sourceJson = JSON.parse(sourceRaw)
|
||||
|
||||
for (const lang of langs) {
|
||||
if (!TARGETS[lang]) {
|
||||
console.error(`Unknown language: ${lang}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const outPath = resolve(repoRoot, `client/src/i18n/locales/${lang}.json`)
|
||||
console.log(`\n→ ${lang}.json`)
|
||||
const translated = await translateLocale(sourceJson, lang, apiKey)
|
||||
await writeFile(outPath, `${JSON.stringify(translated, null, 2)}\n`, 'utf8')
|
||||
console.log(`Wrote ${outPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err.message ?? err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Verify all locale JSON files have identical key sets.
|
||||
* Usage: node scripts/validate-i18n-keys.mjs
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { flattenTranslation } from './lib/deepl-translate.mjs'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const localesDir = resolve(__dirname, '../client/src/i18n/locales')
|
||||
const localeFiles = ['de.json', 'en.json', 'da.json', 'sv.json', 'nb.json']
|
||||
|
||||
async function loadKeys(filename) {
|
||||
const raw = await readFile(resolve(localesDir, filename), 'utf8')
|
||||
const json = JSON.parse(raw)
|
||||
return flattenTranslation(json.translation).map(([path]) => path).sort()
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const keySets = {}
|
||||
for (const file of localeFiles) {
|
||||
keySets[file] = await loadKeys(file)
|
||||
}
|
||||
|
||||
const master = keySets['de.json']
|
||||
let failed = false
|
||||
|
||||
for (const file of localeFiles) {
|
||||
if (file === 'de.json') continue
|
||||
const keys = keySets[file]
|
||||
const missing = master.filter((k) => !keys.includes(k))
|
||||
const extra = keys.filter((k) => !master.includes(k))
|
||||
if (missing.length || extra.length) {
|
||||
failed = true
|
||||
console.error(`\n${file}:`)
|
||||
if (missing.length) console.error(` missing (${missing.length}):`, missing.slice(0, 10).join(', '))
|
||||
if (extra.length) console.error(` extra (${extra.length}):`, extra.slice(0, 10).join(', '))
|
||||
} else {
|
||||
console.log(`${file}: OK (${keys.length} keys)`)
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) process.exit(1)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -22,6 +22,7 @@ model User {
|
||||
collaborations Collaboration[]
|
||||
pushSubscriptions PushSubscription[]
|
||||
notificationPrefs UserNotificationPrefs?
|
||||
appearancePrefs UserAppearancePrefs?
|
||||
}
|
||||
|
||||
model PushSubscription {
|
||||
@@ -48,6 +49,15 @@ model UserNotificationPrefs {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model UserAppearancePrefs {
|
||||
userId String @id
|
||||
theme String @default("auto")
|
||||
colorScheme String @default("auto")
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Credential {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
|
||||
@@ -38,6 +38,17 @@ function normalizeCredentialLabel(label: unknown): string | null {
|
||||
return trimmed.slice(0, 64)
|
||||
}
|
||||
|
||||
const VALID_THEMES = new Set(['auto', 'ocean', 'material', 'cupertino'])
|
||||
const VALID_COLOR_SCHEMES = new Set(['auto', 'light', 'dark'])
|
||||
|
||||
function parseThemePreference(value: unknown): string | null {
|
||||
return typeof value === 'string' && VALID_THEMES.has(value) ? value : null
|
||||
}
|
||||
|
||||
function parseColorSchemePreference(value: unknown): string | null {
|
||||
return typeof value === 'string' && VALID_COLOR_SCHEMES.has(value) ? value : null
|
||||
}
|
||||
|
||||
router.post('/register-options', async (req, res) => {
|
||||
try {
|
||||
const { username } = req.body
|
||||
@@ -426,6 +437,57 @@ router.post('/rotate-recovery', requireReauth, async (req: any, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/appearance-prefs', requireUser, async (req: any, res) => {
|
||||
try {
|
||||
const prefs = await prisma.userAppearancePrefs.findUnique({
|
||||
where: { userId: req.userId }
|
||||
})
|
||||
|
||||
return res.json({
|
||||
theme: prefs?.theme ?? 'auto',
|
||||
colorScheme: prefs?.colorScheme ?? 'auto',
|
||||
persisted: prefs != null
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error reading appearance prefs:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
router.put('/appearance-prefs', requireUser, async (req: any, res) => {
|
||||
try {
|
||||
const theme = parseThemePreference(req.body?.theme)
|
||||
const colorScheme = parseColorSchemePreference(req.body?.colorScheme)
|
||||
if (!theme || !colorScheme) {
|
||||
return res.status(400).json({ error: 'Invalid theme or colorScheme' })
|
||||
}
|
||||
|
||||
const prefs = await prisma.userAppearancePrefs.upsert({
|
||||
where: { userId: req.userId },
|
||||
create: {
|
||||
userId: req.userId,
|
||||
theme,
|
||||
colorScheme,
|
||||
updatedAt: new Date()
|
||||
},
|
||||
update: {
|
||||
theme,
|
||||
colorScheme,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
})
|
||||
|
||||
return res.json({
|
||||
theme: prefs.theme,
|
||||
colorScheme: prefs.colorScheme,
|
||||
persisted: true
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error updating appearance prefs:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/profile', requireUser, async (req: any, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { analyzeFeedbackSpam, feedbackLimiter } from '../middleware/feedbackProt
|
||||
|
||||
const router = Router()
|
||||
|
||||
const VALID_CATEGORIES = new Set(['bug', 'feature', 'general'])
|
||||
const VALID_CATEGORIES = new Set(['bug', 'feature', 'general', 'translation'])
|
||||
const MAX_MESSAGE_LENGTH = 2000
|
||||
const MAX_EMAIL_LENGTH = 254
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
Reference in New Issue
Block a user