Initial commit
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { Router, Route, useNavigate, useBeforeLeave } from "@solidjs/router";
|
||||
import { OverviewPage } from "./pages/Overview";
|
||||
import { MatchPage } from "./pages/MatchDetails";
|
||||
import { type ParentProps, onMount, onCleanup, createSignal } from "solid-js";
|
||||
|
||||
function Layout(props: ParentProps) {
|
||||
return (
|
||||
<div class="bg-gray-900 text-white h-dvh flex flex-col font-sans overflow-hidden select-none">
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [path, setPath] = createSignal(window.location.pathname);
|
||||
|
||||
// Die zentrale Navigations-Funktion
|
||||
const navigate = (to) => {
|
||||
if (window.location.pathname === to) return;
|
||||
|
||||
// Hier steuerst du die Transition BEVOR die URL oder das Signal angefasst wird
|
||||
if (document.startViewTransition) {
|
||||
document.startViewTransition(() => {
|
||||
window.history.pushState({}, "", to);
|
||||
setPath(to);
|
||||
});
|
||||
} else {
|
||||
window.history.pushState({}, "", to);
|
||||
setPath(to);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePopstate = () => {
|
||||
if (document.startViewTransition) {
|
||||
document.documentElement.classList.add("back-navigation");
|
||||
// Der Browser hat die URL bei BACK schon geändert,
|
||||
// wir triggern die Transition und updaten das Signal synchron
|
||||
const transition = document.startViewTransition(() => {
|
||||
setPath(window.location.pathname);
|
||||
});
|
||||
transition.finished.then(() => {
|
||||
document.documentElement.classList.remove("back-navigation");
|
||||
});
|
||||
} else {
|
||||
setPath(window.location.pathname);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLinks = (e) => {
|
||||
const a = e.target.closest("a");
|
||||
if (a) {
|
||||
const href = a.getAttribute("href");
|
||||
if (href.startsWith("/")) {
|
||||
e.preventDefault();
|
||||
navigate(href);
|
||||
}
|
||||
}
|
||||
};
|
||||
// Hier fixen wir das Back-Button-Problem ein für alle Mal sauber!
|
||||
onMount(() => {
|
||||
window.addEventListener("click", handleLinks);
|
||||
window.addEventListener("popstate", handlePopstate);
|
||||
});
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("click", handleLinks);
|
||||
window.removeEventListener("popstate", handlePopstate);
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="bg-gray-900 text-white h-dvh flex flex-col font-sans overflow-hidden select-none">
|
||||
<Show when={path() === "/"}>
|
||||
<OverviewPage />
|
||||
</Show>
|
||||
<Show when={path().startsWith("/match/")}>
|
||||
<MatchPage />
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 166 155.3"><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" fill="#76b3e1"/><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="27.5" y1="3" x2="152" y2="63.5"><stop offset=".1" stop-color="#76b3e1"/><stop offset=".3" stop-color="#dcf2fd"/><stop offset="1" stop-color="#76b3e1"/></linearGradient><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" opacity=".3" fill="url(#a)"/><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" fill="#518ac8"/><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="95.8" y1="32.6" x2="74" y2="105.2"><stop offset="0" stop-color="#76b3e1"/><stop offset=".5" stop-color="#4377bb"/><stop offset="1" stop-color="#1f3b77"/></linearGradient><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" opacity=".3" fill="url(#b)"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="18.4" y1="64.2" x2="144.3" y2="149.8"><stop offset="0" stop-color="#315aa9"/><stop offset=".5" stop-color="#518ac8"/><stop offset="1" stop-color="#315aa9"/></linearGradient><path d="M134 80a45 45 0 00-48-15L24 85 4 120l112 19 20-36c4-7 3-15-2-23z" fill="url(#c)"/><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="75.2" y1="74.5" x2="24.4" y2="260.8"><stop offset="0" stop-color="#4377bb"/><stop offset=".5" stop-color="#1a336b"/><stop offset="1" stop-color="#1a336b"/></linearGradient><path d="M114 115a45 45 0 00-48-15L4 120s53 40 94 30l3-1c17-5 23-21 13-34z" fill="url(#d)"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,35 @@
|
||||
import { splitProps } from "solid-js";
|
||||
|
||||
export const DayTab = (props: {
|
||||
day: {
|
||||
dateString: string;
|
||||
weekday: string;
|
||||
dayNum: number;
|
||||
isToday: boolean;
|
||||
};
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
[key: string]: any;
|
||||
}) => {
|
||||
const [local, restHtmlProps] = splitProps(props, [
|
||||
"day",
|
||||
"isActive",
|
||||
"onClick",
|
||||
]);
|
||||
return (
|
||||
<button
|
||||
{...restHtmlProps}
|
||||
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"
|
||||
: local.day.isToday
|
||||
? "bg-gray-700 text-emerald-400 border border-emerald-500/30"
|
||||
: "bg-gray-800 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<span class="text-xs uppercase tracking-wider">{local.day.weekday}</span>
|
||||
<span class="text-lg mt-0.5">{local.day.dayNum}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
export const EmptyDay = (props: {
|
||||
day: { dayNum: number; weekday: string };
|
||||
}) => {
|
||||
return (
|
||||
<div class="text-center py-12 text-gray-500 bg-gray-800/30 rounded-2xl border border-dashed border-gray-700">
|
||||
<p class="text-lg font-medium">Spielfrei!</p>
|
||||
<p class="text-sm mt-1">
|
||||
Keine Matches am {props.day.dayNum}. {props.day.weekday}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Show, type ParentProps } from "solid-js";
|
||||
|
||||
type HeaderProps = ParentProps<{
|
||||
title: string;
|
||||
onClickTitle?: () => void;
|
||||
showBackButton?: boolean;
|
||||
}>;
|
||||
export const Header = (props: HeaderProps) => (
|
||||
<header
|
||||
class={`p-4 bg-gray-800 border-b border-gray-700 items-center sticky top-0 z-20 flex`}
|
||||
>
|
||||
<div class="flex items-center w-8 h-5">
|
||||
<Show when={props.showBackButton}>
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
class="flex items-center gap-1 text-sm text-emerald-400 hover:text-emerald-300 py-1 pr-3 z-1"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
<span class="sr-only">Zurück</span>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<h1
|
||||
class="absolute inset-x-0 text-base font-boldtracking-wide text-center text-emerald-400 truncate"
|
||||
onClick={props.onClickTitle}
|
||||
>
|
||||
{props.title}
|
||||
</h1>
|
||||
|
||||
<div class="text-right flex justify-end">{props.children}</div>
|
||||
</header>
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
import { For } from "solid-js";
|
||||
|
||||
export function ListSkeleton() {
|
||||
return (
|
||||
// Ein Wrapper, der genau wie dein echter Spiele-Container formatiert ist
|
||||
<div class=" space-y-3 animate-pulse">
|
||||
<div
|
||||
style={{ "--delay": "0ms" }}
|
||||
class="h-3 bg-gray-800 rounded w-1/3 mb-4 animate-skeleton-loop"
|
||||
></div>
|
||||
{/* Wir rendern 4 gefälschte Spiele-Karten */}
|
||||
<For each={[1, 2, 3, 4]}>
|
||||
{(_, i) => (
|
||||
<div
|
||||
class="bg-gray-800/60 p-4 rounded-xl border border-gray-700/40 shadow-md flex items-center justify-between h-[58px] animate-skeleton-loop"
|
||||
style={{ "--delay": `${i() * 150}ms` }}
|
||||
>
|
||||
{/* Team A (Rechtsbündig simuliert) */}
|
||||
<div class="w-full flex justify-end pr-2 items-center gap-2">
|
||||
<div class="h-4 bg-gray-700 rounded w-20"></div>
|
||||
<div class="size-8 bg-gray-700 rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Mittlerer Zeit-/Ergebnisblock */}
|
||||
<div class="flex justify-center">
|
||||
<div class="w-16 h-7 bg-gray-950/60 rounded-lg border border-gray-700/50"></div>
|
||||
</div>
|
||||
|
||||
{/* Team B (Linksbündig simuliert) */}
|
||||
<div class="w-full flex justify-start pl-2 gap-2 items-center">
|
||||
<div class="size-8 bg-gray-700 rounded-full" />
|
||||
<div class="h-4 bg-gray-700 rounded w-20"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export const LogoFallback = () => (
|
||||
<div
|
||||
class={`size-8 flex-shrink-0 rounded-full flex items-center justify-center border transition-colors duration-200 ${"bg-gray-800 border-gray-700 text-gray-400"}`}
|
||||
>
|
||||
{/* Ein sauberes, sportliches Trikot-SVG */}
|
||||
<svg
|
||||
class="size-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
{/* Trikot-Körper */}
|
||||
<path d="M20.38 6.54L16 4.5a2 2 0 0 0-1.6.1L12 6l-2.4-1.4a2 2 0 0 0-1.6-.1L3.62 6.54a1 1 0 0 0-.62.92v4a2 2 0 0 0 1.25 1.85l3.25 1.3V20a2 2 0 0 0 2 2h5a2 2 0 0 0 2-2v-5.4l3.25-1.3A2 2 0 0 0 21 11.46v-4a1 1 0 0 0-.62-.92z" />
|
||||
{/* V-Ausschnitt für den sportlichen Look */}
|
||||
<path d="M10 4.7a2 2 0 0 0 4 0" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Show } from "solid-js";
|
||||
import { MatchItem } from "./MatchItem";
|
||||
interface FirstLeg {
|
||||
homeScore: number;
|
||||
awayScore: number;
|
||||
date: string;
|
||||
wasHome?: boolean; // Hilft zu wissen, wer damals Heimrecht hatte
|
||||
}
|
||||
|
||||
export function MatchInsights(props: {
|
||||
homeTeamName: string;
|
||||
awayTeamName: string;
|
||||
firstLeg: FirstLeg | null | undefined;
|
||||
homeAvgGoals?: number; // z.B. 6.2
|
||||
awayAvgGoals?: number; // z.B. 4.8
|
||||
}) {
|
||||
return (
|
||||
<div class="bg-gray-800/50 rounded-2xl p-4 border border-gray-800 space-y-4">
|
||||
<h3 class="text-xs font-bold uppercase tracking-wider text-gray-400 text-center">
|
||||
Hinspiel
|
||||
</h3>
|
||||
<Show when={props.firstLeg}>
|
||||
<MatchItem game={props.firstLeg} />
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Show } from "solid-js";
|
||||
import { LogoFallback } from "./LogoFallback";
|
||||
import { ResultTile } from "./ResultTile";
|
||||
|
||||
export const MatchItem = (props: { game: any }) => {
|
||||
return (
|
||||
<div class="flex items-center justify-between gap-2 flex-1">
|
||||
<div class="flex-1 flex flex-row items-center gap-2 justify-end min-w-0">
|
||||
<div class="text-right pr-2 line-clamp-2 font-light text-xs">
|
||||
{props.game.homeTeam.name}
|
||||
</div>
|
||||
<div class="min-w-8">
|
||||
<Show when={props.game.homeTeam.logoUrl} fallback={<LogoFallback />}>
|
||||
<img
|
||||
src={props.game.homeTeam.logoUrl}
|
||||
class="size-8 object-contain"
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-16 min-w-16 flex flex-col items-center">
|
||||
<Show
|
||||
when={props.game.status !== "SCHEDULED"}
|
||||
fallback={
|
||||
<span class="text-xs font-bold text-center">
|
||||
{new Date(props.game.scheduledAt).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<ResultTile
|
||||
home={props.game.homeTeam.points}
|
||||
away={props.game.awayTeam.points}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-row gap-2 items-center justify-start min-w-0">
|
||||
<div class="min-w-8">
|
||||
<Show when={props.game.awayTeam.logoUrl} fallback={<LogoFallback />}>
|
||||
<img
|
||||
src={props.game.awayTeam.logoUrl}
|
||||
class="size-8 object-contain"
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="text-left pl-2 line-clamp-2 font-light text-xs">
|
||||
{props.game.awayTeam.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
export const ResultTile = (props: { home?: number; away?: number }) => {
|
||||
return (
|
||||
<div class="flex flex-col items-center justify-center bg-gray-900/60 p-2 rounded-lg border border-gray-700">
|
||||
<div class="flex items-center gap-1 font-mono text-xs font-bold">
|
||||
<span
|
||||
class={props.home > props.away ? "text-emerald-400" : "text-gray-300"}
|
||||
>
|
||||
{props.home ?? "-"}
|
||||
</span>
|
||||
<span class="text-gray-600">:</span>
|
||||
<span
|
||||
class={props.away > props.home ? "text-emerald-400" : "text-gray-300"}
|
||||
>
|
||||
{props.away ?? "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
export function SquadComparison(props: { homeTeam: any; awayTeam: any }) {
|
||||
const homeMembers = () => props.homeRosters || [];
|
||||
const awayMembers = () => props.awayRosters || [];
|
||||
|
||||
return (
|
||||
<div class="bg-gray-800/50 rounded-2xl p-4 border border-gray-800 space-y-4 overflow-hidden">
|
||||
<h3 class="text-xs font-bold uppercase tracking-wider text-gray-400 text-center">
|
||||
Kaderübersicht
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
{/* HEIM TEAM ROW */}
|
||||
<div class="space-y-2">
|
||||
<div class="text-[10px] uppercase font-bold text-emerald-400 px-1">
|
||||
{props.homeTeam.name}
|
||||
</div>
|
||||
{/* Horizontale Scrollbar */}
|
||||
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none snap-x">
|
||||
<For each={homeMembers()}>
|
||||
{(player) => (
|
||||
<div class="flex flex-col items-center justify-center text-center w-14 flex-shrink-0 snap-start">
|
||||
<div class="size-10 rounded-full border border-emerald-500/20 p-0.5 bg-gray-900 mb-1">
|
||||
<img
|
||||
src={player.imageUrl || "/fallback-avatar.svg"}
|
||||
class="size-full rounded-full object-cover"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-300 font-medium truncate w-full">
|
||||
{player.name}
|
||||
{/* Nur Nachname spart Platz */}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AUSWÄRTS TEAM ROW */}
|
||||
<div class="space-y-2">
|
||||
<div class="text-[10px] uppercase font-bold text-gray-400 px-1">
|
||||
{props.awayTeam.name}
|
||||
</div>
|
||||
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none snap-x">
|
||||
<For each={awayMembers()}>
|
||||
{(player) => (
|
||||
<div class="flex flex-col items-center justify-center text-center w-14 flex-shrink-0 snap-start">
|
||||
<div class="size-10 rounded-full border border-gray-700 p-0.5 bg-gray-900 mb-1">
|
||||
<img
|
||||
src={player.imageUrl || "/fallback-avatar.svg"}
|
||||
class="size-full rounded-full object-cover"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-300 font-medium truncate w-full">
|
||||
{player.name.split(" ")[1] || player.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
export function TableComparison(props: { homeTeam: any; awayTeam: any }) {
|
||||
// Beispielhafte Mock-Daten – hier kommen später die echten Tabellenplatz-Daten rein
|
||||
const homeStats = props.homeTeam;
|
||||
|
||||
// name: props.homeTeam.name,
|
||||
// logo: props.homeTeam.logoUrl,
|
||||
// games: 12,
|
||||
// diff: "+14",
|
||||
// points: 25,
|
||||
// };
|
||||
const awayStats = props.awayTeam;
|
||||
// {
|
||||
// pos: 8,
|
||||
// name: props.awayTeam.name,
|
||||
// logo: props.awayTeam.logoUrl,
|
||||
// games: 12,
|
||||
// diff: "-2",
|
||||
// points: 17,
|
||||
// };
|
||||
|
||||
// Sortieren, damit das Team weiter oben auch in der UI oben steht
|
||||
const sortedTeams = () =>
|
||||
[homeStats, awayStats].sort((a, b) => a.pos - b.pos);
|
||||
const diffInPlaces = () => Math.abs(homeStats.pos - awayStats.pos) - 1;
|
||||
|
||||
return (
|
||||
<div class="bg-gray-800/50 rounded-2xl p-4 border border-gray-800 space-y-3">
|
||||
<h3 class="text-xs font-bold uppercase tracking-wider text-gray-400 text-center">
|
||||
Tabellen-Ausschnitt
|
||||
</h3>
|
||||
|
||||
<div class="w-full space-y-1">
|
||||
{/* Header-Zeile */}
|
||||
<div class="flex text-[10px] uppercase font-bold text-gray-500 px-2 pb-1 border-b border-gray-800">
|
||||
<span class="w-8">Pl.</span>
|
||||
<span class="flex-1">Verein</span>
|
||||
<span class="w-8 text-center">Sp.</span>
|
||||
<span class="w-10 text-center">Diff.</span>
|
||||
<span class="w-10 text-right">Pkt.</span>
|
||||
</div>
|
||||
|
||||
{/* Team 1 (Das Höherplatzierte) */}
|
||||
<div
|
||||
class={`flex items-center text-xs p-2 rounded-xl border ${
|
||||
sortedTeams()[0].name === props.homeTeam.name
|
||||
? "bg-emerald-950/20 border-emerald-500/20"
|
||||
: "bg-gray-900/40 border-transparent"
|
||||
}`}
|
||||
>
|
||||
<span class="w-8 font-mono font-bold text-gray-400">
|
||||
#{sortedTeams()[0].pos}
|
||||
</span>
|
||||
<span class="flex-1 font-medium truncate flex items-center gap-2">
|
||||
<img
|
||||
src={sortedTeams()[0].logo}
|
||||
class="size-4 object-contain"
|
||||
alt=""
|
||||
/>
|
||||
{sortedTeams()[0].name}
|
||||
</span>
|
||||
<span class="w-8 text-center text-gray-400 font-mono">
|
||||
{sortedTeams()[0].games}
|
||||
</span>
|
||||
<span class="w-10 text-center text-gray-400 font-mono">
|
||||
{sortedTeams()[0].diff}
|
||||
</span>
|
||||
<span class="w-10 text-right font-bold text-emerald-400 font-mono">
|
||||
{sortedTeams()[0].points}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Platzhalter-Trenner, wenn Teams weiter auseinanderliegen */}
|
||||
<Show when={diffInPlaces() > 0}>
|
||||
<div class="flex items-center px-4 py-1 text-[10px] text-gray-600 font-medium">
|
||||
<div class="w-1 h-3 border-l border-dashed border-gray-700 mr-3"></div>
|
||||
<span>
|
||||
... {diffInPlaces()} {diffInPlaces() === 1 ? "Platz" : "Plätze"}{" "}
|
||||
dazwischen ...
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Team 2 (Das Tieferplatzierte) */}
|
||||
<div
|
||||
class={`flex items-center text-xs p-2 rounded-xl border ${
|
||||
sortedTeams()[1].name === props.homeTeam.name
|
||||
? "bg-emerald-950/20 border-emerald-500/20"
|
||||
: "bg-gray-900/40 border-transparent"
|
||||
}`}
|
||||
>
|
||||
<span class="w-8 font-mono font-bold text-gray-400">
|
||||
#{sortedTeams()[1].pos}
|
||||
</span>
|
||||
<span class="flex-1 font-medium truncate flex items-center gap-2">
|
||||
<img
|
||||
src={sortedTeams()[1].logo}
|
||||
class="size-4 object-contain"
|
||||
alt=""
|
||||
/>
|
||||
{sortedTeams()[1].name}
|
||||
</span>
|
||||
<span class="w-8 text-center text-gray-400 font-mono">
|
||||
{sortedTeams()[1].games}
|
||||
</span>
|
||||
<span class="w-10 text-center text-gray-400 font-mono">
|
||||
{sortedTeams()[1].diff}
|
||||
</span>
|
||||
<span class="w-10 text-right font-bold text-emerald-400 font-mono">
|
||||
{sortedTeams()[1].points}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
|
||||
@keyframes move-stripes {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: 40px 0; }
|
||||
}
|
||||
|
||||
.bg-striped-loading-dark {
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
rgba(0, 0, 0, 0.08) 25%,
|
||||
transparent 25%,
|
||||
transparent 50%,
|
||||
rgba(0, 0, 0, 0.08) 50%,
|
||||
rgba(0, 0, 0, 0.08) 75%,
|
||||
transparent 75%,
|
||||
transparent
|
||||
);
|
||||
background-size: 40px 40px;
|
||||
animation: move-stripes 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes waterfall {
|
||||
0% {
|
||||
opacity: 0.3;
|
||||
/* transform: translateY(12px); */
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
/* transform: translateY(0); */
|
||||
}
|
||||
}
|
||||
|
||||
.animate-waterfall {
|
||||
opacity: 0; /* Startet unsichtbar, bis die Animation greift */
|
||||
animation: waterfall 0.25s cubic-bezier(0.21, 1.02, 0.43, 1.01) forwards;
|
||||
/* Nutzen die Inline-Variable, die wir in SolidJS setzen */
|
||||
animation-delay: var(--delay);
|
||||
}
|
||||
|
||||
@keyframes skeleton-wave {
|
||||
0%, 100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.99);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-skeleton-loop {
|
||||
opacity: 0.3;
|
||||
/* 1.6s für einen kompletten Wellendurchlauf */
|
||||
animation: skeleton-wave 1.6s ease-in-out infinite;
|
||||
animation-delay: var(--delay);
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation-duration: 400ms;
|
||||
animation-timing-function: cubic-bezier(0.32, 0.94, 0.6, 1);
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
1. FORWARD / PUSH ANIMATION (Standard)
|
||||
========================================== */
|
||||
|
||||
/* Die alte Seite schiebt sich leicht nach links (-20%) und wird blasser */
|
||||
@keyframes ios-exit-forward {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(-20%); opacity: 0.9; }
|
||||
}
|
||||
|
||||
/* Die neue Seite kommt von ganz rechts (100%) mit leichtem Schatten */
|
||||
@keyframes ios-enter-forward {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
box-shadow: -10px 0 30px rgba(0,0,0,0.15);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
box-shadow: -10px 0 30px rgba(0,0,0,0);
|
||||
}
|
||||
}
|
||||
|
||||
::view-transition-old(root) {
|
||||
animation-name: ios-exit-forward;
|
||||
}
|
||||
::view-transition-new(root) {
|
||||
animation-name: ios-enter-forward;
|
||||
z-index: 2; /* Wichtig: Neue Seite liegt OBEN */
|
||||
}
|
||||
|
||||
|
||||
/* ==========================================
|
||||
2. BACKWARD / POP ANIMATION (.back-navigation)
|
||||
========================================== */
|
||||
|
||||
/* Die alte Seite (die jetzt schließt) schiebt sich nach rechts weg */
|
||||
@keyframes ios-exit-backward {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
box-shadow: -10px 0 30px rgba(0,0,0,0.15);
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
box-shadow: -10px 0 30px rgba(0,0,0,0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Die neue Seite (die von links wiederkommt) startet bei -20% und kommt vor */
|
||||
@keyframes ios-enter-backward {
|
||||
from { transform: translateX(-20%); opacity: 0.9; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.back-navigation::view-transition-old(root) {
|
||||
animation-name: ios-exit-backward;
|
||||
z-index: 2; /* Wichtig: Die schließende Seite liegt OBEN */
|
||||
}
|
||||
.back-navigation::view-transition-new(root) {
|
||||
animation-name: ios-enter-backward;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/* @refresh reload */
|
||||
import { render } from 'solid-js/web'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
|
||||
render(() => <App />, root!)
|
||||
@@ -0,0 +1,308 @@
|
||||
import { onMount } from "solid-js";
|
||||
import { Header } from "../components/Header";
|
||||
import { useParams } from "@solidjs/router";
|
||||
import { matchStore, loadMatch } from "../stores/matchStore";
|
||||
import { MatchItem } from "../components/MatchItem";
|
||||
import { MatchInsights } from "../components/MatchInsights";
|
||||
import { ResultTile } from "../components/ResultTile";
|
||||
export function MatchPage() {
|
||||
// const { id } = useParams();
|
||||
const id = window.location.pathname.split("/").pop();
|
||||
const match = () => matchStore.matches.find((m) => m.id === id);
|
||||
|
||||
onMount(() => {
|
||||
loadMatch(id);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="w-full h-full overflow-y-auto bg-gray-800">
|
||||
<button
|
||||
class="sticky top-5 left-2 z-22 p-2 cursor-pointer"
|
||||
onClick={() => {
|
||||
window.history.back();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
<span class="sr-only">Back</span>
|
||||
</button>
|
||||
<div class="bg-gray-900">
|
||||
{/* <Header title={""} showBackButton /> */}
|
||||
{match() && match().status !== "SCHEDULED" && (
|
||||
<MatchDetails match={match()} />
|
||||
)}
|
||||
{match() && match().status === "SCHEDULED" && (
|
||||
<MatchDetailsFuture match={match()} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchDetails(props: { match: any }) {
|
||||
return (
|
||||
<>
|
||||
<div class="bg-gradient-to-b from-gray-800 to-gray-850 w-full py-5.5 px-10 border-b border-gray-800 text-center sticky top-0 backdrop-blur-md z-21">
|
||||
<MatchItem game={props.match} />
|
||||
</div>
|
||||
<div>
|
||||
{props.match.slots?.map((s) => {
|
||||
return (
|
||||
<div>
|
||||
{s.type}
|
||||
<div class="flex flex-row">
|
||||
<div class="flex-1">
|
||||
{s.home.players.map((p) => (
|
||||
<div>{p ? `${p.firstName} ${p.lastName}` : "-"}</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
{s.sets.map((s) => (
|
||||
<ResultTile home={s?.homeGoals} away={s?.awayGoals} />
|
||||
))}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
{s.away.players.map((p) => (
|
||||
<div>{p ? `${p.firstName} ${p.lastName}` : "-"}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
import { For, Show } from "solid-js";
|
||||
import { LogoFallback as JerseyFallback } from "../components/LogoFallback.tsx";
|
||||
import { TableComparison } from "../components/TableComparison.tsx";
|
||||
import { SquadComparison } from "../components/SquadComparison.tsx";
|
||||
|
||||
export default function MatchDetailsFuture(props: { match: any }) {
|
||||
// Beispiel-Struktur für props.match:
|
||||
// { homeTeam: { name: "...", logoUrl: "..." }, awayTeam: { ... }, scheduledAt: "...", tournament: "Bundesliga", venue: "Allianz Arena" }
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 1. DER SHOWDOWN HEADER */}
|
||||
<div class="bg-gradient-to-b from-gray-800 to-gray-850 p-6 border-b border-gray-800 text-center relative sticky top-0 backdrop-blur-md z-21">
|
||||
<span class="text-xs font-bold uppercase tracking-widest text-emerald-400 block mb-4">
|
||||
{props.match.event.name}
|
||||
</span>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 my-2">
|
||||
{/* Home Team */}
|
||||
<div class="flex-1 flex flex-col items-center gap-2 min-w-0">
|
||||
<Show
|
||||
when={props.match.homeTeam.logoUrl}
|
||||
fallback={<JerseyFallback type="home" />}
|
||||
>
|
||||
<img
|
||||
src={props.match.homeTeam.logoUrl}
|
||||
class="size-14 object-contain"
|
||||
alt=""
|
||||
/>
|
||||
</Show>
|
||||
<span class="text-sm font-semibold text-gray-200 text-center line-clamp-2 leading-tight">
|
||||
{props.match.homeTeam.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Mittlerer Zeitblock */}
|
||||
<div class="flex flex-col items-center justify-center px-4 py-2 bg-gray-950/50 rounded-2xl border border-gray-800 min-w-[100px]">
|
||||
<span class="text-xs font-semibold text-emerald-400 tracking-wider">
|
||||
{new Date(props.match.scheduledAt).toLocaleDateString("de-DE", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
<span class="text-xl font-mono font-bold text-white mt-0.5">
|
||||
{new Date(props.match.scheduledAt).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
<span class="text-[10px] text-gray-500 uppercase tracking-widest font-bold mt-1">
|
||||
VS
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Away Team */}
|
||||
<div class="flex-1 flex flex-col items-center gap-2 min-w-0">
|
||||
<Show
|
||||
when={props.match.awayTeam.logoUrl}
|
||||
fallback={<JerseyFallback type="away" />}
|
||||
>
|
||||
<img
|
||||
src={props.match.awayTeam.logoUrl}
|
||||
class="size-14 object-contain"
|
||||
alt=""
|
||||
/>
|
||||
</Show>
|
||||
<span class="text-sm font-semibold text-gray-200 text-center line-clamp-2 leading-tight">
|
||||
{props.match.awayTeam.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location Info */}
|
||||
<p class="text-xs text-gray-500 mt-4 flex items-center justify-center gap-1">
|
||||
<svg
|
||||
class="size-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
{props.match.venue?.name || "Unbekannter Spielort"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* INHALT / ANALYSE SEKTIONEN */}
|
||||
<div class="p-4 space-y-6">
|
||||
{/* 2. PROGNOSE / VOTING BAR */}
|
||||
<div class="bg-gray-800/50 rounded-2xl p-4 border border-gray-800">
|
||||
<h3 class="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3 text-center">
|
||||
Wer gewinnt das Match?
|
||||
</h3>
|
||||
{/* Der 3-geteilte Balken */}
|
||||
<div class="h-3.5 w-full rounded-full overflow-hidden flex bg-gray-900 border border-gray-750">
|
||||
<div
|
||||
class="bg-emerald-500 h-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${props.match.prediction?.homeWinChance ?? 0}%`,
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
class="bg-gray-700 h-full transition-all duration-500"
|
||||
style={{ width: `${props.match.prediction?.drawChance ?? 0}%` }}
|
||||
></div>
|
||||
<div
|
||||
class="bg-blue-500 h-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${props.match.prediction?.awayWinChance ?? 0}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
{/* Legende / Prozentzahlen */}
|
||||
<div class="flex justify-between items-center mt-2.5 text-xs font-semibold px-0.5">
|
||||
<span class="text-emerald-400">
|
||||
Heim ({props.match.prediction?.homeWinChance}%)
|
||||
</span>
|
||||
<span class="text-gray-400">
|
||||
Remis ({props.match.prediction?.drawChance}%)
|
||||
</span>
|
||||
<span class="text-blue-400">
|
||||
Auswärts ({props.match.prediction?.awayWinChance}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{props.match.standing && (
|
||||
<TableComparison
|
||||
homeTeam={props.match.standing.home}
|
||||
awayTeam={props.match.standing.away}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 3. FORMKURVE (LAST 5 MATCHES) */}
|
||||
<div class="bg-gray-800/50 rounded-2xl p-4 border border-gray-800 space-y-4">
|
||||
<h3 class="text-xs font-bold uppercase tracking-wider text-gray-400 text-center">
|
||||
Aktuelle Formkurve
|
||||
</h3>
|
||||
<div class="flex items-center justify-between gap-2 px-2">
|
||||
{/* Form Team A */}
|
||||
<div class="flex gap-1">
|
||||
{props.match.form?.home.map((f) => (
|
||||
<FormBadge
|
||||
result={
|
||||
{
|
||||
WIN: "S",
|
||||
DRAW: "U",
|
||||
LOST: "N",
|
||||
}[f]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span class="text-[11px] uppercase tracking-widest font-bold text-gray-600">
|
||||
Form
|
||||
</span>
|
||||
{/* Form Team B */}
|
||||
<div class="flex gap-1">
|
||||
{props.match.form?.away.map((f) => (
|
||||
<FormBadge
|
||||
result={
|
||||
{
|
||||
WIN: "S",
|
||||
DRAW: "U",
|
||||
LOST: "N",
|
||||
}[f]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SquadComparison
|
||||
homeRosters={props.match.homeRosters}
|
||||
awayRosters={props.match.awayRosters}
|
||||
homeTeam={props.match.homeTeam}
|
||||
awayTeam={props.match.awayTeam}
|
||||
/>
|
||||
|
||||
<MatchInsights firstLeg={props.match.firstLeg} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormBadge(props: { result: "S" | "U" | "N" }) {
|
||||
const styles = () => {
|
||||
switch (props.result) {
|
||||
case "S": // Sieg
|
||||
return "bg-emerald-500/20 text-emerald-400 border-emerald-500/30";
|
||||
case "U": // Unentschieden
|
||||
return "bg-gray-700/40 text-gray-400 border-gray-600/30";
|
||||
case "N": // Niederlage
|
||||
return "bg-rose-500/20 text-rose-400 border-rose-500/30";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
class={`size-6 rounded-full flex items-center justify-center text-[11px] font-bold border ${styles()}`}
|
||||
>
|
||||
{props.result}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import {
|
||||
createMemo,
|
||||
For,
|
||||
Show,
|
||||
createEffect,
|
||||
createSignal,
|
||||
onMount,
|
||||
onCleanup,
|
||||
} from "solid-js";
|
||||
// import { useSearchParams } from "@solidjs/router";
|
||||
import { Header } from "../components/Header";
|
||||
import { DayTab } from "../components/DayTab";
|
||||
import { ListSkeleton } from "../components/ListSkeleton";
|
||||
import { MatchItem } from "../components/MatchItem";
|
||||
import { loadMatches, matchStore, type Match } from "../stores/matchStore";
|
||||
import { EmptyDay } from "../components/EmptyDay";
|
||||
|
||||
type MatchesByDay = Record<string, Match[]>;
|
||||
type EventGroup = {
|
||||
eventName: string;
|
||||
matches: Match[];
|
||||
};
|
||||
|
||||
const useSearchParams = () => {
|
||||
const [params, setParams] = createSignal(
|
||||
Object.fromEntries(new URLSearchParams(window.location.search).entries()),
|
||||
);
|
||||
return [
|
||||
params,
|
||||
(value) => {
|
||||
const current = new URL(window.location.href);
|
||||
|
||||
current.search = `?${new URLSearchParams(value).toString()}`;
|
||||
setParams(value);
|
||||
window.history.replaceState({}, "", current.toString());
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const _scrollposition = new Map<string, number>();
|
||||
export function OverviewPage() {
|
||||
const [offset, setOffset] = createSignal(0);
|
||||
const shouldAnimateOnEnter = matchStore.matches.length === 0;
|
||||
|
||||
const daysList = createMemo(() => {
|
||||
const list = [];
|
||||
for (let i = -7; i <= 7; i++) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + i);
|
||||
list.push({
|
||||
dateString: d.toLocaleDateString("en-ca"),
|
||||
weekday: d.toLocaleDateString("de-DE", { weekday: "short" }),
|
||||
dayNum: d.getDate(),
|
||||
isToday: i === 0,
|
||||
});
|
||||
}
|
||||
return list;
|
||||
});
|
||||
const matchesByDay = createMemo(() => {
|
||||
const grouped: MatchesByDay = {};
|
||||
|
||||
for (const match of matchStore.matches) {
|
||||
const dateKey = new Date(match.scheduledAt).toLocaleDateString("en-ca");
|
||||
if (!grouped[dateKey]) grouped[dateKey] = [];
|
||||
grouped[dateKey].push(match);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
});
|
||||
|
||||
let scrollContainerRef!: HTMLDivElement;
|
||||
let navContainerRef!: HTMLDivElement;
|
||||
let topContainerRef!: HTMLDivElement;
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ date: string }>();
|
||||
|
||||
const activeDate = () =>
|
||||
daysList().some((d) => d.dateString === searchParams().date)
|
||||
? searchParams().date!
|
||||
: new Date().toLocaleDateString("en-ca");
|
||||
let isScrolling: boolean | undefined;
|
||||
const scrollToDate = (dateString: string) => {
|
||||
isScrolling = false;
|
||||
setSearchParams({ date: dateString });
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const behavior = isScrolling === undefined ? "instant" : "smooth";
|
||||
const activeTabBtn = navContainerRef.querySelector(
|
||||
`[data-nav='${activeDate()}']`,
|
||||
);
|
||||
|
||||
if (activeTabBtn) {
|
||||
activeTabBtn.scrollIntoView({
|
||||
behavior,
|
||||
block: "nearest",
|
||||
inline: "center",
|
||||
});
|
||||
}
|
||||
|
||||
if (!isScrolling) {
|
||||
const targetEl = scrollContainerRef.querySelector(
|
||||
`[data-date='${activeDate()}']`,
|
||||
) as HTMLElement | null;
|
||||
if (targetEl) {
|
||||
targetEl.scrollIntoView({
|
||||
behavior,
|
||||
block: "nearest",
|
||||
inline: "start",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
loadMatches();
|
||||
if (!shouldAnimateOnEnter) {
|
||||
_scrollposition.forEach((top, selector) => {
|
||||
document.querySelector(selector).scrollTop = top;
|
||||
});
|
||||
}
|
||||
|
||||
const datelists = document.querySelectorAll("[data-date]");
|
||||
datelists.forEach((list) => {
|
||||
list.addEventListener("scrollend", (e) => {
|
||||
_scrollposition.set(
|
||||
`[data-date="${e.target.dataset.date}"]`,
|
||||
e.target.scrollTop,
|
||||
);
|
||||
});
|
||||
});
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!isScrolling) return;
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
const date = entry.target.getAttribute("data-date");
|
||||
if (date) {
|
||||
setSearchParams({ date: date }, { replace: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{ root: scrollContainerRef, threshold: 0.55 },
|
||||
);
|
||||
|
||||
setOffset(topContainerRef.offsetHeight);
|
||||
scrollContainerRef
|
||||
.querySelectorAll("[data-date]")
|
||||
.forEach((el) => observer.observe(el));
|
||||
|
||||
const handleScrollEnd = () => {
|
||||
isScrolling = true;
|
||||
};
|
||||
|
||||
scrollContainerRef.addEventListener("scrollend", handleScrollEnd);
|
||||
|
||||
onCleanup(() => {
|
||||
observer.disconnect();
|
||||
scrollContainerRef.removeEventListener("scrollend", handleScrollEnd);
|
||||
});
|
||||
isScrolling = true;
|
||||
});
|
||||
|
||||
const gamesByLeagueForDay = (dateString: string) => {
|
||||
const dayMatches = matchesByDay()[dateString] || [];
|
||||
return Object.values(
|
||||
dayMatches.reduce<Record<string, EventGroup>>((acc, match) => {
|
||||
const key = `${match.event.id}${match.event.matchday}`;
|
||||
if (!acc[key]) {
|
||||
acc[key] = {
|
||||
eventName: `${match.event.name} - ${match.event.matchday}`,
|
||||
matches: [],
|
||||
};
|
||||
}
|
||||
acc[key].matches.push(match);
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={topContainerRef}
|
||||
class="fixed w-full top-0 backdrop-blur-md z-21 bg-gradient-to-b from-gray-800 to-gray-850"
|
||||
>
|
||||
<div
|
||||
ref={navContainerRef}
|
||||
class="p-3 overflow-x-auto flex gap-2 scrollbar-none border-b border-gray-700/50 flex-none p-6 border-b border-gray-800 text-center"
|
||||
>
|
||||
<For each={daysList()}>
|
||||
{(day) => (
|
||||
<DayTab
|
||||
isActive={activeDate() === day.dateString}
|
||||
day={day}
|
||||
data-nav={day.dateString}
|
||||
onClick={() => scrollToDate(day.dateString)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={`px-4 py-2 bg-gray-800/40 border-b border-gray-800 flex justify-between items-center flex-none transition-colors duration-300 ${
|
||||
matchStore.isLoading
|
||||
? "bg-emerald-600/30 border-emerald-500/40 bg-striped-loading-dark"
|
||||
: "bg-gray-800/40 border-gray-800"
|
||||
}`}
|
||||
>
|
||||
<span class="text-xs font-semibold uppercase text-gray-400">
|
||||
{new Date(activeDate()).toLocaleDateString("de-DE", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
class="flex-1 flex overflow-x-auto snap-x snap-mandatory scrollbar-none scroll-smooth bg-gray-900"
|
||||
>
|
||||
<For each={daysList()}>
|
||||
{(day) => {
|
||||
const gamesForDay = () => gamesByLeagueForDay(day.dateString);
|
||||
const itemIndex = { current: 0 };
|
||||
return (
|
||||
<div
|
||||
data-date={day.dateString}
|
||||
class="w-full h-full shrink-0 snap-start snap-always p-4 overflow-y-auto space-y-3"
|
||||
style={`padding-top: calc(1rem + ${offset()}px)`}
|
||||
>
|
||||
<Show
|
||||
when={gamesForDay().length > 0}
|
||||
fallback={
|
||||
<Show
|
||||
when={!matchStore.isLoading}
|
||||
fallback={<ListSkeleton />}
|
||||
>
|
||||
<EmptyDay day={day} />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{((itemIndex.current = 0), null)}
|
||||
<div class="space-y-6">
|
||||
<For each={gamesForDay()}>
|
||||
{(event) => (
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
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" : ""}`}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
{/* sapce for event icon */}
|
||||
<span class="text-xs font-bold uppercase tracking-wider text-emeral-400">
|
||||
{event.eventName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2.5">
|
||||
<For each={event.matches}>
|
||||
{(game) => (
|
||||
<a
|
||||
style={{
|
||||
"--delay": `${itemIndex.current++ * 25}ms`,
|
||||
}}
|
||||
href={`/match/${game.id}`}
|
||||
class={`bg-gray-800 p-4 rounded-xl border border-gray-700/60 shadow-md flex ${
|
||||
shouldAnimateOnEnter
|
||||
? "animate-waterfall"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<MatchItem game={game} />
|
||||
</a>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { createStore, produce } from "solid-js/store";
|
||||
|
||||
export type Match = {
|
||||
id: string | number;
|
||||
scheduledAt: string;
|
||||
event: {
|
||||
id: string | number;
|
||||
name: string;
|
||||
matchday: string | number;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type MatchStoreState = {
|
||||
matches: Match[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
const [matchStore, setMatchStore] = createStore<MatchStoreState>({
|
||||
matches: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
export async function loadMatches() {
|
||||
if (matchStore.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMatchStore({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/v1/matches");
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load matches (${response.status})`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Match[];
|
||||
setMatchStore({ matches: data, isLoading: false, error: null });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
setMatchStore({ isLoading: false, error: message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadMatch(id: string) {
|
||||
try {
|
||||
const response = await fetch("/api/v1/matches/" + id);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load match ${id} (${response.status})`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Match;
|
||||
|
||||
patchMatch(id, data);
|
||||
|
||||
const stats = await fetch(`/api/v1/matches/${id}/stats`);
|
||||
if (!stats.ok) {
|
||||
throw new Error(`Failed to load match stats ${id} (${response.status})`);
|
||||
}
|
||||
patchMatch(id, (await stats.json()) as Match);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
setMatchStore({ error: message });
|
||||
}
|
||||
}
|
||||
|
||||
export function setMatch(updatedMatch: Match) {
|
||||
setMatchStore(
|
||||
"matches",
|
||||
produce((matches) => {
|
||||
const index = matches.findIndex((match) => match.id === updatedMatch.id);
|
||||
if (index === -1) {
|
||||
matches.push(updatedMatch);
|
||||
return;
|
||||
}
|
||||
matches[index] = updatedMatch;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function patchMatch(matchId: Match["id"], partialMatch: Partial<Match>) {
|
||||
setMatchStore(
|
||||
"matches",
|
||||
produce((matches) => {
|
||||
const index = matches.findIndex((match) => match.id === matchId);
|
||||
if (index === -1) {
|
||||
matches.push(partialMatch);
|
||||
return;
|
||||
}
|
||||
|
||||
matches[index] = {
|
||||
...matches[index],
|
||||
...partialMatch,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export { matchStore };
|
||||
Reference in New Issue
Block a user