Compare commits

...

24 Commits

Author SHA1 Message Date
elpatron 444d347c56 chore: release v0.1.1.31 2026-06-08 07:44:12 +02:00
elpatron a185bbaf27 feat: add local photo zip download for logged-in users and swipe navigation to maximized gallery 2026-06-07 21:42:00 +02:00
elpatron 864d45714c feat(settings): add share button next to copy button on mobile devices for public share link 2026-06-07 21:19:57 +02:00
elpatron faf3b8e3cf chore: release v0.1.1.30 2026-06-07 14:32:46 +02:00
elpatron 74ff8eb16b style: fix journal entry action buttons alignment on mobile 2026-06-07 14:27:44 +02:00
elpatron 81d3e3b777 feat: show travel day count badge on logbook dashboard 2026-06-07 14:22:17 +02:00
elpatron 97c5173e63 chore: release v0.1.1.29 2026-06-07 13:51:26 +02:00
elpatron 8b34044481 chore: switch default git remote to self-hosted Gitea instance 2026-06-07 13:46:28 +02:00
elpatron d948325a45 feat: add French and Spanish locales and update language selector 2026-06-07 13:44:27 +02:00
elpatron 8b8196f6e3 chore: release v0.1.1.28 2026-06-07 13:30:32 +02:00
elpatron 6593b320ee feat(i18n): integrate LanguageDropdown in LogbookDashboard 2026-06-07 13:26:29 +02:00
elpatron 9a931024d6 chore: revert git remote configuration to use github by default 2026-06-07 13:04:42 +02:00
elpatron 4dfe2cea4e feat(i18n): replace language cycle buttons with flag dropdown selector using inline SVGs 2026-06-07 12:59:40 +02:00
elpatron 944f4518e9 chore: update default git remote and url to point to gitea instance 2026-06-07 11:44:06 +02:00
elpatron 0c765f712c chore: release v0.1.1.27 2026-06-07 11:22:28 +02:00
elpatron 676547686b chore: update default git remote to github repository 2026-06-07 11:22:24 +02:00
elpatron 66606c5eca chore: default deployment script back to Gitea origin 2026-06-07 11:11:51 +02:00
elpatron a30fac029d chore: release v0.1.1.26 2026-06-07 11:10:43 +02:00
elpatron 796e61f4ea chore: migrate deployment script to use GitHub remote instead of origin Gitea 2026-06-07 09:09:43 +02:00
elpatron 594c65d1a5 feat: make photo capture attachments section collapsible by default 2026-06-07 09:00:14 +02:00
elpatron fafefff29b chore: release v0.1.1.25 2026-06-06 22:02:25 +02:00
elpatron 4fd7f3c6cf feat(journal): wrap Crew an diesem Reisetag card inside a collapsible accordion defaulting to collapsed 2026-06-06 21:59:25 +02:00
elpatron 262c48a01a chore: document COMPOSE_FILE in .env.example to lock environment compose stack configurations 2026-06-06 21:53:43 +02:00
elpatron 9ad3c2cf38 Add Database Size single metric and time series history chart to Admin Dashboard 2026-06-06 21:45:19 +02:00
33 changed files with 5218 additions and 2158 deletions
+2
View File
@@ -34,6 +34,8 @@ ORIGIN=http://localhost:5173
# POSTGRES_USER=postgres # POSTGRES_USER=postgres
# POSTGRES_PASSWORD= # POSTGRES_PASSWORD=
# POSTGRES_DB=daagbox # POSTGRES_DB=daagbox
# Optional: lock Docker Compose to a specific configuration file (e.g. staging or production) on the server:
# COMPOSE_FILE=docker-compose.staging.yml
# Optional: comma-separated CORS origins (defaults to ORIGIN; 127.0.0.1 may be allowed for CORS but not for login) # Optional: comma-separated CORS origins (defaults to ORIGIN; 127.0.0.1 may be allowed for CORS but not for login)
# CORS_ORIGINS=http://localhost:5173 # CORS_ORIGINS=http://localhost:5173
+1 -1
View File
@@ -1 +1 @@
0.1.1.25 0.1.1.32
+221
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;
} }
@@ -3305,6 +3336,51 @@ html.theme-cupertino .events-scroll-container {
word-break: break-word; word-break: break-word;
} }
.photo-maximized-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #f1f5f9;
border-radius: 50%;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
z-index: 11005;
}
.photo-maximized-nav:hover {
background: rgba(255, 255, 255, 0.2);
border-color: #ffffff;
transform: translateY(-50%) scale(1.08);
}
.photo-maximized-prev {
left: 24px;
}
.photo-maximized-next {
right: 24px;
}
@media (max-width: 768px) {
.photo-maximized-nav {
width: 44px;
height: 44px;
}
.photo-maximized-prev {
left: 12px;
}
.photo-maximized-next {
right: 12px;
}
}
/* Custom Dialog Modals Styling */ /* Custom Dialog Modals Styling */
.custom-dialog-overlay { .custom-dialog-overlay {
position: fixed; position: fixed;
@@ -6523,3 +6599,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} />}
+15 -3
View File
@@ -7,12 +7,22 @@ import {
type AdminTimeSeriesResponse, type AdminTimeSeriesResponse,
type AdminTimeBucket type AdminTimeBucket
} from '../services/adminApi.js' } from '../services/adminApi.js'
import { BarChart2, Bookmark, ChevronLeft, Image, MapPin, Mic, Users } from 'lucide-react' import { BarChart2, Bookmark, ChevronLeft, Database, Image, MapPin, Mic, Users } from 'lucide-react'
function formatNumber(value: number): string { function formatNumber(value: number): string {
return value.toLocaleString() return value.toLocaleString()
} }
function formatBytes(bytes: number | undefined): string {
if (bytes === undefined || bytes === null) return '—'
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
const num = bytes / Math.pow(k, i)
return `${num.toFixed(1)} ${sizes[i]}`
}
function KpiCard({ function KpiCard({
icon, icon,
label, label,
@@ -20,14 +30,14 @@ function KpiCard({
}: { }: {
icon: ReactNode icon: ReactNode
label: string label: string
value: number value: number | string
}) { }) {
return ( return (
<div className="stats-kpi-card glass"> <div className="stats-kpi-card glass">
<div className="stats-kpi-icon">{icon}</div> <div className="stats-kpi-icon">{icon}</div>
<div className="stats-kpi-body"> <div className="stats-kpi-body">
<span className="stats-kpi-label">{label}</span> <span className="stats-kpi-label">{label}</span>
<span className="stats-kpi-value">{formatNumber(value)}</span> <span className="stats-kpi-value">{typeof value === 'number' ? formatNumber(value) : value}</span>
</div> </div>
</div> </div>
) )
@@ -194,6 +204,7 @@ export default function AdminDashboard({ onBack }: AdminDashboardProps) {
label="Einträge mit AI-Zusammenfassung" label="Einträge mit AI-Zusammenfassung"
value={summary.aiSummaryEntries} value={summary.aiSummaryEntries}
/> />
<KpiCard icon={<Database size={20} />} label="Datenbankgröße" value={formatBytes(summary.dbSize)} />
</section> </section>
<section className="admin-controls"> <section className="admin-controls">
@@ -233,6 +244,7 @@ export default function AdminDashboard({ onBack }: AdminDashboardProps) {
<TimeSeriesChart title="Neue Benutzer" seriesKey="users_created" data={timeSeries} /> <TimeSeriesChart title="Neue Benutzer" seriesKey="users_created" data={timeSeries} />
<TimeSeriesChart title="Neue Logbücher" seriesKey="logbooks_created" data={timeSeries} /> <TimeSeriesChart title="Neue Logbücher" seriesKey="logbooks_created" data={timeSeries} />
<TimeSeriesChart title="Foto-Aktivität" seriesKey="photos_updated" data={timeSeries} /> <TimeSeriesChart title="Foto-Aktivität" seriesKey="photos_updated" data={timeSeries} />
<TimeSeriesChart title="Datenbankgröße (MB)" seriesKey="database_size" data={timeSeries} />
</section> </section>
</main> </main>
</div> </div>
+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}
/> />
)} )}
+69 -44
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Users } from 'lucide-react' import { Users, ChevronDown, ChevronUp } from 'lucide-react'
import type { EntryCrewFields, PersonSnapshot } from '../types/person.js' import type { EntryCrewFields, PersonSnapshot } from '../types/person.js'
import { loadPersonPool } from '../services/personPool.js' import { loadPersonPool } from '../services/personPool.js'
import { loadLogbookCrewSelection } from '../services/logbookCrewSelection.js' import { loadLogbookCrewSelection } from '../services/logbookCrewSelection.js'
@@ -24,6 +24,7 @@ export default function EntryCrewSection({
preloadedPool preloadedPool
}: EntryCrewSectionProps) { }: EntryCrewSectionProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [collapsed, setCollapsed] = useState(true)
const [pool, setPool] = useState<Map<string, PersonData>>(preloadedPool ?? new Map()) const [pool, setPool] = useState<Map<string, PersonData>>(preloadedPool ?? new Map())
useEffect(() => { useEffect(() => {
@@ -90,54 +91,78 @@ export default function EntryCrewSection({
return ( return (
<div className="form-card" data-tour="entry-crew"> <div className="form-card" data-tour="entry-crew">
<div className="form-header"> <div
<Users size={22} className="form-icon" /> className="form-header accordion-header"
<h3>{t('entry_crew.title')}</h3> onClick={() => setCollapsed(!collapsed)}
</div> onKeyDown={(e) => {
<p className="help-text mb-3">{t('entry_crew.subtitle')}</p> if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
<div className="input-group mb-3"> setCollapsed(!collapsed)
<label>{t('entry_crew.day_skipper')}</label> }
{skippers.length === 0 ? ( }}
<p className="help-text">{t('entry_crew.no_skipper')}</p> role="button"
aria-expanded={!collapsed}
tabIndex={0}
>
<div className="accordion-header-title">
<Users size={22} className="form-icon" />
<h3>{t('entry_crew.title')}</h3>
</div>
{collapsed ? (
<ChevronDown size={20} className="accordion-chevron" />
) : ( ) : (
<div className="crew-selection-list"> <ChevronUp size={20} className="accordion-chevron" />
{skippers.map(([id, data]) => (
<label key={id} className="crew-selection-item">
<input
type="radio"
name={`entry-skipper-${logbookId}`}
checked={value.selectedSkipperId === id}
onChange={() => !readOnly && applyChange(id, value.selectedCrewIds)}
disabled={readOnly}
/>
<span>{data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)} )}
</div> </div>
<div className="input-group"> {!collapsed && (
<label>{t('entry_crew.day_crew')}</label> <>
{crewEntries.length === 0 ? ( <p className="help-text mb-3" style={{ marginTop: '16px' }}>{t('entry_crew.subtitle')}</p>
<p className="help-text">{t('entry_crew.no_crew')}</p>
) : ( <div className="input-group mb-3">
<div className="crew-selection-list"> <label>{t('entry_crew.day_skipper')}</label>
{crewEntries.map(([id, data]) => ( {skippers.length === 0 ? (
<label key={id} className="crew-selection-item"> <p className="help-text">{t('entry_crew.no_skipper')}</p>
<input ) : (
type="checkbox" <div className="crew-selection-list">
checked={value.selectedCrewIds.includes(id)} {skippers.map(([id, data]) => (
onChange={() => toggleCrew(id)} <label key={id} className="crew-selection-item">
disabled={readOnly} <input
/> type="radio"
<span>{data.name || t('logbook_crew.unnamed')}</span> name={`entry-skipper-${logbookId}`}
</label> checked={value.selectedSkipperId === id}
))} onChange={() => !readOnly && applyChange(id, value.selectedCrewIds)}
disabled={readOnly}
/>
<span>{data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)}
</div> </div>
)}
</div> <div className="input-group">
<label>{t('entry_crew.day_crew')}</label>
{crewEntries.length === 0 ? (
<p className="help-text">{t('entry_crew.no_crew')}</p>
) : (
<div className="crew-selection-list">
{crewEntries.map(([id, data]) => (
<label key={id} className="crew-selection-item">
<input
type="checkbox"
checked={value.selectedCrewIds.includes(id)}
onChange={() => toggleCrew(id)}
disabled={readOnly}
/>
<span>{data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)}
</div>
</>
)}
</div> </div>
) )
} }
+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>
)
}
+148 -12
View File
@@ -1,12 +1,13 @@
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 { db } from '../services/db.js' import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js' import { getActiveMasterKey, hasUnlockedLocalCrypto } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js' import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson } from '../services/crypto.js' import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js' import { syncLogbook } from '../services/sync.js'
import { downloadCsv, shareCsv } from '../services/csvExport.js' import { downloadCsv, shareCsv } from '../services/csvExport.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js' import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { buildZipArchive } from '../services/logbookBackup/zipArchive.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js' import { getErrorMessage } from '../utils/errors.js'
import { findTodayEntryId, pruneEmptyTodayDuplicates, tryDecryptEntryPayload } from '../services/quickEventLog.js' import { findTodayEntryId, pruneEmptyTodayDuplicates, tryDecryptEntryPayload } from '../services/quickEventLog.js'
@@ -59,6 +60,42 @@ interface DecryptedEntryItem {
skipperSignStatus: SkipperSignStatus skipperSignStatus: SkipperSignStatus
} }
// Helper to convert data URL to Uint8Array for zip packaging
function dataUrlToUint8Array(dataUrl: string): { data: Uint8Array; ext: string } {
const parts = dataUrl.split(',')
if (parts.length < 2) {
throw new Error('Invalid data URL')
}
const meta = parts[0]
const base64Data = parts[1]
let ext = 'jpg'
const mimeMatch = meta.match(/data:([^;]+)/)
if (mimeMatch) {
const mime = mimeMatch[1]
if (mime === 'image/png') ext = 'png'
else if (mime === 'image/gif') ext = 'gif'
else if (mime === 'image/webp') ext = 'webp'
else if (mime === 'image/heic') ext = 'heic'
else if (mime === 'image/heif') ext = 'heif'
}
const binaryString = atob(base64Data)
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
return { data: bytes, ext }
}
function sanitizeFilename(str: string): string {
return str
.replace(/[^\w\s-]/gi, '')
.trim()
.replace(/\s+/g, '_')
.slice(0, 30)
}
export default function LogEntriesList({ export default function LogEntriesList({
logbookId, logbookId,
readOnly = false, readOnly = false,
@@ -257,6 +294,90 @@ export default function LogEntriesList({
} }
} }
const handleDownloadPhotosZip = async () => {
setExporting(true)
setError(null)
try {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
// Fetch all photos for this logbook from IndexedDB
const localPhotos = await db.photos.where({ logbookId }).toArray()
if (localPhotos.length === 0) {
setError(t('logs.no_photos_to_download'))
return
}
// Build a map of entry ID to entry info for filename lookup
const entryMap = new Map<string, DecryptedEntryItem>()
entries.forEach((e) => entryMap.set(e.id, e))
const files: Record<string, Uint8Array> = {}
const usedNames = new Set<string>()
for (const photo of localPhotos) {
// Decrypt photo payload (contains base64 image data and caption)
const decrypted = await decryptJson(photo.encryptedData, photo.iv, photo.tag, masterKey)
if (!decrypted || !decrypted.image) continue
const { data, ext } = dataUrlToUint8Array(decrypted.image)
// Construct unique, friendly filename
let fileBase = `photo_${photo.payloadId}`
const entry = entryMap.get(photo.entryId)
if (entry) {
const dateStr = entry.date || 'unknown-date'
const travelDay = entry.dayOfTravel ? `day-${entry.dayOfTravel}` : ''
const sanitizedCaption = decrypted.caption ? sanitizeFilename(decrypted.caption) : ''
const parts = [dateStr]
if (travelDay) parts.push(travelDay)
if (sanitizedCaption) parts.push(sanitizedCaption)
fileBase = parts.join('_')
} else if (decrypted.caption) {
fileBase = `photo_${sanitizeFilename(decrypted.caption)}`
}
// De-duplicate name
let candidate = `${fileBase}.${ext}`
let counter = 1
while (usedNames.has(candidate.toLowerCase())) {
candidate = `${fileBase}_${counter}.${ext}`
counter++
}
usedNames.add(candidate.toLowerCase())
files[candidate] = data
}
if (Object.keys(files).length === 0) {
setError(t('logs.no_photos_to_download'))
return
}
const zipBytes = buildZipArchive(files)
const blob = new Blob([zipBytes as any], { type: 'application/zip' })
const url = URL.createObjectURL(blob)
const yachtName = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook'
const safeTitle = yachtName.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook'
const datePart = new Date().toISOString().slice(0, 10)
const filename = `${safeTitle}-photos-${datePart}.zip`
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
URL.revokeObjectURL(url)
} catch (err: any) {
console.error('Failed to download photos ZIP:', err)
setError(getErrorMessage(err, t('errors.export_failed')))
} finally {
setExporting(false)
}
}
const handleCreate = async () => { const handleCreate = async () => {
if (readOnly) return if (readOnly) return
setError(null) setError(null)
@@ -488,6 +609,21 @@ export default function LogEntriesList({
<span className="hide-mobile">{t('logs.share_csv')}</span> <span className="hide-mobile">{t('logs.share_csv')}</span>
</button> </button>
{hasUnlockedLocalCrypto() && (
<button
className="btn secondary"
onClick={handleDownloadPhotosZip}
disabled={loading || exporting || entries.length === 0}
style={{ width: 'auto', padding: '8px 16px' }}
title={t('logs.export_photos_zip')}
>
<Download size={16} />
<span className="hide-mobile">
{exporting ? t('logs.exporting_photos_zip') : t('logs.export_photos_zip')}
</span>
</button>
)}
{!readOnly && ( {!readOnly && (
<button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.new_entry')}> <button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.new_entry')}>
<Plus size={16} /> <Plus size={16} />
@@ -541,17 +677,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 />
+213 -115
View File
@@ -9,7 +9,7 @@ import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.j
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js' import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
import { useLiveQuery } from 'dexie-react-hooks' import { useLiveQuery } from 'dexie-react-hooks'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import { Camera, Image, Trash2, X } from 'lucide-react' import { Camera, Image, Trash2, X, ChevronDown, ChevronUp, ChevronLeft, ChevronRight } from 'lucide-react'
import { probeCameraAvailability } from '../utils/cameraAvailability.js' import { probeCameraAvailability } from '../utils/cameraAvailability.js'
interface PhotoCaptureProps { interface PhotoCaptureProps {
@@ -29,6 +29,7 @@ interface DecryptedPhoto {
export default function PhotoCapture({ entryId, logbookId, readOnly = false, preloadedPhotos }: PhotoCaptureProps) { export default function PhotoCapture({ entryId, logbookId, readOnly = false, preloadedPhotos }: PhotoCaptureProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { showConfirm } = useDialog() const { showConfirm } = useDialog()
const [collapsed, setCollapsed] = useState(true)
const [caption, setCaption] = useState('') const [caption, setCaption] = useState('')
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -38,6 +39,46 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const cameraInputRef = useRef<HTMLInputElement>(null) const cameraInputRef = useRef<HTMLInputElement>(null)
const touchStartX = useRef<number>(0)
const touchEndX = useRef<number>(0)
const goToNext = () => {
if (!maximizedPhoto || decryptedPhotos.length <= 1) return
const currentIndex = decryptedPhotos.findIndex(p => p.payloadId === maximizedPhoto.payloadId)
if (currentIndex === -1) return
const nextIndex = (currentIndex + 1) % decryptedPhotos.length
setMaximizedPhoto(decryptedPhotos[nextIndex])
}
const goToPrev = () => {
if (!maximizedPhoto || decryptedPhotos.length <= 1) return
const currentIndex = decryptedPhotos.findIndex(p => p.payloadId === maximizedPhoto.payloadId)
if (currentIndex === -1) return
const prevIndex = (currentIndex - 1 + decryptedPhotos.length) % decryptedPhotos.length
setMaximizedPhoto(decryptedPhotos[prevIndex])
}
const handleTouchStart = (e: React.TouchEvent) => {
touchStartX.current = e.targetTouches[0].clientX
touchEndX.current = e.targetTouches[0].clientX
}
const handleTouchMove = (e: React.TouchEvent) => {
touchEndX.current = e.targetTouches[0].clientX
}
const handleTouchEnd = () => {
if (!touchStartX.current || !touchEndX.current) return
const diffX = touchStartX.current - touchEndX.current
const threshold = 50
if (diffX > threshold) {
goToNext()
} else if (diffX < -threshold) {
goToPrev()
}
touchStartX.current = 0
touchEndX.current = 0
}
useEffect(() => { useEffect(() => {
if (!maximizedPhoto) return if (!maximizedPhoto) return
@@ -45,6 +86,10 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
setMaximizedPhoto(null) setMaximizedPhoto(null)
} else if (e.key === 'ArrowLeft' || e.key === 'Left') {
goToPrev()
} else if (e.key === 'ArrowRight' || e.key === 'Right') {
goToNext()
} }
} }
@@ -52,7 +97,7 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
return () => { return () => {
window.removeEventListener('keydown', handleKeyDown) window.removeEventListener('keydown', handleKeyDown)
} }
}, [maximizedPhoto]) }, [maximizedPhoto, decryptedPhotos])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -165,133 +210,156 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
return ( return (
<div className="form-card mt-6"> <div className="form-card mt-6">
<div className="form-header mb-4"> <div
<Camera size={20} className="form-icon" /> className="form-header accordion-header"
<h3>{t('logs.photos_title')}</h3> onClick={() => setCollapsed(!collapsed)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setCollapsed(!collapsed)
}
}}
role="button"
aria-expanded={!collapsed}
tabIndex={0}
>
<div className="accordion-header-title">
<Camera size={20} className="form-icon" />
<h3>{t('logs.photos_title')}</h3>
</div>
{collapsed ? (
<ChevronDown size={20} className="accordion-chevron" />
) : (
<ChevronUp size={20} className="accordion-chevron" />
)}
</div> </div>
{error && <div className="auth-error mb-4">{error}</div>} {!collapsed && (
<div style={{ marginTop: '16px' }}>
{error && <div className="auth-error mb-4">{error}</div>}
{/* Upload area */} {/* Upload area */}
{/* Upload Form */} {/* Upload Form */}
{!readOnly && ( {!readOnly && (
<div className="member-editor-card glass mb-6" style={{ padding: '16px' }}> <div className="member-editor-card glass mb-6" style={{ padding: '16px' }}>
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}> <div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}>
<label>{t('logs.photo_caption_label')}</label> <label>{t('logs.photo_caption_label')}</label>
<input <input
type="text" type="text"
placeholder={t('logs.photo_caption_placeholder')} placeholder={t('logs.photo_caption_placeholder')}
className="input-text" className="input-text"
value={caption} value={caption}
onChange={(e) => setCaption(e.target.value)} onChange={(e) => setCaption(e.target.value)}
disabled={uploading} disabled={uploading}
/> />
</div> </div>
<input <input
type="file" type="file"
accept="image/*" accept="image/*"
ref={fileInputRef} ref={fileInputRef}
onChange={handleFileChange} onChange={handleFileChange}
style={{ display: 'none' }} style={{ display: 'none' }}
/> />
<input <input
type="file" type="file"
accept="image/*" accept="image/*"
capture="environment" capture="environment"
ref={cameraInputRef} ref={cameraInputRef}
onChange={handleFileChange} onChange={handleFileChange}
style={{ display: 'none' }} style={{ display: 'none' }}
/> />
{hasCamera ? ( {hasCamera ? (
<> <>
<button <button
type="button" type="button"
className="btn primary" className="btn primary"
onClick={triggerCameraSelect} onClick={triggerCameraSelect}
disabled={uploading} disabled={uploading}
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }} style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
> >
{uploading ? ( {uploading ? (
<span className="spin"></span> <span className="spin"></span>
) : ( ) : (
<Camera size={16} /> <Camera size={16} />
)} )}
{uploading ? t('logs.photo_processing') : t('logs.photo_camera_btn')} {uploading ? t('logs.photo_processing') : t('logs.photo_camera_btn')}
</button> </button>
<button <button
type="button" type="button"
className="btn secondary" className="btn secondary"
onClick={triggerGallerySelect} onClick={triggerGallerySelect}
disabled={uploading} disabled={uploading}
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }} style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
> >
{uploading ? ( {uploading ? (
<span className="spin"></span> <span className="spin"></span>
) : ( ) : (
<Image size={16} /> <Image size={16} />
)} )}
{uploading ? t('logs.photo_processing') : t('logs.photo_gallery_btn')} {uploading ? t('logs.photo_processing') : t('logs.photo_gallery_btn')}
</button> </button>
</> </>
) : (
<button
type="button"
className="btn primary"
onClick={triggerGallerySelect}
disabled={uploading}
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
>
{uploading ? (
<span className="spin"></span>
) : ( ) : (
<Camera size={16} />
)}
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
</button>
)}
</div>
</div>
)}
{/* Photo Grid */}
{decryptedPhotos.length === 0 ? (
<div className="dashboard-status-msg">{t('logs.no_photos')}</div>
) : (
<div className="photo-attachments-grid">
{decryptedPhotos.map((photo) => (
<div
key={photo.payloadId}
className="photo-card glass"
onClick={() => setMaximizedPhoto(photo)}
style={{ cursor: 'pointer' }}
>
<div className="photo-container">
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
{!readOnly && (
<button <button
type="button" type="button"
className="photo-btn-delete" className="btn primary"
onClick={(e) => { onClick={triggerGallerySelect}
e.stopPropagation() disabled={uploading}
handleDelete(photo.payloadId) style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
}}
title="Remove photo"
> >
<Trash2 size={16} /> {uploading ? (
<span className="spin"></span>
) : (
<Camera size={16} />
)}
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
</button> </button>
)} )}
</div> </div>
{photo.caption && (
<div className="photo-caption-bar">
<span>{photo.caption}</span>
</div>
)}
</div> </div>
))} )}
{/* Photo Grid */}
{decryptedPhotos.length === 0 ? (
<div className="dashboard-status-msg">{t('logs.no_photos')}</div>
) : (
<div className="photo-attachments-grid">
{decryptedPhotos.map((photo) => (
<div
key={photo.payloadId}
className="photo-card glass"
onClick={() => setMaximizedPhoto(photo)}
style={{ cursor: 'pointer' }}
>
<div className="photo-container">
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
{!readOnly && (
<button
type="button"
className="photo-btn-delete"
onClick={(e) => {
e.stopPropagation()
handleDelete(photo.payloadId)
}}
title="Remove photo"
>
<Trash2 size={16} />
</button>
)}
</div>
{photo.caption && (
<div className="photo-caption-bar">
<span>{photo.caption}</span>
</div>
)}
</div>
))}
</div>
)}
</div> </div>
)} )}
@@ -299,7 +367,37 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
<div <div
className="photo-maximized-overlay" className="photo-maximized-overlay"
onClick={() => setMaximizedPhoto(null)} onClick={() => setMaximizedPhoto(null)}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
> >
{decryptedPhotos.length > 1 && (
<>
<button
type="button"
className="photo-maximized-nav photo-maximized-prev"
onClick={(e) => {
e.stopPropagation()
goToPrev()
}}
aria-label={t('common.previous') || 'Previous'}
>
<ChevronLeft size={32} />
</button>
<button
type="button"
className="photo-maximized-nav photo-maximized-next"
onClick={(e) => {
e.stopPropagation()
goToNext()
}}
aria-label={t('common.next') || 'Next'}
>
<ChevronRight size={32} />
</button>
</>
)}
<div className="photo-maximized-container" onClick={(e) => e.stopPropagation()}> <div className="photo-maximized-container" onClick={(e) => e.stopPropagation()}>
<button <button
type="button" type="button"
+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>
+30 -1
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Settings as SettingsIcon, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react' import { Settings as SettingsIcon, Check, Users, Trash2, Copy, Link as LinkIcon, Share2 } from 'lucide-react'
import { ensureLogbookKey } from '../services/logbookKeys.js' import { ensureLogbookKey } from '../services/logbookKeys.js'
import LogbookBackupPanel from './LogbookBackupPanel.tsx' import LogbookBackupPanel from './LogbookBackupPanel.tsx'
import LinkQrCode from './LinkQrCode.tsx' import LinkQrCode from './LinkQrCode.tsx'
@@ -131,6 +131,24 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
} }
} }
const isShareSupported = typeof navigator !== 'undefined' && !!navigator.share
const handleShareLink = async () => {
if (shareLink) {
try {
await navigator.share({
title: t('seo.title') || 'Kapteins Daagbok',
text: t('settings.share_desc'),
url: shareLink
})
} catch (err: unknown) {
if (err instanceof Error && err.name !== 'AbortError') {
console.error('Sharing link failed:', err)
}
}
}
}
const loadCollaborators = async () => { const loadCollaborators = async () => {
setLoadingCollabs(true) setLoadingCollabs(true)
setCollabError(null) setCollabError(null)
@@ -337,6 +355,17 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
> >
{shareCopied ? <Check size={16} /> : <Copy size={16} />} {shareCopied ? <Check size={16} /> : <Copy size={16} />}
</button> </button>
{isShareSupported && (
<button
type="button"
className="btn secondary"
onClick={() => void handleShareLink()}
style={{ width: 'auto', padding: '10px' }}
title={t('settings.share_btn')}
>
<Share2 size={16} />
</button>
)}
</div> </div>
<LinkQrCode value={shareLink} /> <LinkQrCode value={shareLink} />
</div> </div>
+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
+13 -2
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",
@@ -34,7 +36,9 @@
"unsaved_changes_stay": "Bleiben", "unsaved_changes_stay": "Bleiben",
"unsaved_changes_save_leave": "Speichern & verlassen", "unsaved_changes_save_leave": "Speichern & verlassen",
"unsaved_changes_discard": "Verwerfen", "unsaved_changes_discard": "Verwerfen",
"unsaved_changes_leave": "Verlassen" "unsaved_changes_leave": "Verlassen",
"previous": "Zurück",
"next": "Weiter"
}, },
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -443,6 +447,9 @@
"ai_summary_error_forbidden": "Nur der Skipper darf KI-Zusammenfassungen generieren.", "ai_summary_error_forbidden": "Nur der Skipper darf KI-Zusammenfassungen generieren.",
"ai_summary_offline": "Die KI-Zusammenfassung erfordert eine Internetverbindung. Du bist derzeit offline.", "ai_summary_offline": "Die KI-Zusammenfassung erfordert eine Internetverbindung. Du bist derzeit offline.",
"photos_title": "Foto-Anhänge", "photos_title": "Foto-Anhänge",
"export_photos_zip": "Fotos herunterladen (ZIP)",
"exporting_photos_zip": "ZIP wird erstellt...",
"no_photos_to_download": "Keine Fotos in diesem Logbuch vorhanden.",
"photo_caption_label": "Foto-Beschreibung / Label (Optional)", "photo_caption_label": "Foto-Beschreibung / Label (Optional)",
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt", "photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
"photo_btn": "Foto aufnehmen / Hochladen", "photo_btn": "Foto aufnehmen / Hochladen",
@@ -540,6 +547,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",
@@ -815,6 +825,7 @@
"share_enable": "Öffentlichen Link aktivieren", "share_enable": "Öffentlichen Link aktivieren",
"share_copied": "Link kopiert!", "share_copied": "Link kopiert!",
"share_copy_btn": "Link kopieren", "share_copy_btn": "Link kopieren",
"share_btn": "Link teilen",
"link_qr_hint": "QR-Code zum Scannen mit dem Smartphone", "link_qr_hint": "QR-Code zum Scannen mit dem Smartphone",
"link_qr_alt": "QR-Code für den Link", "link_qr_alt": "QR-Code für den Link",
"danger_zone_title": "Gefahrenzone", "danger_zone_title": "Gefahrenzone",
+13 -2
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",
@@ -34,7 +36,9 @@
"unsaved_changes_stay": "Stay", "unsaved_changes_stay": "Stay",
"unsaved_changes_save_leave": "Save & leave", "unsaved_changes_save_leave": "Save & leave",
"unsaved_changes_discard": "Discard", "unsaved_changes_discard": "Discard",
"unsaved_changes_leave": "Leave" "unsaved_changes_leave": "Leave",
"previous": "Previous",
"next": "Next"
}, },
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -443,6 +447,9 @@
"ai_summary_error_forbidden": "Only the skipper may generate AI summaries.", "ai_summary_error_forbidden": "Only the skipper may generate AI summaries.",
"ai_summary_offline": "AI summary generation requires an internet connection. You are currently offline.", "ai_summary_offline": "AI summary generation requires an internet connection. You are currently offline.",
"photos_title": "Photo Attachments", "photos_title": "Photo Attachments",
"export_photos_zip": "Download Photos (ZIP)",
"exporting_photos_zip": "Creating ZIP...",
"no_photos_to_download": "No photos found in this logbook.",
"photo_caption_label": "Photo Caption / Label (Optional)", "photo_caption_label": "Photo Caption / Label (Optional)",
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance", "photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
"photo_btn": "Take Photo / Upload", "photo_btn": "Take Photo / Upload",
@@ -540,6 +547,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",
@@ -815,6 +825,7 @@
"share_enable": "Enable Public Link", "share_enable": "Enable Public Link",
"share_copied": "Link copied!", "share_copied": "Link copied!",
"share_copy_btn": "Copy Link", "share_copy_btn": "Copy Link",
"share_btn": "Share Link",
"link_qr_hint": "Scan this QR code with your phone", "link_qr_hint": "Scan this QR code with your phone",
"link_qr_alt": "QR code for the link", "link_qr_alt": "QR code for the link",
"danger_zone_title": "Danger Zone", "danger_zone_title": "Danger Zone",
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
+1
View File
@@ -16,6 +16,7 @@ export interface AdminSummary {
totalCollaborations: number totalCollaborations: number
totalInvitations: number totalInvitations: number
aiSummaryEntries: number aiSummaryEntries: number
dbSize: number
} }
export type AdminTimeBucket = 'day' | 'week' | 'month' export type AdminTimeBucket = 'day' | 'week' | 'month'
+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). */
+30 -17
View File
@@ -70,6 +70,11 @@ DEFAULT_VERSION="0.1.0.0"
MAX_WAIT=90 MAX_WAIT=90
REMOTE_USER="${REMOTE_USER:-root}" REMOTE_USER="${REMOTE_USER:-root}"
# GIT_REMOTE="${GIT_REMOTE:-github}"
# GIT_REMOTE_URL="${GIT_REMOTE_URL:-https://github.com/elpatron68/kapteins-daagbok.git}"
GIT_REMOTE="${GIT_REMOTE:-origin}"
GIT_REMOTE_URL="${GIT_REMOTE_URL:-https://gitea.elpatron.me/elpatron/kapteins-daagbok.git}"
if [[ "$DEST" == "stage" ]]; then if [[ "$DEST" == "stage" ]]; then
REMOTE_HOST="${REMOTE_HOST:-10.0.0.27}" REMOTE_HOST="${REMOTE_HOST:-10.0.0.27}"
@@ -186,34 +191,34 @@ ensure_local_sync_with_origin() {
exit 1 exit 1
fi fi
echo "Syncing with origin..." echo "Syncing with ${GIT_REMOTE}..."
git fetch --tags origin git fetch --tags "${GIT_REMOTE}"
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo "Error: git fetch origin failed." >&2 echo "Error: git fetch ${GIT_REMOTE} failed." >&2
exit 1 exit 1
fi fi
if ! git rev-parse --verify "origin/${branch}" >/dev/null 2>&1; then if ! git rev-parse --verify "${GIT_REMOTE}/${branch}" >/dev/null 2>&1; then
echo "Error: origin/${branch} does not exist." >&2 echo "Error: ${GIT_REMOTE}/${branch} does not exist." >&2
exit 1 exit 1
fi fi
local_sha="$(git rev-parse HEAD)" local_sha="$(git rev-parse HEAD)"
origin_sha="$(git rev-parse "origin/${branch}")" origin_sha="$(git rev-parse "${GIT_REMOTE}/${branch}")"
if [ "$local_sha" = "$origin_sha" ]; then if [ "$local_sha" = "$origin_sha" ]; then
echo "Local branch '$branch' matches origin/${branch} ($(git rev-parse --short HEAD))." echo "Local branch '$branch' matches ${GIT_REMOTE}/${branch} ($(git rev-parse --short HEAD))."
return 0 return 0
fi fi
echo "Error: Local '$branch' is not in sync with origin/${branch}." >&2 echo "Error: Local '$branch' is not in sync with ${GIT_REMOTE}/${branch}." >&2
echo " local: $(git rev-parse --short HEAD) $(git log -1 --format='%s' HEAD)" >&2 echo " local: $(git rev-parse --short HEAD) $(git log -1 --format='%s' HEAD)" >&2
echo " origin: $(git rev-parse --short "origin/${branch}") $(git log -1 --format='%s' "origin/${branch}")" >&2 echo " ${GIT_REMOTE}: $(git rev-parse --short "${GIT_REMOTE}/${branch}") $(git log -1 --format='%s' "${GIT_REMOTE}/${branch}")" >&2
if git merge-base --is-ancestor "$local_sha" "origin/${branch}" 2>/dev/null; then if git merge-base --is-ancestor "$local_sha" "${GIT_REMOTE}/${branch}" 2>/dev/null; then
echo "Hint: run 'git pull' to fast-forward." >&2 echo "Hint: run 'git pull' to fast-forward." >&2
elif git merge-base --is-ancestor "origin/${branch}" "$local_sha" 2>/dev/null; then elif git merge-base --is-ancestor "${GIT_REMOTE}/${branch}" "$local_sha" 2>/dev/null; then
echo "Hint: run 'git push origin ${branch}' before deploying." >&2 echo "Hint: run 'git push ${GIT_REMOTE} ${branch}' before deploying." >&2
else else
echo "Hint: branches have diverged — reconcile manually before deploying." >&2 echo "Hint: branches have diverged — reconcile manually before deploying." >&2
fi fi
@@ -246,12 +251,12 @@ prepare_release() {
echo " Next prep: v${next_version}" echo " Next prep: v${next_version}"
echo "" echo ""
read -r -p "Push commit and tag to origin? [Y/n] " push_answer read -r -p "Push commit and tag to ${GIT_REMOTE}? [Y/n] " push_answer
if [[ ! "$push_answer" =~ ^[nN]$ ]]; then if [[ ! "$push_answer" =~ ^[nN]$ ]]; then
current_branch="$(git branch --show-current)" current_branch="$(git branch --show-current)"
git push origin "$current_branch" git push "${GIT_REMOTE}" "$current_branch"
git push origin "$tag_name" git push "${GIT_REMOTE}" "$tag_name"
echo "Pushed ${current_branch} and ${tag_name} to origin." echo "Pushed ${current_branch} and ${tag_name} to ${GIT_REMOTE}."
else else
echo "Skipped push. Remote host must receive this commit/tag manually." echo "Skipped push. Remote host must receive this commit/tag manually."
fi fi
@@ -281,7 +286,7 @@ echo "Deploying v${APP_VERSION} to ${REMOTE_TARGET}:${REMOTE_DIR}"
echo "==================================================" echo "=================================================="
ssh -o ConnectTimeout=10 "$REMOTE_TARGET" 'bash -s' -- \ ssh -o ConnectTimeout=10 "$REMOTE_TARGET" 'bash -s' -- \
"$REMOTE_DIR" "$COMPOSE_FILE" "$BACKEND_CONTAINER" "$MAX_WAIT" "$APP_URL" "$APP_VERSION" "$DEST" "$DEPLOY_BRANCH" <<'REMOTE_SCRIPT' "$REMOTE_DIR" "$COMPOSE_FILE" "$BACKEND_CONTAINER" "$MAX_WAIT" "$APP_URL" "$APP_VERSION" "$DEST" "$DEPLOY_BRANCH" "$GIT_REMOTE_URL" <<'REMOTE_SCRIPT'
set -uo pipefail set -uo pipefail
REMOTE_DIR="$1" REMOTE_DIR="$1"
@@ -292,9 +297,17 @@ APP_URL="$5"
APP_VERSION="$6" APP_VERSION="$6"
DEST="$7" DEST="$7"
DEPLOY_BRANCH="${8:-}" DEPLOY_BRANCH="${8:-}"
GIT_REMOTE_URL="${9:-https://github.com/elpatron68/kapteins-daagbok.git}"
cd "$REMOTE_DIR" || { echo "Error: Remote directory '$REMOTE_DIR' not found."; exit 1; } cd "$REMOTE_DIR" || { echo "Error: Remote directory '$REMOTE_DIR' not found."; exit 1; }
echo "Configuring git remote 'origin' URL to ${GIT_REMOTE_URL} on remote host..."
if git remote | grep -q "^origin$"; then
git remote set-url origin "$GIT_REMOTE_URL"
else
git remote add origin "$GIT_REMOTE_URL"
fi
if ! git diff-index --quiet HEAD -- || [ -n "$(git status --porcelain)" ]; then if ! git diff-index --quiet HEAD -- || [ -n "$(git status --porcelain)" ]; then
echo "Warning: Local changes on deployment host will be discarded." echo "Warning: Local changes on deployment host will be discarded."
fi fi
+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')
+76 -3
View File
@@ -23,6 +23,11 @@ router.get('/summary', requireUser, requireAdmin, async (_req, res) => {
prisma.aiSummaryUsage.count() prisma.aiSummaryUsage.count()
]) ])
const rawDbSize = await prisma.$queryRaw<[{ size: string }]>`
SELECT pg_database_size(current_database())::text as size
`
const dbSize = Number(rawDbSize[0]?.size || '0')
res.json({ res.json({
totalUsers, totalUsers,
totalLogbooks, totalLogbooks,
@@ -31,7 +36,8 @@ router.get('/summary', requireUser, requireAdmin, async (_req, res) => {
totalGpsTracks, totalGpsTracks,
totalCollaborations, totalCollaborations,
totalInvitations, totalInvitations,
aiSummaryEntries aiSummaryEntries,
dbSize
}) })
} catch (error: unknown) { } catch (error: unknown) {
console.error('admin/summary error', error) console.error('admin/summary error', error)
@@ -91,7 +97,7 @@ async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<
const since = new Date() const since = new Date()
since.setUTCDate(since.getUTCDate() - windowDays) since.setUTCDate(since.getUTCDate() - windowDays)
const [users, logbooks, photos] = await Promise.all([ const [users, logbooks, photos, dbSizeRaw, photosSize, voiceSize, tracksSize, entriesSize] = await Promise.all([
prisma.user.findMany({ prisma.user.findMany({
where: { createdAt: { gte: since } }, where: { createdAt: { gte: since } },
select: { createdAt: true } select: { createdAt: true }
@@ -103,9 +109,72 @@ async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<
prisma.photoPayload.findMany({ prisma.photoPayload.findMany({
where: { updatedAt: { gte: since } }, where: { updatedAt: { gte: since } },
select: { updatedAt: true } select: { updatedAt: true }
}),
prisma.$queryRaw<[{ size: string }]>`
SELECT pg_database_size(current_database())::text as size
`,
prisma.photoPayload.findMany({
select: { updatedAt: true, encryptedData: true }
}),
prisma.voiceMemoPayload.findMany({
select: { updatedAt: true, encryptedData: true }
}),
prisma.gpsTrackPayload.findMany({
select: { updatedAt: true, encryptedData: true }
}),
prisma.entryPayload.findMany({
select: { updatedAt: true, encryptedData: true }
}) })
]) ])
const dbSizeVal = Number(dbSizeRaw[0]?.size || '0')
const payloads: { date: Date; size: number }[] = []
for (const p of photosSize) {
payloads.push({ date: p.updatedAt, size: p.encryptedData.length })
}
for (const v of voiceSize) {
payloads.push({ date: v.updatedAt, size: v.encryptedData.length })
}
for (const g of tracksSize) {
payloads.push({ date: g.updatedAt, size: g.encryptedData.length })
}
for (const e of entriesSize) {
payloads.push({ date: e.updatedAt, size: e.encryptedData.length })
}
const totalPayloadsSize = payloads.reduce((acc, p) => acc + p.size, 0)
const baseDbSize = Math.max(0, dbSizeVal - totalPayloadsSize)
payloads.sort((a, b) => a.date.getTime() - b.date.getTime())
// Generate complete list of date keys for the window
const dateKeys: string[] = []
const current = new Date(since)
const todayStr = bucketDate(new Date(), bucket)
while (true) {
const key = bucketDate(current, bucket)
if (!dateKeys.includes(key)) {
dateKeys.push(key)
}
if (key >= todayStr) break
current.setUTCDate(current.getUTCDate() + 1)
}
const dbSizePoints = dateKeys.map((key) => {
let sizeSum = 0
for (const p of payloads) {
if (bucketDate(p.date, bucket) <= key) {
sizeSum += p.size
} else {
break
}
}
const totalBytes = baseDbSize + sizeSum
const sizeInMb = Math.round((totalBytes / (1024 * 1024)) * 10) / 10
return { date: key, count: sizeInMb }
})
function aggregate(dates: Date[], metric: string): TimeSeries { function aggregate(dates: Date[], metric: string): TimeSeries {
const map = new Map<string, number>() const map = new Map<string, number>()
for (const d of dates) { for (const d of dates) {
@@ -130,7 +199,11 @@ async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<
aggregate( aggregate(
photos.map((p) => p.updatedAt), photos.map((p) => p.updatedAt),
'photos_updated' 'photos_updated'
) ),
{
metric: 'database_size',
points: dbSizePoints
}
] ]
} }