Phase 5.6 abgeschlossen

- Seitenmenu um Einstellungen ergänzt
- Sprach-Badge und Flaggen anzeige im Thumbnail des Streams anzeigbar.
-  Sprach und Flaggen Einstellungen hinzugefügt.
This commit is contained in:
Andre
2026-04-08 21:37:20 +02:00
parent 1f40d25771
commit 897f6ab727
17 changed files with 504 additions and 26 deletions
@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react'
import type { FollowedChannelInfo } from '../types/t4sd'
import LanguageBadge from './LanguageBadge'
interface Props {
channel: FollowedChannelInfo
@@ -40,6 +41,7 @@ export default function FocusableCard({ channel, focused, onFocus, onSelect }: P
{channel.viewerCount !== undefined && (
<span className="card__viewers">{formatViewers(channel.viewerCount)}</span>
)}
<LanguageBadge language={channel.language} />
</>
) : (
<div className="card__thumb-offline">
+9
View File
@@ -36,3 +36,12 @@ export function UserIcon(props: IconProps): JSX.Element {
</svg>
)
}
export function SettingsIcon(props: IconProps): JSX.Element {
return (
<svg {...base} {...props}>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
)
}
@@ -0,0 +1,74 @@
import { useSettings } from '../context/SettingsContext'
// Pragmatische Sprache→Flagge-Zuordnung.
// Grundsatz: dominante Twitch-Nutzerbase pro Sprache,
// nicht die politisch "erste" Assoziation.
const LANG_FLAG: Record<string, string> = {
en: '🇺🇸',
de: '🇩🇪',
es: '🇪🇸',
fr: '🇫🇷',
pt: '🇧🇷', // Brasilien dominiert pt auf Twitch
ja: '🇯🇵',
ko: '🇰🇷',
zh: '🇹🇼', // Taiwan dominiert zh auf Twitch
ru: '🇷🇺',
it: '🇮🇹',
pl: '🇵🇱',
tr: '🇹🇷',
nl: '🇳🇱',
sv: '🇸🇪',
fi: '🇫🇮',
da: '🇩🇰',
no: '🇳🇴',
cs: '🇨🇿',
hu: '🇭🇺',
th: '🇹🇭',
ar: '🇸🇦',
id: '🇮🇩',
el: '🇬🇷',
ro: '🇷🇴',
bg: '🇧🇬',
uk: '🇺🇦',
vi: '🇻🇳',
he: '🇮🇱',
sk: '🇸🇰',
ms: '🇲🇾',
tl: '🇵🇭',
hi: '🇮🇳',
ca: '🇪🇸',
hr: '🇭🇷',
sr: '🇷🇸',
sl: '🇸🇮',
lt: '🇱🇹',
lv: '🇱🇻',
et: '🇪🇪'
}
interface Props {
language?: string
/** Optional: andere CSS-Klasse für kleinere Karten */
className?: string
}
export default function LanguageBadge({ language, className = 'card__language' }: Props): JSX.Element | null {
const { settings } = useSettings()
const mode = settings.streamBadgeMode
if (mode === 'off' || !language || language === 'other' || language === '') return null
const upper = language.toUpperCase()
const flag = LANG_FLAG[language.toLowerCase()]
let content: string
if (mode === 'language') {
content = upper
} else if (mode === 'flag') {
content = flag ?? upper // Fallback auf Kürzel wenn keine Flagge bekannt
} else {
// 'both'
content = flag ? `${flag} ${upper}` : upper
}
return <span className={className}>{content}</span>
}
+4 -3
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react'
import { CompassIcon, HeartIcon, UserIcon } from './Icons'
import { CompassIcon, HeartIcon, SettingsIcon, UserIcon } from './Icons'
export type TabKey = 'following' | 'browse' | 'account'
export type TabKey = 'following' | 'browse' | 'account' | 'settings'
interface SidebarItemDef {
key: TabKey
@@ -12,7 +12,8 @@ interface SidebarItemDef {
export const SIDEBAR_ITEMS: SidebarItemDef[] = [
{ key: 'following', label: 'Du folgst', Icon: HeartIcon },
{ key: 'browse', label: 'Durchsuchen', Icon: CompassIcon },
{ key: 'account', label: 'Mein Account', Icon: UserIcon }
{ key: 'account', label: 'Mein Account', Icon: UserIcon },
{ key: 'settings', label: 'Einstellungen', Icon: SettingsIcon }
]
interface Props {
@@ -0,0 +1,65 @@
import { createContext, useCallback, useContext, useState } from 'react'
export type StreamBadgeMode = 'off' | 'language' | 'flag' | 'both'
export interface AppSettings {
streamBadgeMode: StreamBadgeMode
}
const STORAGE_KEY = 't4sd:settings'
const DEFAULTS: AppSettings = { streamBadgeMode: 'language' }
function loadSettings(): AppSettings {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return DEFAULTS
const parsed = JSON.parse(raw) as Partial<AppSettings>
const modes: StreamBadgeMode[] = ['off', 'language', 'flag', 'both']
return {
streamBadgeMode: modes.includes(parsed.streamBadgeMode as StreamBadgeMode)
? (parsed.streamBadgeMode as StreamBadgeMode)
: DEFAULTS.streamBadgeMode
}
} catch {
return DEFAULTS
}
}
function saveSettings(s: AppSettings): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(s))
} catch {
// localStorage nicht verfügbar — ignorieren
}
}
interface SettingsCtx {
settings: AppSettings
setStreamBadgeMode: (mode: StreamBadgeMode) => void
}
const SettingsContext = createContext<SettingsCtx | null>(null)
export function SettingsProvider({ children }: { children: React.ReactNode }): JSX.Element {
const [settings, setSettings] = useState<AppSettings>(loadSettings)
const setStreamBadgeMode = useCallback((mode: StreamBadgeMode) => {
setSettings((prev) => {
const next = { ...prev, streamBadgeMode: mode }
saveSettings(next)
return next
})
}, [])
return (
<SettingsContext.Provider value={{ settings, setStreamBadgeMode }}>
{children}
</SettingsContext.Provider>
)
}
export function useSettings(): SettingsCtx {
const ctx = useContext(SettingsContext)
if (!ctx) throw new Error('useSettings must be used inside SettingsProvider')
return ctx
}
+4 -1
View File
@@ -1,10 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import { SettingsProvider } from './context/SettingsContext'
import './styles/global.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
<SettingsProvider>
<App />
</SettingsProvider>
</React.StrictMode>
)
+4
View File
@@ -5,6 +5,7 @@ import BrowseScreen from './BrowseScreen'
import CategoryScreen from './CategoryScreen'
import ChannelScreen from './ChannelScreen'
import FollowingScreen from './FollowingScreen'
import SettingsScreen from './SettingsScreen'
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
type Region = 'sidebar' | 'main'
@@ -115,6 +116,9 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
onLogout={onLogout}
/>
)}
{tab === 'settings' && (
<SettingsScreen hasFocus={mainFocus} onRequestSidebar={requestSidebar} />
)}
</>
)}
</div>
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import LanguageBadge from '../components/LanguageBadge'
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
interface Props {
@@ -244,6 +245,7 @@ export default function BrowseScreen({
{ch.viewerCount !== undefined && (
<span className="card__viewers">{formatViewers(ch.viewerCount)}</span>
)}
<LanguageBadge language={ch.language} className="browse-stream-card__language" />
</div>
<span className="browse-stream-card__name">{ch.broadcasterName}</span>
{ch.gameName && (
+107
View File
@@ -0,0 +1,107 @@
import { useEffect, useState } from 'react'
import { SettingsIcon } from '../components/Icons'
import { type StreamBadgeMode, useSettings } from '../context/SettingsContext'
interface Props {
hasFocus: boolean
onRequestSidebar: () => void
}
interface Option {
mode: StreamBadgeMode
label: string
preview: string
}
const OPTIONS: Option[] = [
{ mode: 'off', label: 'Aus', preview: '— kein Badge' },
{ mode: 'language', label: 'Nur Sprach-Kürzel', preview: 'z.B. DE EN PT' },
{ mode: 'flag', label: 'Nur Flagge', preview: 'z.B. 🇩🇪 🇺🇸 🇧🇷' },
{ mode: 'both', label: 'Beides (Flagge + Kürzel)', preview: 'z.B. 🇩🇪 DE 🇺🇸 EN' }
]
export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): JSX.Element {
const { settings, setStreamBadgeMode } = useSettings()
const [focusedIndex, setFocusedIndex] = useState(() =>
Math.max(0, OPTIONS.findIndex((o) => o.mode === settings.streamBadgeMode))
)
useEffect(() => {
if (!hasFocus) return
const onKey = (e: KeyboardEvent): void => {
switch (e.key) {
case 'ArrowUp':
e.preventDefault()
setFocusedIndex((i) => Math.max(0, i - 1))
break
case 'ArrowDown':
e.preventDefault()
setFocusedIndex((i) => Math.min(OPTIONS.length - 1, i + 1))
break
case 'ArrowLeft':
e.preventDefault()
onRequestSidebar()
break
case 'Enter': {
e.preventDefault()
const opt = OPTIONS[focusedIndex]
if (opt) setStreamBadgeMode(opt.mode)
break
}
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [hasFocus, focusedIndex, onRequestSidebar, setStreamBadgeMode])
return (
<div className="screen">
<header className="screen__header">
<h2 className="screen__title">Einstellungen</h2>
</header>
<div className="settings-section">
<h3 className="settings-section__title">Sprach-Anzeige auf Stream-Karten</h3>
<p className="settings-section__hint">
Twitch liefert nur die Stream-Sprache — kein Land. Flaggen-Zuordnung
ist eine Annäherung basierend auf der dominanten Twitch-Nutzerbase
(z.B. Portugiesisch → 🇧🇷 Brasilien).
</p>
<div className="settings-options">
{OPTIONS.map((opt, i) => {
const isActive = settings.streamBadgeMode === opt.mode
const isFocused = hasFocus && i === focusedIndex
return (
<button
key={opt.mode}
className={[
'settings-option',
isActive ? 'settings-option--active' : '',
isFocused ? 'settings-option--focused' : ''
].filter(Boolean).join(' ')}
onClick={() => {
setFocusedIndex(i)
setStreamBadgeMode(opt.mode)
}}
tabIndex={isFocused ? 0 : -1}
>
<span className="settings-option__radio" aria-hidden="true">
{isActive ? '●' : '○'}
</span>
<span className="settings-option__label">{opt.label}</span>
<span className="settings-option__preview">{opt.preview}</span>
</button>
)
})}
</div>
</div>
{!hasFocus && (
<div className="screen__state" style={{ flex: 0 }}>
<SettingsIcon style={{ width: 48, height: 48, opacity: 0.2 }} />
</div>
)}
</div>
)
}
+109
View File
@@ -989,6 +989,115 @@ body,
color: var(--color-fg-dim);
}
/* ── Language Badge (unten-rechts im Thumbnail) ───── */
.card__language {
position: absolute;
bottom: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.72);
color: #fff;
font-size: 15px;
font-weight: 700;
padding: 3px 7px;
border-radius: 4px;
letter-spacing: 0.5px;
line-height: 1.2;
}
.browse-stream-card__language {
position: absolute;
bottom: 6px;
right: 7px;
background: rgba(0, 0, 0, 0.72);
color: #fff;
font-size: 14px;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
letter-spacing: 0.5px;
line-height: 1.2;
}
/* ── Settings Screen ──────────────────────────────── */
.settings-section {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 680px;
}
.settings-section__title {
margin: 0;
font-size: 24px;
font-weight: 700;
}
.settings-section__hint {
margin: 0;
font-size: 16px;
color: var(--color-fg-dim);
line-height: 1.5;
}
.settings-options {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.settings-option {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 20px;
border-radius: var(--radius);
background: var(--color-bg-elev);
border: 2px solid transparent;
color: var(--color-fg);
font: inherit;
font-size: 20px;
cursor: pointer;
text-align: left;
transition: background 0.1s, border-color 0.1s;
}
.settings-option:hover {
background: var(--color-bg-elev2);
}
.settings-option--active {
background: rgba(145, 71, 255, 0.12);
}
.settings-option--active .settings-option__radio {
color: var(--color-accent);
}
.settings-option--focused {
border-color: var(--color-focus);
box-shadow: 0 0 0 2px rgba(191, 148, 255, 0.25);
}
.settings-option__radio {
font-size: 20px;
color: var(--color-fg-dim);
flex-shrink: 0;
width: 24px;
}
.settings-option__label {
flex: 1;
font-weight: 600;
}
.settings-option__preview {
font-size: 17px;
color: var(--color-fg-dim);
font-weight: 400;
white-space: nowrap;
}
/* ── Account Screen ───────────────────────────────── */
.account {
flex: 1;
+1
View File
@@ -27,6 +27,7 @@ export interface FollowedChannelInfo {
viewerCount?: number
thumbnailUrl?: string
startedAt?: string
language?: string
}
export interface GameInfo {