feat: add language selector to login screen, default to English

Without a stored preference, the app always started in German even for
non-German speakers who had never seen a settings screen. English as the
fallback and a visible selector on the login screen mean the UI is
navigable before any account connection.
This commit is contained in:
Andre
2026-05-16 17:43:42 +02:00
parent 6d93b98ee5
commit d89b9f2e82
6 changed files with 97 additions and 6 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ const DEFAULTS: AppSettings = {
badgeGap: BADGE_GAP_DEFAULT, badgeGap: BADGE_GAP_DEFAULT,
hlsCacheEnabled: false, hlsCacheEnabled: false,
fileLoggingEnabled: false, fileLoggingEnabled: false,
language: 'de' language: 'en'
} }
export { SIDEBAR_MIN, SIDEBAR_MAX, SIDEBAR_DEFAULT, BADGE_GAP_MIN, BADGE_GAP_MAX, BADGE_GAP_DEFAULT } export { SIDEBAR_MIN, SIDEBAR_MAX, SIDEBAR_DEFAULT, BADGE_GAP_MIN, BADGE_GAP_MAX, BADGE_GAP_DEFAULT }
+1
View File
@@ -48,6 +48,7 @@ export const de = {
'login.expiresIn': 'Code läuft in {time} ab.', 'login.expiresIn': 'Code läuft in {time} ab.',
'login.error.accessDenied': 'Zugriff abgelehnt.', 'login.error.accessDenied': 'Zugriff abgelehnt.',
'login.error.expired': 'Code abgelaufen — bitte erneut versuchen.', 'login.error.expired': 'Code abgelaufen — bitte erneut versuchen.',
'login.languageSelect': 'Sprachauswahl',
// ─── Following ────────────────────────────────────────────────────────── // ─── Following ──────────────────────────────────────────────────────────
'following.loading': 'Lade Kanäle…', 'following.loading': 'Lade Kanäle…',
+1
View File
@@ -49,6 +49,7 @@ export const en: Messages = {
'login.expiresIn': 'Code expires in {time}.', 'login.expiresIn': 'Code expires in {time}.',
'login.error.accessDenied': 'Access denied.', 'login.error.accessDenied': 'Access denied.',
'login.error.expired': 'Code expired — please try again.', 'login.error.expired': 'Code expired — please try again.',
'login.languageSelect': 'Language',
// ─── Following ────────────────────────────────────────────────────────── // ─── Following ──────────────────────────────────────────────────────────
'following.loading': 'Loading channels…', 'following.loading': 'Loading channels…',
+2 -2
View File
@@ -20,7 +20,7 @@ function interpolate(raw: string, vars?: Record<string, string | number>): strin
export function useT(): TranslateFn { export function useT(): TranslateFn {
const { settings } = useSettings() const { settings } = useSettings()
const table = TABLES[settings.language] ?? de const table = TABLES[settings.language] ?? en
return useCallback((key: MessageKey, vars?: Record<string, string | number>): string => return useCallback((key: MessageKey, vars?: Record<string, string | number>): string =>
interpolate(table[key], vars), [table]) interpolate(table[key], vars), [table])
} }
@@ -36,7 +36,7 @@ export function getActiveLanguage(): Language {
if (parsed.language === 'en' || parsed.language === 'de') return parsed.language if (parsed.language === 'en' || parsed.language === 'de') return parsed.language
} }
} catch { /* ignore */ } } catch { /* ignore */ }
return 'de' return 'en'
} }
export function tStatic(key: MessageKey, vars?: Record<string, string | number>): string { export function tStatic(key: MessageKey, vars?: Record<string, string | number>): string {
+38 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import type { DeviceFlowStartInfo } from '../types/t4sd' import type { DeviceFlowStartInfo } from '../types/t4sd'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import { useSettings } from '../context/SettingsContext'
interface Props { interface Props {
onAuthorized: () => void onAuthorized: () => void
@@ -16,8 +17,11 @@ type Phase =
export default function LoginScreen({ onAuthorized }: Props): JSX.Element { export default function LoginScreen({ onAuthorized }: Props): JSX.Element {
const t = useT() const t = useT()
const { settings, setLanguage } = useSettings()
const [phase, setPhase] = useState<Phase>({ kind: 'idle' }) const [phase, setPhase] = useState<Phase>({ kind: 'idle' })
const startBtnRef = useRef<HTMLButtonElement>(null) const startBtnRef = useRef<HTMLButtonElement>(null)
const langEnRef = useRef<HTMLButtonElement>(null)
const langDeRef = useRef<HTMLButtonElement>(null)
// On mount: check whether the client ID is configured. // On mount: check whether the client ID is configured.
useEffect(() => { useEffect(() => {
@@ -29,10 +33,22 @@ export default function LoginScreen({ onAuthorized }: Props): JSX.Element {
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent): void => { const onKey = (e: KeyboardEvent): void => {
if (e.key !== 'Enter') return const active = document.activeElement
if (e.key === 'ArrowRight' && active === langEnRef.current) {
langDeRef.current?.focus()
} else if (e.key === 'ArrowLeft' && active === langDeRef.current) {
langEnRef.current?.focus()
} else if (e.key === 'ArrowDown' && active === startBtnRef.current) {
langEnRef.current?.focus()
} else if (e.key === 'ArrowUp' && (active === langEnRef.current || active === langDeRef.current)) {
startBtnRef.current?.focus()
} else if (e.key === 'Enter') {
if (active === langEnRef.current) { setLanguage('en'); return }
if (active === langDeRef.current) { setLanguage('de'); return }
if (phase.kind === 'idle' || phase.kind === 'error') void start() if (phase.kind === 'idle' || phase.kind === 'error') void start()
else if (phase.kind === 'awaiting') void cancel() else if (phase.kind === 'awaiting') void cancel()
} }
}
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [phase]) }, [phase])
@@ -134,6 +150,27 @@ export default function LoginScreen({ onAuthorized }: Props): JSX.Element {
> >
{phase.kind === 'starting' ? t('login.starting') : t('login.connect')} {phase.kind === 'starting' ? t('login.starting') : t('login.connect')}
</button> </button>
<div className="login__lang-select">
<span className="login__lang-label">{t('login.languageSelect')}</span>
<div className="login__lang-buttons">
<button
ref={langEnRef}
className={`login__lang-btn${settings.language === 'en' ? ' login__lang-btn--active' : ''}`}
onClick={() => setLanguage('en')}
aria-label="English"
>
🇺🇸
</button>
<button
ref={langDeRef}
className={`login__lang-btn${settings.language === 'de' ? ' login__lang-btn--active' : ''}`}
onClick={() => setLanguage('de')}
aria-label="Deutsch"
>
🇩🇪
</button>
</div>
</div>
</div> </div>
) )
} }
+52
View File
@@ -149,6 +149,58 @@ body,
color: #ff6868; color: #ff6868;
} }
.login__lang-select {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
margin-top: 4px;
}
.login__lang-label {
font-size: 13px;
color: var(--color-fg-dim);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.login__lang-buttons {
display: flex;
gap: 12px;
}
.login__lang-btn {
width: 80px;
height: 56px;
font-size: 36px;
line-height: 1;
border-radius: 10px;
border: 2px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.15s, background 0.15s;
outline: none;
}
.login__lang-btn:hover,
.login__lang-btn:focus {
border-color: rgba(255, 255, 255, 0.4);
background: rgba(255, 255, 255, 0.08);
}
.login__lang-btn--active {
border-color: var(--color-accent);
background: rgba(145, 71, 255, 0.15);
}
.login__lang-btn--active:focus {
border-color: var(--color-accent);
background: rgba(145, 71, 255, 0.25);
}
/* ── App Layout (post-auth) ───────────────────────── */ /* ── App Layout (post-auth) ───────────────────────── */
.app-layout { .app-layout {
display: grid; display: grid;