From a593e5c63addff3417a5cfc8207f5831430b134e Mon Sep 17 00:00:00 2001 From: arnef Date: Thu, 10 Sep 2026 10:02:43 +0200 Subject: [PATCH] feat: add light/dark theme with settings page --- apps/frontend/index.html | 29 +++++- apps/frontend/src/App.tsx | 9 +- apps/frontend/src/components/Card.tsx | 42 ++++++++ apps/frontend/src/components/DayTab.tsx | 6 +- apps/frontend/src/components/EmptyDay.tsx | 2 +- apps/frontend/src/components/Header.tsx | 6 +- apps/frontend/src/components/ListSkeleton.tsx | 14 +-- apps/frontend/src/components/LogoFallback.tsx | 2 +- .../components/MatchDetailsFutureSkeleton.tsx | 62 ++++++------ .../MatchDetailsGenericSkeleton.tsx | 14 +-- .../src/components/MatchDetailsSkeleton.tsx | 32 +++--- .../frontend/src/components/MatchInsights.tsx | 7 +- apps/frontend/src/components/ResultTile.tsx | 8 +- .../src/components/SquadComparison.tsx | 21 ++-- .../src/components/TableComparison.tsx | 38 ++++---- apps/frontend/src/index.css | 67 +++++++++++++ apps/frontend/src/pages/MatchDetails.tsx | 81 ++++++++-------- apps/frontend/src/pages/Overview.tsx | 52 +++++++--- apps/frontend/src/pages/Settings.tsx | 97 +++++++++++++++++++ apps/frontend/src/stores/themeStore.ts | 57 +++++++++++ 20 files changed, 486 insertions(+), 160 deletions(-) create mode 100644 apps/frontend/src/components/Card.tsx create mode 100644 apps/frontend/src/pages/Settings.tsx create mode 100644 apps/frontend/src/stores/themeStore.ts diff --git a/apps/frontend/index.html b/apps/frontend/index.html index 6a3b905..00cbd67 100644 --- a/apps/frontend/index.html +++ b/apps/frontend/index.html @@ -1,11 +1,34 @@ - + - - fontend + + + Kickern +
diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 54a0324..cbd279e 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -1,11 +1,11 @@ -import { Router, Route, useNavigate, useBeforeLeave } from "@solidjs/router"; import { OverviewPage } from "./pages/Overview"; import { MatchPage } from "./pages/MatchDetails"; +import { SettingsPage } from "./pages/Settings"; import { type ParentProps, onMount, onCleanup, createSignal } from "solid-js"; function Layout(props: ParentProps) { return ( -
+
{props.children}
); @@ -67,10 +67,13 @@ function App() { }); return ( -
+
+ + + diff --git a/apps/frontend/src/components/Card.tsx b/apps/frontend/src/components/Card.tsx new file mode 100644 index 0000000..82a7872 --- /dev/null +++ b/apps/frontend/src/components/Card.tsx @@ -0,0 +1,42 @@ +import { splitProps, type ParentProps, type Flow } from "solid-js"; + +interface CardProps extends ParentProps { + /** "default" = rounded-xl (overview tile), "section" = rounded-2xl panel (details) */ + variant?: "default" | "section"; + /** Subtle shadow, off by default */ + shadowed?: boolean; + /** If set, render as an link instead of a
*/ + href?: string; + [key: string]: any; +} + +export function Card(props: CardProps) { + const [local, rest] = splitProps(props, [ + "variant", + "shadowed", + "class", + "children", + "href", + ]); + + const base = () => + `border border-line ${ + local.variant === "section" ? "bg-surface/50 rounded-2xl" : "bg-surface rounded-xl" + } ${local.shadowed ? "shadow-md" : ""} ${local.class ?? ""}`; + + const children = local.children as Flow; + + if (local.href) { + return ( + + {children} + + ); + } + + return ( +
+ {children} +
+ ); +} diff --git a/apps/frontend/src/components/DayTab.tsx b/apps/frontend/src/components/DayTab.tsx index 97840b1..2b96f27 100644 --- a/apps/frontend/src/components/DayTab.tsx +++ b/apps/frontend/src/components/DayTab.tsx @@ -22,10 +22,10 @@ export const DayTab = (props: { onClick={local.onClick} class={`flex flex-col items-center justify-center min-w-13.75 p-2 rounded-xl transition-all duration-200 ${ local.isActive - ? "bg-emerald-500 text-gray-900 font-bold scale-105 shadow-lg shadow-emerald-500/20" + ? "bg-accent text-accent-contrast font-bold scale-105 shadow-lg shadow-accent/20" : local.day.isToday - ? "bg-gray-700 text-emerald-400 border border-emerald-500/30" - : "bg-gray-800 text-gray-400" + ? "bg-chip text-accent border border-accent/30" + : "bg-surface text-ink-muted" }`} > {local.day.weekday} diff --git a/apps/frontend/src/components/EmptyDay.tsx b/apps/frontend/src/components/EmptyDay.tsx index aca2df4..46b2925 100644 --- a/apps/frontend/src/components/EmptyDay.tsx +++ b/apps/frontend/src/components/EmptyDay.tsx @@ -2,7 +2,7 @@ export const EmptyDay = (props: { day: { dayNum: number; weekday: string }; }) => { return ( -
+

Spielfrei!

Keine Matches am {props.day.dayNum}. {props.day.weekday} diff --git a/apps/frontend/src/components/Header.tsx b/apps/frontend/src/components/Header.tsx index 2a26b9c..6cee03c 100644 --- a/apps/frontend/src/components/Header.tsx +++ b/apps/frontend/src/components/Header.tsx @@ -7,13 +7,13 @@ type HeaderProps = ParentProps<{ }>; export const Header = (props: HeaderProps) => (

{props.title} diff --git a/apps/frontend/src/components/ListSkeleton.tsx b/apps/frontend/src/components/ListSkeleton.tsx index fbc7c61..5155484 100644 --- a/apps/frontend/src/components/ListSkeleton.tsx +++ b/apps/frontend/src/components/ListSkeleton.tsx @@ -6,30 +6,30 @@ export function ListSkeleton() {
{/* Wir rendern 4 gefälschte Spiele-Karten */} {(_, i) => (
{/* Team A (Rechtsbündig simuliert) */}
-
-
+
+
{/* Mittlerer Zeit-/Ergebnisblock */}
-
+
{/* Team B (Linksbündig simuliert) */}
-
-
+
+
)} diff --git a/apps/frontend/src/components/LogoFallback.tsx b/apps/frontend/src/components/LogoFallback.tsx index 5ed0aca..a015fb1 100644 --- a/apps/frontend/src/components/LogoFallback.tsx +++ b/apps/frontend/src/components/LogoFallback.tsx @@ -1,6 +1,6 @@ export const LogoFallback = () => (
{/* Ein sauberes, sportliches Trikot-SVG */} -
-
+
+
-
+
-
-
-
-
+
+
+
+
-
+
-
+
-
-
-
+ +
+
-
-
-
+
+
+
-
+ -
-
-
+
- {() =>
} + {() =>
}
-
+
- {() =>
} + {() =>
}
-
+ -
diff --git a/apps/frontend/src/components/MatchDetailsGenericSkeleton.tsx b/apps/frontend/src/components/MatchDetailsGenericSkeleton.tsx index 08159a8..1f5ef7e 100644 --- a/apps/frontend/src/components/MatchDetailsGenericSkeleton.tsx +++ b/apps/frontend/src/components/MatchDetailsGenericSkeleton.tsx @@ -1,19 +1,20 @@ import { For } from "solid-js"; +import { Card } from "./Card"; export function MatchDetailsGenericSkeleton() { return (
-
+
@@ -21,8 +22,9 @@ export function MatchDetailsGenericSkeleton() {
{(i) => ( -
)} diff --git a/apps/frontend/src/components/MatchDetailsSkeleton.tsx b/apps/frontend/src/components/MatchDetailsSkeleton.tsx index de1a776..1fde239 100644 --- a/apps/frontend/src/components/MatchDetailsSkeleton.tsx +++ b/apps/frontend/src/components/MatchDetailsSkeleton.tsx @@ -1,31 +1,32 @@ import { For } from "solid-js"; +import { Card } from "./Card"; export function MatchDetailsSkeleton() { return (
-
+
@@ -35,29 +36,30 @@ export function MatchDetailsSkeleton() {
{(i) => ( -
-
+
-
-
+
+
-
+
-
-
+
+
-
+ )}
diff --git a/apps/frontend/src/components/MatchInsights.tsx b/apps/frontend/src/components/MatchInsights.tsx index 14ed3d3..1c298ae 100644 --- a/apps/frontend/src/components/MatchInsights.tsx +++ b/apps/frontend/src/components/MatchInsights.tsx @@ -1,5 +1,6 @@ import { Show } from "solid-js"; import { MatchItem } from "./MatchItem"; +import { Card } from "./Card"; interface FirstLeg { homeScore: number; awayScore: number; @@ -16,12 +17,12 @@ export function MatchInsights(props: { }) { return ( -
-

+ +

Hinspiel

-

+
); } diff --git a/apps/frontend/src/components/ResultTile.tsx b/apps/frontend/src/components/ResultTile.tsx index 03fd695..240a19e 100644 --- a/apps/frontend/src/components/ResultTile.tsx +++ b/apps/frontend/src/components/ResultTile.tsx @@ -1,15 +1,15 @@ export const ResultTile = (props: { home?: number; away?: number }) => { return ( -
+
props.away ? "text-emerald-400" : "text-gray-300"} + class={props.home > props.away ? "text-accent" : "text-ink"} > {props.home ?? "-"} - : + : props.home ? "text-emerald-400" : "text-gray-300"} + class={props.away > props.home ? "text-accent" : "text-ink"} > {props.away ?? "-"} diff --git a/apps/frontend/src/components/SquadComparison.tsx b/apps/frontend/src/components/SquadComparison.tsx index 26b7a0f..f707409 100644 --- a/apps/frontend/src/components/SquadComparison.tsx +++ b/apps/frontend/src/components/SquadComparison.tsx @@ -1,17 +1,20 @@ +import { For } from "solid-js"; +import { Card } from "./Card"; + export function SquadComparison(props: { homeTeam: any; awayTeam: any }) { const homeMembers = () => props.homeRosters || []; const awayMembers = () => props.awayRosters || []; return ( -
-

+ +

Kaderübersicht

{/* HEIM TEAM ROW */}
-
+
{props.homeTeam.name}
{/* Horizontale Scrollbar */} @@ -19,14 +22,14 @@ export function SquadComparison(props: { homeTeam: any; awayTeam: any }) { {(player) => (
-
+
- + {player.name} {/* Nur Nachname spart Platz */} @@ -38,21 +41,21 @@ export function SquadComparison(props: { homeTeam: any; awayTeam: any }) { {/* AUSWÄRTS TEAM ROW */}
-
+
{props.awayTeam.name}
{(player) => (
-
+
- + {player.name.split(" ")[1] || player.name}
@@ -61,6 +64,6 @@ export function SquadComparison(props: { homeTeam: any; awayTeam: any }) {
-
+ ); } diff --git a/apps/frontend/src/components/TableComparison.tsx b/apps/frontend/src/components/TableComparison.tsx index 26c8a52..311ce58 100644 --- a/apps/frontend/src/components/TableComparison.tsx +++ b/apps/frontend/src/components/TableComparison.tsx @@ -1,3 +1,5 @@ +import { Card } from "./Card"; + export function TableComparison(props: { homeTeam: any; awayTeam: any }) { // Beispielhafte Mock-Daten – hier kommen später die echten Tabellenplatz-Daten rein const homeStats = props.homeTeam; @@ -24,14 +26,14 @@ export function TableComparison(props: { homeTeam: any; awayTeam: any }) { const diffInPlaces = () => Math.abs(homeStats.pos - awayStats.pos) - 1; return ( -
-

+ +

Tabellen-Ausschnitt

{/* Header-Zeile */} -
+
Pl. Verein Sp. @@ -43,11 +45,11 @@ export function TableComparison(props: { homeTeam: any; awayTeam: any }) {
- + #{sortedTeams()[0].pos} @@ -58,21 +60,21 @@ export function TableComparison(props: { homeTeam: any; awayTeam: any }) { /> {sortedTeams()[0].name} - + {sortedTeams()[0].games} - + {sortedTeams()[0].diff} - + {sortedTeams()[0].points}
{/* Platzhalter-Trenner, wenn Teams weiter auseinanderliegen */} 0}> -
-
+
+
... {diffInPlaces()} {diffInPlaces() === 1 ? "Platz" : "Plätze"}{" "} dazwischen ... @@ -84,11 +86,11 @@ export function TableComparison(props: { homeTeam: any; awayTeam: any }) {
- + #{sortedTeams()[1].pos} @@ -99,17 +101,17 @@ export function TableComparison(props: { homeTeam: any; awayTeam: any }) { /> {sortedTeams()[1].name} - + {sortedTeams()[1].games} - + {sortedTeams()[1].diff} - + {sortedTeams()[1].points}
-
+ ); } diff --git a/apps/frontend/src/index.css b/apps/frontend/src/index.css index 03f0f9f..49719d8 100644 --- a/apps/frontend/src/index.css +++ b/apps/frontend/src/index.css @@ -1,5 +1,72 @@ @import "tailwindcss"; +/* Dark mode toggled via a class on (see stores/themeStore.ts), + so the `dark:` variant and CSS tokens follow the user's Light/Dark/Auto choice. */ +@custom-variant dark (&:where(.dark, .dark *)); + +:root { + color-scheme: light; + --c-app: #f1f5f9; + --c-surface: #ffffff; + --c-surface2: #f8fafc; + --c-elevated: #e2e8f0; + --c-line: #e2e8f0; + --c-line-soft: #e8edf4; + --c-ink: #0f172a; + --c-ink-muted: #475569; + --c-ink-faint: #94a3b8; + --c-chip: #cbd5e1; + --c-accent: #047857; + --c-accent2: #2563eb; + --c-accent3: #e11d48; + --c-accent4: #d97706; + --c-accent5: #0284c7; + --c-accent-contrast: #ffffff; +} + +.dark { + color-scheme: dark; + --c-app: #111827; + --c-surface: #1f2937; + --c-surface2: #182337; + --c-elevated: #0b1120; + --c-line: #374151; + --c-line-soft: #263248; + --c-ink: #f8fafc; + --c-ink-muted: #9ca3af; + --c-ink-faint: #6b7280; + --c-chip: #334155; + --c-accent: #34d399; + --c-accent2: #60a5fa; + --c-accent3: #fb7185; + --c-accent4: #fbbf24; + --c-accent5: #38bdf8; + --c-accent-contrast: #052e1a; +} + +@theme inline { + --color-app: var(--c-app); + --color-surface: var(--c-surface); + --color-surface2: var(--c-surface2); + --color-elevated: var(--c-elevated); + --color-line: var(--c-line); + --color-line-soft: var(--c-line-soft); + --color-ink: var(--c-ink); + --color-ink-muted: var(--c-ink-muted); + --color-ink-faint: var(--c-ink-faint); + --color-chip: var(--c-chip); + --color-accent: var(--c-accent); + --color-accent2: var(--c-accent2); + --color-accent3: var(--c-accent3); + --color-accent4: var(--c-accent4); + --color-accent5: var(--c-accent5); + --color-accent-contrast: var(--c-accent-contrast); +} + +html, +body { + background-color: var(--c-app); +} @keyframes move-stripes { 0% { background-position: 0 0; } diff --git a/apps/frontend/src/pages/MatchDetails.tsx b/apps/frontend/src/pages/MatchDetails.tsx index ecf8e0b..99bb196 100644 --- a/apps/frontend/src/pages/MatchDetails.tsx +++ b/apps/frontend/src/pages/MatchDetails.tsx @@ -21,7 +21,7 @@ export function MatchPage() { return ( <> -
+
-
+
{/*
*/} }> @@ -77,9 +77,9 @@ const SLOT_LABEL: Record = { }; const SLOT_TEXT: Record = { - SINGLE: "text-emerald-400/80", - DOUBLE: "text-sky-400/80", - SHOOT_OUT: "text-amber-400/80", + SINGLE: "text-accent/80", + DOUBLE: "text-accent5/80", + SHOOT_OUT: "text-accent4/80", }; function sidePlayers(side: any) { @@ -97,14 +97,14 @@ function PlayerChip(props: { const name = () => (p() ? `${p().firstName} ${p().lastName}`.trim() : "Unbesetzt"); const avatar = (
+ {p()?.firstName?.[0]?.toUpperCase() || "?"} } @@ -121,7 +121,7 @@ function PlayerChip(props: { {name()} @@ -159,7 +159,7 @@ function PlayerSide(props: { when={players().length > 0} fallback={ @@ -186,7 +186,7 @@ function SlotCard(props: { slot: any }) { const awayWins = () => awayTotal() > homeTotal(); return ( -
+
0} - fallback={} + fallback={} > {sets().map((s: any) => ( @@ -212,21 +212,21 @@ function SlotCard(props: { slot: any }) {
-
+ ); } function MatchDetails(props: { match: any }) { return ( <> -
+
+
Noch keine Ergebnisse
} @@ -241,6 +241,7 @@ function MatchDetails(props: { match: any }) { } import { For, Show } from "solid-js"; +import { Card } from "../components/Card.tsx"; import { LogoFallback as JerseyFallback } from "../components/LogoFallback.tsx"; import { TableComparison } from "../components/TableComparison.tsx"; import { SquadComparison } from "../components/SquadComparison.tsx"; @@ -252,8 +253,8 @@ export default function MatchDetailsFuture(props: { match: any }) { return (
{/* 1. DER SHOWDOWN HEADER */} -
- +
+ {props.match.event.name} @@ -270,14 +271,14 @@ export default function MatchDetailsFuture(props: { match: any }) { alt="" /> - + {props.match.homeTeam.name}
{/* Mittlerer Zeitblock */} -
- +
+ {new Date(props.match.scheduledAt).toLocaleDateString("de-DE", { weekday: "short", day: "2-digit", @@ -290,7 +291,7 @@ export default function MatchDetailsFuture(props: { match: any }) { minute: "2-digit", })} - + VS
@@ -307,14 +308,14 @@ export default function MatchDetailsFuture(props: { match: any }) { alt="" /> - + {props.match.awayTeam.name}
{/* Location Info */} -

+

{/* 2. PROGNOSE / VOTING BAR */} -

-

+ +

Wer gewinnt das Match?

{/* Der 3-geteilte Balken */} -
+
{/* Legende / Prozentzahlen */}
- + Heim ({props.match.prediction?.homeWinChance}%) - + Remis ({props.match.prediction?.drawChance}%) - + Auswärts ({props.match.prediction?.awayWinChance}%)
-
+ {props.match.standing && ( -

+ +

Aktuelle Formkurve

@@ -404,7 +405,7 @@ export default function MatchDetailsFuture(props: { match: any }) { /> ))}
- + Form {/* Form Team B */} @@ -422,7 +423,7 @@ export default function MatchDetailsFuture(props: { match: any }) { ))}

-

+ { switch (props.result) { case "S": // Sieg - return "bg-emerald-500/20 text-emerald-400 border-emerald-500/30"; + return "bg-accent/20 text-accent border-accent/30"; case "U": // Unentschieden - return "bg-gray-700/40 text-gray-400 border-gray-600/30"; + return "bg-chip/40 text-ink-muted border-line/30"; case "N": // Niederlage - return "bg-rose-500/20 text-rose-400 border-rose-500/30"; + return "bg-accent3/20 text-accent3 border-accent3/30"; } }; diff --git a/apps/frontend/src/pages/Overview.tsx b/apps/frontend/src/pages/Overview.tsx index fa9194f..86c5c25 100644 --- a/apps/frontend/src/pages/Overview.tsx +++ b/apps/frontend/src/pages/Overview.tsx @@ -10,6 +10,7 @@ import { // import { useSearchParams } from "@solidjs/router"; import { Header } from "../components/Header"; import { DayTab } from "../components/DayTab"; +import { Card } from "../components/Card"; import { ListSkeleton } from "../components/ListSkeleton"; import { MatchItem } from "../components/MatchItem"; import { loadMatches, matchStore, type Match } from "../stores/matchStore"; @@ -182,11 +183,11 @@ export function OverviewPage() { <>
{(day) => ( @@ -201,24 +202,46 @@ export function OverviewPage() {
- + {new Date(activeDate()).toLocaleDateString("de-DE", { weekday: "long", day: "numeric", month: "long", })} + + +
{(day) => { @@ -250,11 +273,11 @@ export function OverviewPage() { style={{ "--delay": `${itemIndex.current++ * 25}ms`, }} - class={`bg-gray-900 flex items-center justify-between border-b border-gray-800 pb-1.5 px-1 ${shouldAnimateOnEnter ? "animate-waterfall" : ""}`} + class={`bg-app flex items-center justify-between border-b border-line pb-1.5 px-1 ${shouldAnimateOnEnter ? "animate-waterfall" : ""}`} >
{/* sapce for event icon */} - + {event.eventName}
@@ -262,19 +285,18 @@ export function OverviewPage() {
{(game) => ( - - + )}
diff --git a/apps/frontend/src/pages/Settings.tsx b/apps/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..7ceacae --- /dev/null +++ b/apps/frontend/src/pages/Settings.tsx @@ -0,0 +1,97 @@ +import { For, Show } from "solid-js"; +import { themeStore, type ThemeMode } from "../stores/themeStore"; + +const MODES: { value: ThemeMode; label: string; icon: string }[] = [ + { value: "light", label: "Hell", icon: "sun" }, + { value: "dark", label: "Dunkel", icon: "moon" }, + { value: "auto", label: "Auto", icon: "auto" }, +]; + +function Icon({ name }: { name: string }) { + const common = { + class: "h-4 w-4", + fill: "none", + stroke: "currentColor", + "stroke-width": 2, + } as const; + switch (name) { + case "sun": + return ( + + + + + ); + case "moon": + return ( + + + + ); + case "auto": + return ( + + + + + ); + } +} + +export function SettingsPage() { + const current = () => themeStore.mode; + + return ( +
+
+ + Einstellungen + +
+ +
+
+
+

Erscheinungsbild

+

+ Wähle, wie die App aussehen soll. +

+
+ +
+ + {(mode) => ( + + )} + +
+ + +

+ Auto folgt den Systemeinstellungen deines Geräts. +

+
+
+ +
+

+ Kickern – deine Kicker-Liga auf einen Blick. +

+
+
+
+ ); +} diff --git a/apps/frontend/src/stores/themeStore.ts b/apps/frontend/src/stores/themeStore.ts new file mode 100644 index 0000000..e280383 --- /dev/null +++ b/apps/frontend/src/stores/themeStore.ts @@ -0,0 +1,57 @@ +import { createSignal, createEffect } from "solid-js"; + +export type ThemeMode = "light" | "dark" | "auto"; + +const STORAGE_KEY = "theme"; +const media = window.matchMedia("(prefers-color-scheme: dark)"); +const root = document.documentElement; + +function readStored(): ThemeMode { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw === "light" || raw === "dark" || raw === "auto") return raw; + } catch { + /* ignore */ + } + return "auto"; +} + +function isDark(mode: ThemeMode): boolean { + if (mode === "auto") return media.matches; + return mode === "dark"; +} + +function apply(mode: ThemeMode) { + root.classList.toggle("dark", isDark(mode)); +} + +const [getMode, setModeSignal] = createSignal(readStored()); + +// Track the OS preference so "auto" follows the system live. +const onSystemChange = () => { + if (getMode() === "auto") apply("auto"); +}; +media.addEventListener("change", onSystemChange); + +// Persist + apply whenever the mode (or, for auto, the system) changes. +createEffect(() => { + const mode = getMode(); + if (mode !== "auto") { + try { + localStorage.setItem(STORAGE_KEY, mode); + } catch { + /* ignore */ + } + } + apply(mode); +}); + +export const themeStore = { + get mode() { + return getMode(); + }, + set(mode: ThemeMode) { + setModeSignal(mode); + }, + isDark: () => isDark(getMode()), +};