Compare commits

...

17 Commits

Author SHA1 Message Date
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
27 changed files with 4723 additions and 2102 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.1.26
0.1.1.31
+176
View File
@@ -1939,6 +1939,21 @@ html.scheme-dark .themed-select-option.is-selected {
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-inline-edit,
.logbook-card .card-title-row {
@@ -2165,6 +2180,16 @@ html.scheme-dark .themed-select-option.is-selected {
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 {
position: relative;
display: inline-flex;
@@ -2958,6 +2983,12 @@ html.scheme-dark .themed-select-option.is-selected {
opacity: 1;
}
.logbook-card-right-group .btn-pdf,
.logbook-card-right-group .btn-delete {
position: static;
opacity: 1;
}
.card-meta {
flex-wrap: wrap;
}
@@ -6523,3 +6554,148 @@ body.app-tour-active .feedback-modal-overlay--tour .disclaimer-modal-panel {
cursor: pointer;
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 type { LogbookAccessRole } from './services/logbook.js'
import { useLiveQuery } from 'dexie-react-hooks'
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react'
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, BarChart2 } from 'lucide-react'
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
import ProfileHeaderButton from './components/ProfileHeaderButton.tsx'
import AdminHeaderButton from './components/AdminHeaderButton.tsx'
import { checkAdminAccess } from './services/adminApi.js'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage } from './utils/i18nLanguages.js'
import LanguageDropdown from './components/LanguageDropdown.tsx'
import {
resolveTourLogbookContext,
seedDemoLogbookIfNeeded
@@ -66,7 +66,7 @@ import { requestPersistentStorage } from './utils/storagePersist.js'
const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
function App() {
const { t, i18n } = useTranslation()
const { t } = useTranslation()
const { confirmLeave } = useUnsavedChangesContext()
const { registerNavigation, registerDemoTourContext, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
const [isAuthenticated, setIsAuthenticated] = useState(false)
@@ -555,10 +555,6 @@ function App() {
localStorage.removeItem('active_logbook_title')
}
const toggleLanguage = () => {
cycleAppLanguage(i18n)
}
const handleExitDemo = () => {
window.history.replaceState({}, document.title, '/')
syncRouteFromLocation()
@@ -715,10 +711,7 @@ function App() {
{online ? <Wifi size={18} /> : <WifiOff size={18} />}
<span>{online ? 'Online' : t('sync.status_offline')}</span>
</div>
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
<Languages size={18} />
</button>
<LanguageDropdown variant="icon" align="right" />
{isAdminUser && <AdminHeaderButton onClick={openAdmin} />}
+4 -10
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import LanguageDropdown from './LanguageDropdown.tsx'
import {
registerUser,
loginUser,
@@ -15,7 +15,7 @@ import {
logoutUser,
resolveRestoreUsername
} 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 DisclaimerModal from './DisclaimerModal.tsx'
import BetaBadge from './BetaBadge.tsx'
@@ -37,7 +37,7 @@ export default function AuthOnboarding({
onOpenDemo,
restoreSession = false
}: AuthOnboardingProps) {
const { t, i18n } = useTranslation()
const { t } = useTranslation()
const [username, setUsername] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -267,9 +267,6 @@ export default function AuthOnboarding({
setKnownUsers(getKnownUsernames())
}
const toggleLanguage = () => {
cycleAppLanguage(i18n)
}
const copyToClipboard = () => {
if (recoveryPhrase) {
@@ -780,10 +777,7 @@ export default function AuthOnboarding({
</div>
<div className="auth-footer">
<button type="button" className="btn-icon-text" onClick={toggleLanguage}>
<Languages size={18} />
{t(`languages.${getNextLanguage(i18n.language)}`)}
</button>
<LanguageDropdown variant="text" align="left" />
<button
type="button"
className="btn-icon-text link-sec"
+4 -10
View File
@@ -1,12 +1,12 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import LanguageDropdown from './LanguageDropdown.tsx'
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
import type { LogbookCrewSelectionData } from '../types/person.js'
import { personToSnapshot } from '../utils/personSnapshots.js'
import LogEntriesList from './LogEntriesList.tsx'
import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react'
import { Ship, Users, FileText, Lock, ChevronLeft, UserPlus } from 'lucide-react'
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
import type { VesselData } from '../types/vessel.js'
import type { LogbookVesselSelectionData } from '../types/vessel.js'
@@ -52,9 +52,6 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
}
}, [registerNavigation, registerDemoTourContext, startTour, fixture.firstEntryId])
const toggleLanguage = () => {
cycleAppLanguage(i18n)
}
const {
title,
@@ -111,10 +108,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
<UserPlus size={14} style={{ marginRight: '4px' }} />
{t('demo.cta_register')}
</button>
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}>
<Globe size={14} style={{ marginRight: '4px' }} />
{t(`languages.${getNextLanguage(i18n.language)}`)}
</button>
<LanguageDropdown variant="secondary-button" align="right" />
</div>
</header>
@@ -172,7 +166,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
payloadId: v.payloadId,
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 { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight, KeyRound } from 'lucide-react'
import LanguageDropdown from './LanguageDropdown.tsx'
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, ArrowRight, KeyRound } from 'lucide-react'
import {
getActiveMasterKey,
registerUser,
@@ -50,7 +50,7 @@ const hexToBuffer = (hex: string): ArrayBuffer => {
}
export default function InvitationAcceptance({ onAccepted, onCancel }: InvitationAcceptanceProps) {
const { t, i18n } = useTranslation()
const { t } = useTranslation()
const [loading, setLoading] = useState(true)
const [accepting, setAccepting] = useState(false)
@@ -308,9 +308,6 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
setIsLoggedIn(true)
}
const toggleLanguage = () => {
cycleAppLanguage(i18n)
}
if (recoveryPhrase) {
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' }}>
<button className="btn-icon-text" onClick={toggleLanguage}>
<Languages size={18} />
{t(`languages.${getNextLanguage(i18n.language)}`)}
</button>
<LanguageDropdown variant="text" align="left" />
</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>
<ChevronRight size={18} className="logbook-card-chevron" aria-hidden />
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
<Download size={18} />
</button>
{!readOnly && (
<button className="btn-delete" onClick={(e) => handleDelete(item.id, e)} title={t('logs.delete_entry')}>
<Trash2 size={18} />
<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}>
<Download size={18} />
</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>
+16 -14
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage } from '../utils/i18nLanguages.js'
import LanguageDropdown from './LanguageDropdown.tsx'
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.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 { logoutUser } from '../services/auth.js'
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 FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
@@ -35,10 +35,14 @@ function sortLogbooks(
): DecryptedLogbook[] {
const sorted = [...items]
sorted.sort((a, b) => {
const cmp =
sortBy === 'name'
? a.title.localeCompare(b.title, locale, { sensitivity: 'base' })
: new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
let cmp = 0
if (sortBy === 'name') {
cmp = a.title.localeCompare(b.title, locale, { sensitivity: 'base' })
} 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 sorted
@@ -198,9 +202,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
onLogout()
}
const toggleLanguage = () => {
cycleAppLanguage(i18n)
}
const ownedLogbooks = 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 && (
<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">
{new Date(lb.updatedAt).toLocaleDateString(i18n.language, {
{new Date(lb.lastTravelDate || lb.updatedAt).toLocaleDateString(i18n.language, {
year: 'numeric',
month: 'short',
day: 'numeric'
@@ -392,10 +397,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
{onOpenAdmin && <AdminHeaderButton onClick={onOpenAdmin} />}
{/* Lang toggle */}
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
<Languages size={18} />
</button>
<LanguageDropdown variant="icon" align="right" />
<DisclaimerHeaderButton />
+138 -114
View File
@@ -9,7 +9,7 @@ import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.j
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
import { useLiveQuery } from 'dexie-react-hooks'
import { useDialog } from './ModalDialog.tsx'
import { Camera, Image, Trash2, X } from 'lucide-react'
import { Camera, Image, Trash2, X, ChevronDown, ChevronUp } from 'lucide-react'
import { probeCameraAvailability } from '../utils/cameraAvailability.js'
interface PhotoCaptureProps {
@@ -29,6 +29,7 @@ interface DecryptedPhoto {
export default function PhotoCapture({ entryId, logbookId, readOnly = false, preloadedPhotos }: PhotoCaptureProps) {
const { t } = useTranslation()
const { showConfirm } = useDialog()
const [collapsed, setCollapsed] = useState(true)
const [caption, setCaption] = useState('')
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -165,133 +166,156 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
return (
<div className="form-card mt-6">
<div className="form-header mb-4">
<Camera size={20} className="form-icon" />
<h3>{t('logs.photos_title')}</h3>
<div
className="form-header accordion-header"
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>
{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 Form */}
{!readOnly && (
<div className="member-editor-card glass mb-6" style={{ padding: '16px' }}>
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}>
<label>{t('logs.photo_caption_label')}</label>
<input
type="text"
placeholder={t('logs.photo_caption_placeholder')}
className="input-text"
value={caption}
onChange={(e) => setCaption(e.target.value)}
disabled={uploading}
/>
</div>
{/* Upload area */}
{/* Upload Form */}
{!readOnly && (
<div className="member-editor-card glass mb-6" style={{ padding: '16px' }}>
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}>
<label>{t('logs.photo_caption_label')}</label>
<input
type="text"
placeholder={t('logs.photo_caption_placeholder')}
className="input-text"
value={caption}
onChange={(e) => setCaption(e.target.value)}
disabled={uploading}
/>
</div>
<input
type="file"
accept="image/*"
ref={fileInputRef}
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<input
type="file"
accept="image/*"
ref={fileInputRef}
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<input
type="file"
accept="image/*"
capture="environment"
ref={cameraInputRef}
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<input
type="file"
accept="image/*"
capture="environment"
ref={cameraInputRef}
onChange={handleFileChange}
style={{ display: 'none' }}
/>
{hasCamera ? (
<>
<button
type="button"
className="btn primary"
onClick={triggerCameraSelect}
disabled={uploading}
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
>
{uploading ? (
<span className="spin"></span>
) : (
<Camera size={16} />
)}
{uploading ? t('logs.photo_processing') : t('logs.photo_camera_btn')}
</button>
<button
type="button"
className="btn secondary"
onClick={triggerGallerySelect}
disabled={uploading}
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
>
{uploading ? (
<span className="spin"></span>
) : (
<Image size={16} />
)}
{uploading ? t('logs.photo_processing') : t('logs.photo_gallery_btn')}
</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>
{hasCamera ? (
<>
<button
type="button"
className="btn primary"
onClick={triggerCameraSelect}
disabled={uploading}
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
>
{uploading ? (
<span className="spin"></span>
) : (
<Camera size={16} />
)}
{uploading ? t('logs.photo_processing') : t('logs.photo_camera_btn')}
</button>
<button
type="button"
className="btn secondary"
onClick={triggerGallerySelect}
disabled={uploading}
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
>
{uploading ? (
<span className="spin"></span>
) : (
<Image size={16} />
)}
{uploading ? t('logs.photo_processing') : t('logs.photo_gallery_btn')}
</button>
</>
) : (
<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
type="button"
className="photo-btn-delete"
onClick={(e) => {
e.stopPropagation()
handleDelete(photo.payloadId)
}}
title="Remove photo"
className="btn primary"
onClick={triggerGallerySelect}
disabled={uploading}
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
>
<Trash2 size={16} />
{uploading ? (
<span className="spin"></span>
) : (
<Camera size={16} />
)}
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
</button>
)}
</div>
{photo.caption && (
<div className="photo-caption-bar">
<span>{photo.caption}</span>
</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>
)}
+4 -9
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
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 { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
@@ -12,7 +13,7 @@ import { emptyLogbookCrewSelection } from '../types/person.js'
import { legacyCrewRecordsToLogbookSelection } from '../utils/personSnapshots.js'
import type { PersonData } from '../types/person.js'
import LogEntriesList from './LogEntriesList.tsx'
import { Ship, Users, FileText, Lock, AlertCircle, Globe } from 'lucide-react'
import { Ship, Users, FileText, Lock, AlertCircle } from 'lucide-react'
interface ReadOnlyViewerProps {
token: string
@@ -215,9 +216,6 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
}
}
const toggleLanguage = () => {
cycleAppLanguage(i18n)
}
if (loading) {
return (
@@ -258,10 +256,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
</div>
<div className="header-actions">
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}>
<Globe size={14} style={{ marginRight: '4px' }} />
{t(`languages.${getNextLanguage(i18n.language)}`)}
</button>
<LanguageDropdown variant="secondary-button" align="right" />
</div>
</header>
+5 -1
View File
@@ -6,6 +6,8 @@ import deJson from './locales/de.json'
import daJson from './locales/da.json'
import svJson from './locales/sv.json'
import nbJson from './locales/nb.json'
import frJson from './locales/fr.json'
import esJson from './locales/es.json'
import { initSeo } from '../utils/seo.js'
import { SUPPORTED_LANGUAGES } from '../utils/i18nLanguages.js'
@@ -15,7 +17,9 @@ const resources = {
de: { translation: deJson.translation },
da: { translation: daJson.translation },
sv: { translation: svJson.translation },
nb: { translation: nbJson.translation }
nb: { translation: nbJson.translation },
fr: { translation: frJson.translation },
es: { translation: esJson.translation }
}
i18n
+5 -1
View File
@@ -4,6 +4,8 @@ import enJson from '../i18n/locales/en.json'
import daJson from '../i18n/locales/da.json'
import svJson from '../i18n/locales/sv.json'
import nbJson from '../i18n/locales/nb.json'
import frJson from '../i18n/locales/fr.json'
import esJson from '../i18n/locales/es.json'
function collectKeys(obj: Record<string, unknown>, prefix = ''): string[] {
const keys: string[] = []
@@ -23,7 +25,9 @@ const bundles = {
en: enJson.translation,
da: daJson.translation,
sv: svJson.translation,
nb: nbJson.translation
nb: nbJson.translation,
fr: frJson.translation,
es: esJson.translation
} as const
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",
"da": "Dansk",
"sv": "Svenska",
"nb": "Norsk"
"nb": "Norsk",
"fr": "Français",
"es": "Español"
},
"dialog": {
"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.",
"no_logbooks": "Keine Logbücher gefunden. Erstelle dein erstes Logbuch, um zu beginnen!",
"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_local": "Nur lokaler Cache",
"delete_btn": "Logbuch löschen",
+6 -1
View File
@@ -15,7 +15,9 @@
"en": "English",
"da": "Dansk",
"sv": "Svenska",
"nb": "Norsk"
"nb": "Norsk",
"fr": "French",
"es": "Spanish"
},
"dialog": {
"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.",
"no_logbooks": "No logbooks found. Create your first logbook to begin!",
"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_local": "Local Cache Only",
"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
accessRole: LogbookAccessRole
isDemo?: boolean
lastTravelDate?: string
entryCount?: number
}
// 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
const cachedLogbooks = await db.logbooks.toArray()
// Decrypt titles
// Decrypt titles and query last travel dates
const decrypted: DecryptedLogbook[] = []
for (const lb of cachedLogbooks) {
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({
id: lb.id,
title,
@@ -155,7 +171,9 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
accessRole: lb.isShared === 1
? parseCollaborationRole(lb.collaborationRole, `cached logbook ${lb.id}`)
: '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 {
let current = language
return {
language: current,
const mock = {
language,
changeLanguage: vi.fn(async (lng: string) => {
current = lng
;(this as { language: string }).language = lng
mock.language = lng
})
} as unknown as I18nInstance
return mock
}
describe('i18nLanguages', () => {
@@ -72,11 +71,11 @@ describe('i18nLanguages', () => {
})
it('cycleAppLanguage tracks the next language', () => {
const i18n = createMockI18n('nb')
const i18n = createMockI18n('es')
cycleAppLanguage(i18n)
expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.LANGUAGE_CHANGED, {
from: 'nb',
from: 'es',
to: 'de'
})
})
+11 -1
View File
@@ -2,10 +2,20 @@ import type { i18n as I18nInstance } from 'i18next'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
/** Supported UI languages (ISO 639-1, language-only). */
export const SUPPORTED_LANGUAGES = ['de', 'en', 'da', 'sv', 'nb'] as const
export const SUPPORTED_LANGUAGES = ['de', 'en', 'da', 'sv', 'nb', 'fr', 'es'] as const
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 {
const base = (language ?? 'en').split('-')[0].toLowerCase()
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',
da: 'da_DK',
sv: 'sv_SE',
nb: 'nb_NO'
nb: 'nb_NO',
fr: 'fr_FR',
es: 'es_ES'
}
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 = {
da: 'DA',
sv: 'SV',
nb: 'NB'
nb: 'NB',
fr: 'FR',
es: 'ES'
}
/** 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
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
REMOTE_HOST="${REMOTE_HOST:-10.0.0.27}"
@@ -186,34 +191,34 @@ ensure_local_sync_with_origin() {
exit 1
fi
echo "Syncing with origin..."
git fetch --tags origin
echo "Syncing with ${GIT_REMOTE}..."
git fetch --tags "${GIT_REMOTE}"
if [ $? -ne 0 ]; then
echo "Error: git fetch origin failed." >&2
echo "Error: git fetch ${GIT_REMOTE} failed." >&2
exit 1
fi
if ! git rev-parse --verify "origin/${branch}" >/dev/null 2>&1; then
echo "Error: origin/${branch} does not exist." >&2
if ! git rev-parse --verify "${GIT_REMOTE}/${branch}" >/dev/null 2>&1; then
echo "Error: ${GIT_REMOTE}/${branch} does not exist." >&2
exit 1
fi
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
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
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 " 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
elif git merge-base --is-ancestor "origin/${branch}" "$local_sha" 2>/dev/null; then
echo "Hint: run 'git push origin ${branch}' before deploying." >&2
elif git merge-base --is-ancestor "${GIT_REMOTE}/${branch}" "$local_sha" 2>/dev/null; then
echo "Hint: run 'git push ${GIT_REMOTE} ${branch}' before deploying." >&2
else
echo "Hint: branches have diverged — reconcile manually before deploying." >&2
fi
@@ -246,12 +251,12 @@ prepare_release() {
echo " Next prep: v${next_version}"
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
current_branch="$(git branch --show-current)"
git push origin "$current_branch"
git push origin "$tag_name"
echo "Pushed ${current_branch} and ${tag_name} to origin."
git push "${GIT_REMOTE}" "$current_branch"
git push "${GIT_REMOTE}" "$tag_name"
echo "Pushed ${current_branch} and ${tag_name} to ${GIT_REMOTE}."
else
echo "Skipped push. Remote host must receive this commit/tag manually."
fi
@@ -281,7 +286,7 @@ echo "Deploying v${APP_VERSION} to ${REMOTE_TARGET}:${REMOTE_DIR}"
echo "=================================================="
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
REMOTE_DIR="$1"
@@ -292,9 +297,17 @@ APP_URL="$5"
APP_VERSION="$6"
DEST="$7"
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; }
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
echo "Warning: Local changes on deployment host will be discarded."
fi
+1 -1
View File
@@ -11,7 +11,7 @@ import { flattenTranslation } from './lib/deepl-translate.mjs'
const __dirname = dirname(fileURLToPath(import.meta.url))
const localesDir = resolve(__dirname, '../client/src/i18n/locales')
const localeFiles = ['de.json', 'en.json', 'da.json', 'sv.json', 'nb.json']
const localeFiles = ['de.json', 'en.json', 'da.json', 'sv.json', 'nb.json', 'fr.json', 'es.json']
async function loadKeys(filename) {
const raw = await readFile(resolve(localesDir, filename), 'utf8')