Compare commits

...

10 Commits

25 changed files with 4555 additions and 1971 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.1.28 0.1.1.31
+176
View File
@@ -1939,6 +1939,21 @@ html.scheme-dark .themed-select-option.is-selected {
pointer-events: none; pointer-events: none;
} }
.logbook-card-right-group {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
position: relative;
z-index: 2;
align-self: center;
}
.logbook-card-right-group .logbook-card-chevron {
margin-left: 0;
}
.logbook-card .logbook-title-editable, .logbook-card .logbook-title-editable,
.logbook-card .logbook-title-inline-edit, .logbook-card .logbook-title-inline-edit,
.logbook-card .card-title-row { .logbook-card .card-title-row {
@@ -2165,6 +2180,16 @@ html.scheme-dark .themed-select-option.is-selected {
color: var(--app-text-subtle); color: var(--app-text-subtle);
} }
.entry-count-badge {
background: rgba(255, 255, 255, 0.05);
color: var(--app-text-muted);
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
display: inline-flex;
align-items: center;
}
.entry-sign-badge { .entry-sign-badge {
position: relative; position: relative;
display: inline-flex; display: inline-flex;
@@ -2958,6 +2983,12 @@ html.scheme-dark .themed-select-option.is-selected {
opacity: 1; opacity: 1;
} }
.logbook-card-right-group .btn-pdf,
.logbook-card-right-group .btn-delete {
position: static;
opacity: 1;
}
.card-meta { .card-meta {
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -6523,3 +6554,148 @@ body.app-tour-active .feedback-modal-overlay--tour .disclaimer-modal-panel {
cursor: pointer; cursor: pointer;
accent-color: var(--app-accent, #fbbf24); accent-color: var(--app-accent, #fbbf24);
} }
/* Language Dropdown */
.lang-dropdown {
position: relative;
display: inline-block;
}
.lang-dropdown-trigger-flag {
font-size: 20px;
line-height: 1;
display: inline-block;
}
.lang-dropdown-chevron {
flex-shrink: 0;
opacity: 0.75;
transition: transform 0.2s ease;
margin-left: 6px;
}
.lang-dropdown.is-open .lang-dropdown-chevron {
transform: rotate(180deg);
}
.lang-dropdown-menu {
position: absolute;
z-index: 1000;
top: calc(100% + 8px);
margin: 0;
padding: 4px;
list-style: none;
border: 1px solid var(--app-input-border, rgba(255, 255, 255, 0.1));
border-radius: var(--app-radius-input, 12px);
box-shadow: var(--app-card-shadow, 0 10px 30px rgba(0, 0, 0, 0.3));
min-width: 140px;
overflow: hidden;
isolation: isolate;
animation: slideDownFade 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes slideDownFade {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.lang-dropdown.align-right .lang-dropdown-menu {
right: 0;
left: auto;
}
.lang-dropdown.align-left .lang-dropdown-menu {
left: 0;
right: auto;
}
html.scheme-light .lang-dropdown-menu {
background: #ffffff;
color: #0f172a;
border-color: rgba(0, 0, 0, 0.08);
}
html.scheme-dark .lang-dropdown-menu {
background: #1c1c1e;
color: #f8fafc;
border-color: rgba(255, 255, 255, 0.08);
}
.lang-dropdown-option {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: calc(var(--app-radius-input, 12px) - 4px);
cursor: pointer;
font-size: 15px;
font-weight: 500;
line-height: 1.4;
transition: background-color 0.15s ease, color 0.15s ease;
text-align: left;
}
.lang-flag-svg {
width: 20px;
height: 14px;
flex-shrink: 0;
display: inline-block;
vertical-align: middle;
}
.lang-flag-svg.trigger-icon-only {
width: 24px;
height: 17px;
}
html.scheme-light .lang-dropdown-option {
color: #334155;
-webkit-text-fill-color: #334155;
}
html.scheme-dark .lang-dropdown-option {
color: #cbd5e1;
-webkit-text-fill-color: #cbd5e1;
}
.lang-dropdown-option:hover {
background: var(--app-accent-bg, rgba(217, 119, 6, 0.1));
}
html.scheme-light .lang-dropdown-option:hover {
color: var(--app-accent, #d97706);
-webkit-text-fill-color: var(--app-accent, #d97706);
}
html.scheme-dark .lang-dropdown-option:hover {
color: var(--app-accent-light, #fbbf24);
-webkit-text-fill-color: var(--app-accent-light, #fbbf24);
}
.lang-dropdown-option.is-selected {
background: var(--app-accent-bg, rgba(217, 119, 6, 0.15));
font-weight: 600;
}
html.scheme-light .lang-dropdown-option.is-selected {
color: var(--app-accent, #d97706);
-webkit-text-fill-color: var(--app-accent, #d97706);
}
html.scheme-dark .lang-dropdown-option.is-selected {
color: var(--app-accent-light, #fbbf24);
-webkit-text-fill-color: var(--app-accent-light, #fbbf24);
}
.lang-trigger-name {
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+4 -11
View File
@@ -46,14 +46,14 @@ import { db } from './services/db.js'
import { getLogbookAccess } from './services/logbookAccess.js' import { getLogbookAccess } from './services/logbookAccess.js'
import type { LogbookAccessRole } from './services/logbook.js' import type { LogbookAccessRole } from './services/logbook.js'
import { useLiveQuery } from 'dexie-react-hooks' import { useLiveQuery } from 'dexie-react-hooks'
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react' import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, BarChart2 } from 'lucide-react'
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx' import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx' import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
import ProfileHeaderButton from './components/ProfileHeaderButton.tsx' import ProfileHeaderButton from './components/ProfileHeaderButton.tsx'
import AdminHeaderButton from './components/AdminHeaderButton.tsx' import AdminHeaderButton from './components/AdminHeaderButton.tsx'
import { checkAdminAccess } from './services/adminApi.js' import { checkAdminAccess } from './services/adminApi.js'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cycleAppLanguage } from './utils/i18nLanguages.js' import LanguageDropdown from './components/LanguageDropdown.tsx'
import { import {
resolveTourLogbookContext, resolveTourLogbookContext,
seedDemoLogbookIfNeeded seedDemoLogbookIfNeeded
@@ -66,7 +66,7 @@ import { requestPersistentStorage } from './utils/storagePersist.js'
const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id' const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
function App() { function App() {
const { t, i18n } = useTranslation() const { t } = useTranslation()
const { confirmLeave } = useUnsavedChangesContext() const { confirmLeave } = useUnsavedChangesContext()
const { registerNavigation, registerDemoTourContext, requestStartAfterLogin, isActive, currentStepId } = useAppTour() const { registerNavigation, registerDemoTourContext, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
const [isAuthenticated, setIsAuthenticated] = useState(false) const [isAuthenticated, setIsAuthenticated] = useState(false)
@@ -555,10 +555,6 @@ function App() {
localStorage.removeItem('active_logbook_title') localStorage.removeItem('active_logbook_title')
} }
const toggleLanguage = () => {
cycleAppLanguage(i18n)
}
const handleExitDemo = () => { const handleExitDemo = () => {
window.history.replaceState({}, document.title, '/') window.history.replaceState({}, document.title, '/')
syncRouteFromLocation() syncRouteFromLocation()
@@ -715,10 +711,7 @@ function App() {
{online ? <Wifi size={18} /> : <WifiOff size={18} />} {online ? <Wifi size={18} /> : <WifiOff size={18} />}
<span>{online ? 'Online' : t('sync.status_offline')}</span> <span>{online ? 'Online' : t('sync.status_offline')}</span>
</div> </div>
<LanguageDropdown variant="icon" align="right" />
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
<Languages size={18} />
</button>
{isAdminUser && <AdminHeaderButton onClick={openAdmin} />} {isAdminUser && <AdminHeaderButton onClick={openAdmin} />}
+4 -10
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js' import LanguageDropdown from './LanguageDropdown.tsx'
import { import {
registerUser, registerUser,
loginUser, loginUser,
@@ -15,7 +15,7 @@ import {
logoutUser, logoutUser,
resolveRestoreUsername resolveRestoreUsername
} from '../services/auth.js' } from '../services/auth.js'
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react' import { KeyRound, ShieldAlert, HelpCircle, UserRound, X } from 'lucide-react'
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx' import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
import DisclaimerModal from './DisclaimerModal.tsx' import DisclaimerModal from './DisclaimerModal.tsx'
import BetaBadge from './BetaBadge.tsx' import BetaBadge from './BetaBadge.tsx'
@@ -37,7 +37,7 @@ export default function AuthOnboarding({
onOpenDemo, onOpenDemo,
restoreSession = false restoreSession = false
}: AuthOnboardingProps) { }: AuthOnboardingProps) {
const { t, i18n } = useTranslation() const { t } = useTranslation()
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -267,9 +267,6 @@ export default function AuthOnboarding({
setKnownUsers(getKnownUsernames()) setKnownUsers(getKnownUsernames())
} }
const toggleLanguage = () => {
cycleAppLanguage(i18n)
}
const copyToClipboard = () => { const copyToClipboard = () => {
if (recoveryPhrase) { if (recoveryPhrase) {
@@ -780,10 +777,7 @@ export default function AuthOnboarding({
</div> </div>
<div className="auth-footer"> <div className="auth-footer">
<button type="button" className="btn-icon-text" onClick={toggleLanguage}> <LanguageDropdown variant="text" align="left" />
<Languages size={18} />
{t(`languages.${getNextLanguage(i18n.language)}`)}
</button>
<button <button
type="button" type="button"
className="btn-icon-text link-sec" className="btn-icon-text link-sec"
+4 -10
View File
@@ -1,12 +1,12 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js' import LanguageDropdown from './LanguageDropdown.tsx'
import LogbookVesselPicker from './LogbookVesselPicker.tsx' import LogbookVesselPicker from './LogbookVesselPicker.tsx'
import LogbookCrewPicker from './LogbookCrewPicker.tsx' import LogbookCrewPicker from './LogbookCrewPicker.tsx'
import type { LogbookCrewSelectionData } from '../types/person.js' import type { LogbookCrewSelectionData } from '../types/person.js'
import { personToSnapshot } from '../utils/personSnapshots.js' import { personToSnapshot } from '../utils/personSnapshots.js'
import LogEntriesList from './LogEntriesList.tsx' import LogEntriesList from './LogEntriesList.tsx'
import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react' import { Ship, Users, FileText, Lock, ChevronLeft, UserPlus } from 'lucide-react'
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js' import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
import type { VesselData } from '../types/vessel.js' import type { VesselData } from '../types/vessel.js'
import type { LogbookVesselSelectionData } from '../types/vessel.js' import type { LogbookVesselSelectionData } from '../types/vessel.js'
@@ -52,9 +52,6 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
} }
}, [registerNavigation, registerDemoTourContext, startTour, fixture.firstEntryId]) }, [registerNavigation, registerDemoTourContext, startTour, fixture.firstEntryId])
const toggleLanguage = () => {
cycleAppLanguage(i18n)
}
const { const {
title, title,
@@ -111,10 +108,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
<UserPlus size={14} style={{ marginRight: '4px' }} /> <UserPlus size={14} style={{ marginRight: '4px' }} />
{t('demo.cta_register')} {t('demo.cta_register')}
</button> </button>
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}> <LanguageDropdown variant="secondary-button" align="right" />
<Globe size={14} style={{ marginRight: '4px' }} />
{t(`languages.${getNextLanguage(i18n.language)}`)}
</button>
</div> </div>
</header> </header>
@@ -172,7 +166,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
payloadId: v.payloadId, payloadId: v.payloadId,
data: v.data as VesselData data: v.data as VesselData
}))} }))}
preloadedSelection={logbookVesselSelection as LogbookVesselSelectionData} preloadedSelection={logbookVesselSelection as unknown as LogbookVesselSelectionData}
/> />
)} )}
+4 -10
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback, useRef } from 'react' import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js' import LanguageDropdown from './LanguageDropdown.tsx'
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight, KeyRound } from 'lucide-react' import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, ArrowRight, KeyRound } from 'lucide-react'
import { import {
getActiveMasterKey, getActiveMasterKey,
registerUser, registerUser,
@@ -50,7 +50,7 @@ const hexToBuffer = (hex: string): ArrayBuffer => {
} }
export default function InvitationAcceptance({ onAccepted, onCancel }: InvitationAcceptanceProps) { export default function InvitationAcceptance({ onAccepted, onCancel }: InvitationAcceptanceProps) {
const { t, i18n } = useTranslation() const { t } = useTranslation()
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [accepting, setAccepting] = useState(false) const [accepting, setAccepting] = useState(false)
@@ -308,9 +308,6 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
setIsLoggedIn(true) setIsLoggedIn(true)
} }
const toggleLanguage = () => {
cycleAppLanguage(i18n)
}
if (recoveryPhrase) { if (recoveryPhrase) {
return ( return (
@@ -510,10 +507,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' }}> <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}> <LanguageDropdown variant="text" align="left" />
<Languages size={18} />
{t(`languages.${getNextLanguage(i18n.language)}`)}
</button>
</div> </div>
</div> </div>
) )
+206
View File
@@ -0,0 +1,206 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Languages, Globe, ChevronDown } from 'lucide-react'
import {
SUPPORTED_LANGUAGES,
changeAppLanguage,
normalizeAppLanguage,
type AppLanguage
} from '../utils/i18nLanguages.js'
function FlagIcon({ lang, className, style }: { lang: string; className?: string; style?: React.CSSProperties }) {
const baseStyle = {
display: 'inline-block',
verticalAlign: 'middle',
borderRadius: '2px',
overflow: 'hidden',
border: '1px solid rgba(255, 255, 255, 0.15)',
boxSizing: 'border-box' as const,
...style
}
switch (lang) {
case 'de':
return (
<svg viewBox="0 0 5 3" className={className} style={baseStyle}>
<rect width="5" height="3" fill="#FFCE00"/>
<rect width="5" height="2" fill="#DD0000"/>
<rect width="5" height="1" fill="#000000"/>
</svg>
)
case 'en':
return (
<svg viewBox="0 0 60 30" className={className} style={baseStyle}>
<clipPath id="union-jack-clip">
<path d="M0,0 L60,30 M60,0 L0,30"/>
</clipPath>
<rect width="60" height="30" fill="#012169"/>
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#fff" strokeWidth="6"/>
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#C8102E" strokeWidth="4" clipPath="url(#union-jack-clip)"/>
<path d="M30,0 v30 M0,15 h60" stroke="#fff" strokeWidth="10"/>
<path d="M30,0 v30 M0,15 h60" stroke="#C8102E" strokeWidth="6"/>
</svg>
)
case 'da':
return (
<svg viewBox="0 0 37 28" className={className} style={baseStyle}>
<rect width="37" height="28" fill="#C8102E"/>
<path d="M12,0 h4 v28 h-4 z M0,12 h37 v4 h-37 z" fill="#FFFFFF"/>
</svg>
)
case 'sv':
return (
<svg viewBox="0 0 16 10" className={className} style={baseStyle}>
<rect width="16" height="10" fill="#006AA7"/>
<path d="M5,0 h2 v10 h-2 z M0,4 h16 v2 h-16 z" fill="#FECC00"/>
</svg>
)
case 'nb':
return (
<svg viewBox="0 0 22 16" className={className} style={baseStyle}>
<rect width="22" height="16" fill="#BA0C2F"/>
<path d="M6,0 h4 v16 h-4 z M0,6 h22 v4 h-22 z" fill="#FFFFFF"/>
<path d="M7,0 h2 v16 h-2 z M0,7 h22 v2 h-22 z" fill="#00205B"/>
</svg>
)
case 'fr':
return (
<svg viewBox="0 0 3 2" className={className} style={baseStyle}>
<rect width="3" height="2" fill="#FFFFFF"/>
<rect width="1" height="2" fill="#002395"/>
<rect x="2" width="1" height="2" fill="#ED2939"/>
</svg>
)
case 'es':
return (
<svg viewBox="0 0 3 2" className={className} style={baseStyle}>
<rect width="3" height="2" fill="#C1272D"/>
<rect y="0.5" width="3" height="1" fill="#FEE100"/>
</svg>
)
default:
return null
}
}
interface LanguageDropdownProps {
variant?: 'icon' | 'text' | 'secondary-button'
align?: 'left' | 'right'
}
export default function LanguageDropdown({
variant = 'icon',
align = 'right'
}: LanguageDropdownProps) {
const { t, i18n } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
const activeLang = normalizeAppLanguage(i18n.language)
useEffect(() => {
if (!isOpen) return
const closeOnOutsideClick = (event: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setIsOpen(false)
}
document.addEventListener('mousedown', closeOnOutsideClick)
document.addEventListener('keydown', closeOnEscape)
return () => {
document.removeEventListener('mousedown', closeOnOutsideClick)
document.removeEventListener('keydown', closeOnEscape)
}
}, [isOpen])
const selectLanguage = (lang: AppLanguage) => {
changeAppLanguage(i18n, lang)
setIsOpen(false)
}
// Trigger button content based on variant
const renderTriggerContent = () => {
const name = t(`languages.${activeLang}`)
if (variant === 'icon') {
return (
<span className="lang-dropdown-trigger-flag" aria-hidden="true">
<FlagIcon lang={activeLang} className="lang-flag-svg trigger-icon-only" />
</span>
)
}
if (variant === 'secondary-button') {
return (
<>
<Globe size={14} style={{ marginRight: '4px' }} />
<FlagIcon lang={activeLang} className="lang-flag-svg" style={{ marginRight: '4px' }} />
<span className="lang-trigger-name">{name}</span>
<ChevronDown size={12} className="lang-dropdown-chevron" />
</>
)
}
// Default or "text" variant (used in footer)
return (
<>
<Languages size={18} />
<FlagIcon lang={activeLang} className="lang-flag-svg" style={{ margin: '0 4px' }} />
<span>{name}</span>
<ChevronDown size={14} className="lang-dropdown-chevron" />
</>
)
}
const triggerClass =
variant === 'icon'
? 'btn-icon'
: variant === 'secondary-button'
? 'btn secondary compact'
: 'btn-icon-text'
return (
<div
className={`lang-dropdown ${isOpen ? 'is-open' : ''} align-${align}`}
ref={rootRef}
>
<button
type="button"
className={triggerClass}
onClick={() => setIsOpen((prev) => !prev)}
aria-haspopup="listbox"
aria-expanded={isOpen}
title="Switch Language"
style={variant === 'secondary-button' ? { width: 'auto', padding: '6px 12px', fontSize: '13px' } : undefined}
>
{renderTriggerContent()}
</button>
{isOpen && (
<ul className="lang-dropdown-menu" role="listbox">
{SUPPORTED_LANGUAGES.map((lang) => {
const isSelected = lang === activeLang
return (
<li
key={lang}
role="option"
aria-selected={isSelected}
className={`lang-dropdown-option ${isSelected ? 'is-selected' : ''}`}
onClick={() => selectLanguage(lang)}
>
<FlagIcon lang={lang} className="lang-flag-svg" />
<span className="lang-option-name">{t(`languages.${lang}`)}</span>
</li>
)
})}
</ul>
)}
</div>
)
}
+10 -10
View File
@@ -541,17 +541,17 @@ export default function LogEntriesList({
</div> </div>
</div> </div>
<ChevronRight size={18} className="logbook-card-chevron" aria-hidden /> <div className="logbook-card-right-group">
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}> <Download size={18} />
<Download size={18} />
</button>
{!readOnly && (
<button className="btn-delete" onClick={(e) => handleDelete(item.id, e)} title={t('logs.delete_entry')}>
<Trash2 size={18} />
</button> </button>
)} {!readOnly && (
<button className="btn-delete" onClick={(e) => handleDelete(item.id, e)} title={t('logs.delete_entry')}>
<Trash2 size={18} />
</button>
)}
<ChevronRight size={18} className="logbook-card-chevron" aria-hidden />
</div>
</div> </div>
))} ))}
</div> </div>
+16 -14
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef, useMemo } from 'react' import React, { useState, useEffect, useRef, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cycleAppLanguage } from '../utils/i18nLanguages.js' import LanguageDropdown from './LanguageDropdown.tsx'
import { useSyncIndicator } from '../hooks/useSyncIndicator.js' import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js' import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
import { loadLogbookSearchFieldsBatch } from '../services/logbookSearchIndex.js' import { loadLogbookSearchFieldsBatch } from '../services/logbookSearchIndex.js'
@@ -11,7 +11,7 @@ import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js' import { getErrorMessage } from '../utils/errors.js'
import { logoutUser } from '../services/auth.js' import { logoutUser } from '../services/auth.js'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react' import { BookOpen, Plus, Trash2, LogOut, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx' import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx' import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
import ProfileHeaderButton from './ProfileHeaderButton.tsx' import ProfileHeaderButton from './ProfileHeaderButton.tsx'
@@ -35,10 +35,14 @@ function sortLogbooks(
): DecryptedLogbook[] { ): DecryptedLogbook[] {
const sorted = [...items] const sorted = [...items]
sorted.sort((a, b) => { sorted.sort((a, b) => {
const cmp = let cmp = 0
sortBy === 'name' if (sortBy === 'name') {
? a.title.localeCompare(b.title, locale, { sensitivity: 'base' }) cmp = a.title.localeCompare(b.title, locale, { sensitivity: 'base' })
: new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime() } else {
const timeA = a.lastTravelDate ? new Date(a.lastTravelDate).getTime() : new Date(a.updatedAt).getTime()
const timeB = b.lastTravelDate ? new Date(b.lastTravelDate).getTime() : new Date(b.updatedAt).getTime()
cmp = timeA - timeB
}
return direction === 'asc' ? cmp : -cmp return direction === 'asc' ? cmp : -cmp
}) })
return sorted return sorted
@@ -198,9 +202,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
onLogout() onLogout()
} }
const toggleLanguage = () => {
cycleAppLanguage(i18n)
}
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared) const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
const sharedLogbooks = logbooks.filter((lb) => lb.isShared) const sharedLogbooks = logbooks.filter((lb) => lb.isShared)
@@ -291,8 +292,12 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
{lb.isDemo && ( {lb.isDemo && (
<span className="demo-badge">{t('demo.badge')}</span> <span className="demo-badge">{t('demo.badge')}</span>
)} )}
<span className="entry-count-badge" title={t('dashboard.travel_days_count', { count: lb.entryCount ?? 0 })}>
<CalendarDays size={12} style={{ marginRight: '4px' }} />
{lb.entryCount ?? 0}
</span>
<span className="date-badge"> <span className="date-badge">
{new Date(lb.updatedAt).toLocaleDateString(i18n.language, { {new Date(lb.lastTravelDate || lb.updatedAt).toLocaleDateString(i18n.language, {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric' day: 'numeric'
@@ -392,10 +397,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
{onOpenAdmin && <AdminHeaderButton onClick={onOpenAdmin} />} {onOpenAdmin && <AdminHeaderButton onClick={onOpenAdmin} />}
{/* Lang toggle */} <LanguageDropdown variant="icon" align="right" />
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
<Languages size={18} />
</button>
<DisclaimerHeaderButton /> <DisclaimerHeaderButton />
+4 -9
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage, isGermanLocale } from '../utils/i18nLanguages.js' import { isGermanLocale } from '../utils/i18nLanguages.js'
import LanguageDropdown from './LanguageDropdown.tsx'
import { decryptJson } from '../services/crypto.js' import { decryptJson } from '../services/crypto.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import LogbookVesselPicker from './LogbookVesselPicker.tsx' import LogbookVesselPicker from './LogbookVesselPicker.tsx'
@@ -12,7 +13,7 @@ import { emptyLogbookCrewSelection } from '../types/person.js'
import { legacyCrewRecordsToLogbookSelection } from '../utils/personSnapshots.js' import { legacyCrewRecordsToLogbookSelection } from '../utils/personSnapshots.js'
import type { PersonData } from '../types/person.js' import type { PersonData } from '../types/person.js'
import LogEntriesList from './LogEntriesList.tsx' import LogEntriesList from './LogEntriesList.tsx'
import { Ship, Users, FileText, Lock, AlertCircle, Globe } from 'lucide-react' import { Ship, Users, FileText, Lock, AlertCircle } from 'lucide-react'
interface ReadOnlyViewerProps { interface ReadOnlyViewerProps {
token: string token: string
@@ -215,9 +216,6 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
} }
} }
const toggleLanguage = () => {
cycleAppLanguage(i18n)
}
if (loading) { if (loading) {
return ( return (
@@ -258,10 +256,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
</div> </div>
<div className="header-actions"> <div className="header-actions">
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}> <LanguageDropdown variant="secondary-button" align="right" />
<Globe size={14} style={{ marginRight: '4px' }} />
{t(`languages.${getNextLanguage(i18n.language)}`)}
</button>
</div> </div>
</header> </header>
+5 -1
View File
@@ -6,6 +6,8 @@ import deJson from './locales/de.json'
import daJson from './locales/da.json' import daJson from './locales/da.json'
import svJson from './locales/sv.json' import svJson from './locales/sv.json'
import nbJson from './locales/nb.json' import nbJson from './locales/nb.json'
import frJson from './locales/fr.json'
import esJson from './locales/es.json'
import { initSeo } from '../utils/seo.js' import { initSeo } from '../utils/seo.js'
import { SUPPORTED_LANGUAGES } from '../utils/i18nLanguages.js' import { SUPPORTED_LANGUAGES } from '../utils/i18nLanguages.js'
@@ -15,7 +17,9 @@ const resources = {
de: { translation: deJson.translation }, de: { translation: deJson.translation },
da: { translation: daJson.translation }, da: { translation: daJson.translation },
sv: { translation: svJson.translation }, sv: { translation: svJson.translation },
nb: { translation: nbJson.translation } nb: { translation: nbJson.translation },
fr: { translation: frJson.translation },
es: { translation: esJson.translation }
} }
i18n i18n
+5 -1
View File
@@ -4,6 +4,8 @@ import enJson from '../i18n/locales/en.json'
import daJson from '../i18n/locales/da.json' import daJson from '../i18n/locales/da.json'
import svJson from '../i18n/locales/sv.json' import svJson from '../i18n/locales/sv.json'
import nbJson from '../i18n/locales/nb.json' import nbJson from '../i18n/locales/nb.json'
import frJson from '../i18n/locales/fr.json'
import esJson from '../i18n/locales/es.json'
function collectKeys(obj: Record<string, unknown>, prefix = ''): string[] { function collectKeys(obj: Record<string, unknown>, prefix = ''): string[] {
const keys: string[] = [] const keys: string[] = []
@@ -23,7 +25,9 @@ const bundles = {
en: enJson.translation, en: enJson.translation,
da: daJson.translation, da: daJson.translation,
sv: svJson.translation, sv: svJson.translation,
nb: nbJson.translation nb: nbJson.translation,
fr: frJson.translation,
es: esJson.translation
} as const } as const
describe('i18n locale key parity', () => { describe('i18n locale key parity', () => {
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -15,7 +15,9 @@
"en": "English", "en": "English",
"da": "Dansk", "da": "Dansk",
"sv": "Svenska", "sv": "Svenska",
"nb": "Norsk" "nb": "Norsk",
"fr": "Français",
"es": "Español"
}, },
"dialog": { "dialog": {
"ok": "OK", "ok": "OK",
@@ -540,6 +542,9 @@
"delete_confirm": "Bist du sicher, dass du dieses Logbuch unwiderruflich löschen möchtest? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstelle vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok), falls du die Daten später behalten möchtest.", "delete_confirm": "Bist du sicher, dass du dieses Logbuch unwiderruflich löschen möchtest? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstelle vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok), falls du die Daten später behalten möchtest.",
"no_logbooks": "Keine Logbücher gefunden. Erstelle dein erstes Logbuch, um zu beginnen!", "no_logbooks": "Keine Logbücher gefunden. Erstelle dein erstes Logbuch, um zu beginnen!",
"loading": "Logbücher werden geladen...", "loading": "Logbücher werden geladen...",
"travel_days_count_zero": "Keine Reisetage",
"travel_days_count_one": "1 Reisetag",
"travel_days_count_other": "{{count}} Reisetage",
"status_synced": "Synchronisiert", "status_synced": "Synchronisiert",
"status_local": "Nur lokaler Cache", "status_local": "Nur lokaler Cache",
"delete_btn": "Logbuch löschen", "delete_btn": "Logbuch löschen",
+6 -1
View File
@@ -15,7 +15,9 @@
"en": "English", "en": "English",
"da": "Dansk", "da": "Dansk",
"sv": "Svenska", "sv": "Svenska",
"nb": "Norsk" "nb": "Norsk",
"fr": "French",
"es": "Spanish"
}, },
"dialog": { "dialog": {
"ok": "OK", "ok": "OK",
@@ -540,6 +542,9 @@
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok) if you may need the data later.", "delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok) if you may need the data later.",
"no_logbooks": "No logbooks found. Create your first logbook to begin!", "no_logbooks": "No logbooks found. Create your first logbook to begin!",
"loading": "Loading logbooks...", "loading": "Loading logbooks...",
"travel_days_count_zero": "No travel days",
"travel_days_count_one": "1 travel day",
"travel_days_count_other": "{{count}} travel days",
"status_synced": "Synced", "status_synced": "Synced",
"status_local": "Local Cache Only", "status_local": "Local Cache Only",
"delete_btn": "Delete logbook", "delete_btn": "Delete logbook",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+20 -2
View File
@@ -34,6 +34,8 @@ export interface DecryptedLogbook {
isShared: boolean isShared: boolean
accessRole: LogbookAccessRole accessRole: LogbookAccessRole
isDemo?: boolean isDemo?: boolean
lastTravelDate?: string
entryCount?: number
} }
// Helper to decrypt a logbook's title using the active logbook key or master key // Helper to decrypt a logbook's title using the active logbook key or master key
@@ -142,10 +144,24 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
// Retrieve all from Dexie cache // Retrieve all from Dexie cache
const cachedLogbooks = await db.logbooks.toArray() const cachedLogbooks = await db.logbooks.toArray()
// Decrypt titles // Decrypt titles and query last travel dates
const decrypted: DecryptedLogbook[] = [] const decrypted: DecryptedLogbook[] = []
for (const lb of cachedLogbooks) { for (const lb of cachedLogbooks) {
const title = await decryptLogbookTitle(lb.id, lb.encryptedTitle) const title = await decryptLogbookTitle(lb.id, lb.encryptedTitle)
// Find latest travel date from local entries cache
const entries = await db.entries.where({ logbookId: lb.id }).toArray()
let lastTravelDate: string | undefined = undefined
if (entries.length > 0) {
const dates = entries
.map((e) => e.listCache?.date)
.filter((d): d is string => typeof d === 'string' && d.length > 0)
if (dates.length > 0) {
dates.sort()
lastTravelDate = dates[dates.length - 1]
}
}
decrypted.push({ decrypted.push({
id: lb.id, id: lb.id,
title, title,
@@ -155,7 +171,9 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
accessRole: lb.isShared === 1 accessRole: lb.isShared === 1
? parseCollaborationRole(lb.collaborationRole, `cached logbook ${lb.id}`) ? parseCollaborationRole(lb.collaborationRole, `cached logbook ${lb.id}`)
: 'OWNER', : 'OWNER',
isDemo: lb.isDemo === 1 isDemo: lb.isDemo === 1,
lastTravelDate,
entryCount: entries.length
}) })
} }
+6 -7
View File
@@ -20,14 +20,13 @@ vi.mock('../services/analytics.js', async (importOriginal) => {
}) })
function createMockI18n(language: string): I18nInstance { function createMockI18n(language: string): I18nInstance {
let current = language const mock = {
return { language,
language: current,
changeLanguage: vi.fn(async (lng: string) => { changeLanguage: vi.fn(async (lng: string) => {
current = lng mock.language = lng
;(this as { language: string }).language = lng
}) })
} as unknown as I18nInstance } as unknown as I18nInstance
return mock
} }
describe('i18nLanguages', () => { describe('i18nLanguages', () => {
@@ -72,11 +71,11 @@ describe('i18nLanguages', () => {
}) })
it('cycleAppLanguage tracks the next language', () => { it('cycleAppLanguage tracks the next language', () => {
const i18n = createMockI18n('nb') const i18n = createMockI18n('es')
cycleAppLanguage(i18n) cycleAppLanguage(i18n)
expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.LANGUAGE_CHANGED, { expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.LANGUAGE_CHANGED, {
from: 'nb', from: 'es',
to: 'de' to: 'de'
}) })
}) })
+11 -1
View File
@@ -2,10 +2,20 @@ import type { i18n as I18nInstance } from 'i18next'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
/** Supported UI languages (ISO 639-1, language-only). */ /** Supported UI languages (ISO 639-1, language-only). */
export const SUPPORTED_LANGUAGES = ['de', 'en', 'da', 'sv', 'nb'] as const export const SUPPORTED_LANGUAGES = ['de', 'en', 'da', 'sv', 'nb', 'fr', 'es'] as const
export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number] export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number]
export const LANGUAGE_FLAGS: Record<AppLanguage, string> = {
de: '🇩🇪',
en: '🇬🇧',
da: '🇩🇰',
sv: '🇸🇪',
nb: '🇳🇴',
fr: '🇫🇷',
es: '🇪🇸'
}
export function normalizeAppLanguage(language?: string): AppLanguage { export function normalizeAppLanguage(language?: string): AppLanguage {
const base = (language ?? 'en').split('-')[0].toLowerCase() const base = (language ?? 'en').split('-')[0].toLowerCase()
if ((SUPPORTED_LANGUAGES as readonly string[]).includes(base)) { if ((SUPPORTED_LANGUAGES as readonly string[]).includes(base)) {
+3 -1
View File
@@ -10,7 +10,9 @@ const OG_LOCALES: Record<SeoLang, string> = {
en: 'en_GB', en: 'en_GB',
da: 'da_DK', da: 'da_DK',
sv: 'sv_SE', sv: 'sv_SE',
nb: 'nb_NO' nb: 'nb_NO',
fr: 'fr_FR',
es: 'es_ES'
} }
let i18nRef: I18nInstance | null = null let i18nRef: I18nInstance | null = null
+3 -1
View File
@@ -23,7 +23,9 @@ const defaultSource = resolve(repoRoot, 'client/src/i18n/locales/de.json')
const TARGETS = { const TARGETS = {
da: 'DA', da: 'DA',
sv: 'SV', sv: 'SV',
nb: 'NB' nb: 'NB',
fr: 'FR',
es: 'ES'
} }
/** Keys whose values stay identical to source (language names, brand). */ /** Keys whose values stay identical to source (language names, brand). */
+1 -1
View File
@@ -11,7 +11,7 @@ import { flattenTranslation } from './lib/deepl-translate.mjs'
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
const localesDir = resolve(__dirname, '../client/src/i18n/locales') const localesDir = resolve(__dirname, '../client/src/i18n/locales')
const localeFiles = ['de.json', 'en.json', 'da.json', 'sv.json', 'nb.json'] const localeFiles = ['de.json', 'en.json', 'da.json', 'sv.json', 'nb.json', 'fr.json', 'es.json']
async function loadKeys(filename) { async function loadKeys(filename) {
const raw = await readFile(resolve(localesDir, filename), 'utf8') const raw = await readFile(resolve(localesDir, filename), 'utf8')