Projektstart

- Phase 1 "Twitch Login" abgeschlossen
This commit is contained in:
Andre
2026-04-07 20:11:00 +02:00
commit 7d19ccf652
23 changed files with 8606 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1280, initial-scale=1" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:;" />
<title>Twitch4SteamDeck</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+50
View File
@@ -0,0 +1,50 @@
import { useCallback, useEffect, useState } from 'react'
import LoginScreen from './screens/LoginScreen'
import type { AuthStatus } from './types/t4sd'
export default function App(): JSX.Element {
const [status, setStatus] = useState<AuthStatus | 'loading'>('loading')
const refresh = useCallback(async () => {
const s = await window.t4sd.auth.getStatus()
setStatus(s)
}, [])
useEffect(() => {
void refresh()
const off = window.t4sd.auth.onEvent((event) => {
if (event.kind === 'authorized' || event.kind === 'cancelled') void refresh()
})
return off
}, [refresh])
return (
<main className="shell">
<header className="shell__header">
<h1>Twitch4SteamDeck</h1>
<span className="shell__version">v{window.t4sd.appVersion}</span>
</header>
<section className="shell__body">
{status === 'loading' && <p>Lade…</p>}
{status === 'logged-out' && <LoginScreen onAuthorized={refresh} />}
{status === 'logged-in' && <LoggedInPlaceholder onLogout={refresh} />}
</section>
</main>
)
}
function LoggedInPlaceholder({ onLogout }: { onLogout: () => void }): JSX.Element {
async function handleLogout(): Promise<void> {
await window.t4sd.auth.logout()
onLogout()
}
return (
<div className="login">
<h2>Eingeloggt</h2>
<p>Followed-Liste, VODs und Player folgen in den nächsten Schritten.</p>
<button className="btn" onClick={handleLogout}>
Abmelden
</button>
</div>
)
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles/global.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+139
View File
@@ -0,0 +1,139 @@
import { useEffect, useRef, useState } from 'react'
import QRCode from 'qrcode'
import type { DeviceFlowStartInfo } from '../types/t4sd'
interface Props {
onAuthorized: () => void
}
type Phase =
| { kind: 'idle' }
| { kind: 'starting' }
| { kind: 'awaiting'; info: DeviceFlowStartInfo; qrDataUrl: string; remainingSec: number }
| { kind: 'error'; message: string }
| { kind: 'not-configured' }
export default function LoginScreen({ onAuthorized }: Props): JSX.Element {
const [phase, setPhase] = useState<Phase>({ kind: 'idle' })
const startBtnRef = useRef<HTMLButtonElement>(null)
// Initial: prüfen ob Client-ID konfiguriert ist.
useEffect(() => {
void window.t4sd.auth.isConfigured().then((ok) => {
if (!ok) setPhase({ kind: 'not-configured' })
})
startBtnRef.current?.focus()
}, [])
// Auth-Events aus Main abonnieren.
useEffect(() => {
const off = window.t4sd.auth.onEvent((event) => {
switch (event.kind) {
case 'authorized':
onAuthorized()
break
case 'denied':
setPhase({ kind: 'error', message: 'Zugriff abgelehnt.' })
break
case 'expired':
setPhase({ kind: 'error', message: 'Code abgelaufen — bitte erneut versuchen.' })
break
case 'error':
setPhase({ kind: 'error', message: event.message })
break
case 'cancelled':
setPhase({ kind: 'idle' })
break
}
})
return off
}, [onAuthorized])
// Countdown für die aktive Phase.
useEffect(() => {
if (phase.kind !== 'awaiting') return
if (phase.remainingSec <= 0) return
const t = setTimeout(() => {
setPhase((p) =>
p.kind === 'awaiting' ? { ...p, remainingSec: p.remainingSec - 1 } : p
)
}, 1000)
return () => clearTimeout(t)
}, [phase])
async function start(): Promise<void> {
setPhase({ kind: 'starting' })
try {
const info = await window.t4sd.auth.startDeviceFlow()
const qrDataUrl = await QRCode.toDataURL(info.verificationUri, {
margin: 1,
width: 280,
color: { dark: '#0e0e10', light: '#ffffff' }
})
setPhase({ kind: 'awaiting', info, qrDataUrl, remainingSec: info.expiresInSec })
} catch (err) {
setPhase({ kind: 'error', message: (err as Error).message })
}
}
async function cancel(): Promise<void> {
await window.t4sd.auth.cancel()
setPhase({ kind: 'idle' })
}
if (phase.kind === 'not-configured') {
return (
<div className="login">
<h2>Konfiguration fehlt</h2>
<p>
Es ist keine Twitch <code>Client ID</code> hinterlegt. Lege im Projektverzeichnis eine
Datei <code>.env</code> an (siehe <code>.env.example</code>) und setze
<br />
<code>MAIN_VITE_TWITCH_CLIENT_ID=…</code>
<br />
Danach <code>npm run dev</code> neu starten.
</p>
</div>
)
}
if (phase.kind === 'awaiting') {
return (
<div className="login">
<h2>Mit Twitch verbinden</h2>
<p>
Öffne auf einem anderen Gerät{' '}
<strong>{phase.info.verificationUri}</strong> und gib den Code ein:
</p>
<div className="login__code">{phase.info.userCode}</div>
<img className="login__qr" src={phase.qrDataUrl} alt="QR Code" />
<p className="login__hint">Code läuft in {formatTime(phase.remainingSec)} ab.</p>
<button className="btn" onClick={cancel}>
Abbrechen
</button>
</div>
)
}
return (
<div className="login">
<h2>Willkommen</h2>
<p>Verbinde deinen Twitch-Account, um gefolgte Kanäle und VODs zu sehen.</p>
{phase.kind === 'error' && <p className="login__error">{phase.message}</p>}
<button
ref={startBtnRef}
className="btn btn--primary"
onClick={start}
disabled={phase.kind === 'starting'}
>
{phase.kind === 'starting' ? 'Starte…' : 'Mit Twitch verbinden'}
</button>
</div>
)
}
function formatTime(sec: number): string {
const m = Math.floor(sec / 60)
const s = sec % 60
return `${m}:${s.toString().padStart(2, '0')}`
}
+154
View File
@@ -0,0 +1,154 @@
/* Big-Screen-Baseline: 1280x800 (Steam Deck), große Schrift, dunkle Oberfläche. */
:root {
--color-bg: #0e0e10;
--color-bg-elev: #18181b;
--color-fg: #efeff1;
--color-fg-dim: #adadb8;
--color-accent: #9147ff;
--color-focus: #bf94ff;
--radius: 12px;
--font-size-body: 22px;
--font-size-h1: 44px;
}
* {
box-sizing: border-box;
}
html, body, #root {
margin: 0;
padding: 0;
height: 100%;
background: var(--color-bg);
color: var(--color-fg);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-size: var(--font-size-body);
line-height: 1.4;
-webkit-font-smoothing: antialiased;
user-select: none;
overflow: hidden;
}
:focus-visible {
outline: 3px solid var(--color-focus);
outline-offset: 4px;
border-radius: 6px;
}
.shell {
display: flex;
flex-direction: column;
height: 100%;
padding: 32px 48px;
gap: 32px;
}
.shell__header {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.shell__header h1 {
margin: 0;
font-size: var(--font-size-h1);
letter-spacing: 0.5px;
}
.shell__version {
color: var(--color-fg-dim);
font-size: 18px;
}
.shell__body {
flex: 1;
background: var(--color-bg-elev);
border-radius: var(--radius);
padding: 32px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
color: var(--color-fg);
overflow: auto;
}
.login {
max-width: 640px;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.login h2 {
margin: 0;
font-size: 36px;
}
.login p {
margin: 0;
color: var(--color-fg-dim);
line-height: 1.5;
}
.login code {
background: rgba(255, 255, 255, 0.08);
padding: 2px 8px;
border-radius: 6px;
font-size: 18px;
}
.login__code {
font-size: 56px;
letter-spacing: 12px;
font-weight: 700;
background: rgba(145, 71, 255, 0.12);
padding: 16px 28px;
border-radius: var(--radius);
color: var(--color-fg);
}
.login__qr {
width: 280px;
height: 280px;
border-radius: var(--radius);
background: #fff;
padding: 8px;
}
.login__hint {
font-size: 18px;
}
.login__error {
color: #ff6868;
}
.btn {
font: inherit;
padding: 16px 32px;
border: 2px solid transparent;
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.08);
color: var(--color-fg);
cursor: pointer;
min-width: 220px;
}
.btn:hover {
background: rgba(255, 255, 255, 0.14);
}
.btn--primary {
background: var(--color-accent);
}
.btn--primary:hover {
background: #a970ff;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
+34
View File
@@ -0,0 +1,34 @@
// Spiegel der Preload-API. Manuell synchron halten zu src/preload/index.ts.
export type AuthStatus = 'logged-out' | 'logged-in'
export interface DeviceFlowStartInfo {
userCode: string
verificationUri: string
expiresInSec: number
}
export type AuthEvent =
| { kind: 'authorized' }
| { kind: 'denied' }
| { kind: 'expired' }
| { kind: 'cancelled' }
| { kind: 'error'; message: string }
export interface T4sdApi {
appVersion: string
auth: {
isConfigured: () => Promise<boolean>
getStatus: () => Promise<AuthStatus>
startDeviceFlow: () => Promise<DeviceFlowStartInfo>
cancel: () => Promise<void>
logout: () => Promise<void>
onEvent: (cb: (event: AuthEvent) => void) => () => void
}
}
declare global {
interface Window {
t4sd: T4sdApi
}
}