mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 15:43:08 +01:00
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:
@@ -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 }
|
||||||
|
|||||||
@@ -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…',
|
||||||
|
|||||||
@@ -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…',
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,9 +33,21 @@ 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 (phase.kind === 'idle' || phase.kind === 'error') void start()
|
if (e.key === 'ArrowRight' && active === langEnRef.current) {
|
||||||
else if (phase.kind === 'awaiting') void cancel()
|
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()
|
||||||
|
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)
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user