From 4dfe2cea4ea88a726a04c757c8f120450379ee0f Mon Sep 17 00:00:00 2001 From: elpatron Date: Sun, 7 Jun 2026 12:59:40 +0200 Subject: [PATCH] feat(i18n): replace language cycle buttons with flag dropdown selector using inline SVGs --- client/src/App.css | 145 +++++++++++++ client/src/App.tsx | 15 +- client/src/components/AuthOnboarding.tsx | 14 +- client/src/components/DemoViewer.tsx | 14 +- .../src/components/InvitationAcceptance.tsx | 14 +- client/src/components/LanguageDropdown.tsx | 191 ++++++++++++++++++ client/src/components/ReadOnlyViewer.tsx | 13 +- client/src/utils/i18nLanguages.ts | 8 + 8 files changed, 364 insertions(+), 50 deletions(-) create mode 100644 client/src/components/LanguageDropdown.tsx diff --git a/client/src/App.css b/client/src/App.css index f3d1bd8..d88dc43 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -6523,3 +6523,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; +} diff --git a/client/src/App.tsx b/client/src/App.tsx index 6a6d6bd..a83a013 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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 ? : } {online ? 'Online' : t('sync.status_offline')} - - + {isAdminUser && } diff --git a/client/src/components/AuthOnboarding.tsx b/client/src/components/AuthOnboarding.tsx index 3262678..4525ce4 100644 --- a/client/src/components/AuthOnboarding.tsx +++ b/client/src/components/AuthOnboarding.tsx @@ -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(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)) {