(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({
-
+
-
+
@@ -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}
/>
)}
diff --git a/client/src/components/InvitationAcceptance.tsx b/client/src/components/InvitationAcceptance.tsx
index 4677683..8b11f27 100644
--- a/client/src/components/InvitationAcceptance.tsx
+++ b/client/src/components/InvitationAcceptance.tsx
@@ -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
)}
-
+
)
diff --git a/client/src/components/LanguageDropdown.tsx b/client/src/components/LanguageDropdown.tsx
new file mode 100644
index 0000000..0ae7e02
--- /dev/null
+++ b/client/src/components/LanguageDropdown.tsx
@@ -0,0 +1,191 @@
+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 (
+
+ )
+ case 'en':
+ return (
+
+ )
+ case 'da':
+ return (
+
+ )
+ case 'sv':
+ return (
+
+ )
+ case 'nb':
+ return (
+
+ )
+ 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(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 (
+
+
+
+ )
+ }
+
+ if (variant === 'secondary-button') {
+ return (
+ <>
+
+
+ {name}
+
+ >
+ )
+ }
+
+ // Default or "text" variant (used in footer)
+ return (
+ <>
+
+
+ {name}
+
+ >
+ )
+ }
+
+ const triggerClass =
+ variant === 'icon'
+ ? 'btn-icon'
+ : variant === 'secondary-button'
+ ? 'btn secondary compact'
+ : 'btn-icon-text'
+
+ return (
+
+
+
+ {isOpen && (
+
+ {SUPPORTED_LANGUAGES.map((lang) => {
+ const isSelected = lang === activeLang
+ return (
+ - selectLanguage(lang)}
+ >
+
+ {t(`languages.${lang}`)}
+
+ )
+ })}
+
+ )}
+
+ )
+}
diff --git a/client/src/components/ReadOnlyViewer.tsx b/client/src/components/ReadOnlyViewer.tsx
index fedcec4..9dc43b6 100644
--- a/client/src/components/ReadOnlyViewer.tsx
+++ b/client/src/components/ReadOnlyViewer.tsx
@@ -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) {
-
+
diff --git a/client/src/utils/i18nLanguages.ts b/client/src/utils/i18nLanguages.ts
index cf0e985..36e0115 100644
--- a/client/src/utils/i18nLanguages.ts
+++ b/client/src/utils/i18nLanguages.ts
@@ -6,6 +6,14 @@ export const SUPPORTED_LANGUAGES = ['de', 'en', 'da', 'sv', 'nb'] as const
export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number]
+export const LANGUAGE_FLAGS: Record = {
+ de: 'π©πͺ',
+ en: 'π¬π§',
+ da: 'π©π°',
+ sv: 'πΈπͺ',
+ nb: 'π³π΄'
+}
+
export function normalizeAppLanguage(language?: string): AppLanguage {
const base = (language ?? 'en').split('-')[0].toLowerCase()
if ((SUPPORTED_LANGUAGES as readonly string[]).includes(base)) {