made claude make me a ui that looks actually nice
This commit is contained in:
@@ -1,92 +1,102 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
// import { enhance } from '$app/forms';
|
||||
import Table from '$lib/ui/Table.svelte';
|
||||
import { onMount, onDestroy, tick } from 'svelte';
|
||||
|
||||
// Get initial data from the load thing (innacurate lol)
|
||||
let { data }: { data: import('./$types').PageData } = $props();
|
||||
let {
|
||||
data
|
||||
}: {
|
||||
data: import('./$types').PageData;
|
||||
focusEventId?: number | null;
|
||||
} = $props();
|
||||
|
||||
// Derived unordered
|
||||
let teams = $derived(data.teams.teams);
|
||||
|
||||
let eventTable = $derived(data.events.events);
|
||||
|
||||
//// Leaderboard Database logic
|
||||
// new event source for websocket
|
||||
let scoreEndpoint: EventSource;
|
||||
let eventEndpoint: EventSource;
|
||||
|
||||
onMount(() => {
|
||||
// get endpoint
|
||||
scoreEndpoint = new EventSource('/api/teams');
|
||||
// Ref map for event card DOM nodes
|
||||
let eventRefs = $state<Record<number, HTMLElement>>({});
|
||||
|
||||
// when you get a message do something
|
||||
onMount(() => {
|
||||
scoreEndpoint = new EventSource('/api/teams');
|
||||
scoreEndpoint.onmessage = (e) => {
|
||||
const teamsData = JSON.parse(e.data);
|
||||
// If the message has a teams object update the score thing
|
||||
if (teamsData['teams']) {
|
||||
teams = teamsData['teams'];
|
||||
console.log('teams updated');
|
||||
}
|
||||
if (teamsData['teams']) teams = teamsData['teams'];
|
||||
};
|
||||
// Player endpoint
|
||||
eventEndpoint = new EventSource('/api/registeredEvents');
|
||||
|
||||
eventEndpoint.onmessage = (e) => {
|
||||
const eventData = JSON.parse(e.data);
|
||||
console.log(eventData);
|
||||
eventTable = eventData;
|
||||
eventTable = JSON.parse(e.data);
|
||||
console.log(eventTable);
|
||||
};
|
||||
});
|
||||
|
||||
// When window destroyed close the websocket connection
|
||||
onDestroy(() => scoreEndpoint?.close());
|
||||
onDestroy(() => {
|
||||
scoreEndpoint?.close();
|
||||
eventEndpoint?.close();
|
||||
});
|
||||
|
||||
// Order leaderboard so that its displayed correctly
|
||||
let leaderboard = $derived([...teams].sort((a, b) => b.points - a.points));
|
||||
let focusEventId = $derived([...eventTable].reverse().find((e) => e.state == 1)?.id ?? null);
|
||||
|
||||
function ordinal(n: number) {
|
||||
const s = ['th', 'st', 'nd', 'rd'];
|
||||
const v = n % 100;
|
||||
return n + (s[(v - 20) % 10] ?? s[v] ?? s[0]);
|
||||
}
|
||||
|
||||
// When focusEventId changes, scroll to and highlight that event card
|
||||
// TODO make this scrolling shit work idk
|
||||
$effect(() => {
|
||||
if (focusEventId == null) return;
|
||||
tick().then(() => {
|
||||
const el = eventRefs[focusEventId!];
|
||||
const container = el?.closest('.events-scroll') as HTMLElement;
|
||||
if (el && container) {
|
||||
const elMid = el.offsetTop + el.offsetHeight / 2;
|
||||
const targetScroll = elMid - container.clientHeight / 2 - 60;
|
||||
container.scrollTo({ top: targetScroll, behavior: 'smooth' });
|
||||
el.classList.remove('highlight-pulse');
|
||||
void el.offsetWidth;
|
||||
el.classList.add('highlight-pulse');
|
||||
}
|
||||
});
|
||||
});
|
||||
// Svelte action: measures text overflow and drives CSS marquee
|
||||
function marquee(node: HTMLElement) {
|
||||
function measure() {
|
||||
const inner = node.querySelector<HTMLElement>('.marquee-inner');
|
||||
if (!inner) return;
|
||||
const overflow = inner.scrollWidth - node.clientWidth;
|
||||
if (overflow > 2) {
|
||||
node.style.setProperty('--scroll-dist', `-${overflow + 6}px`);
|
||||
inner.classList.add('scrolling');
|
||||
} else {
|
||||
inner.classList.remove('scrolling');
|
||||
}
|
||||
}
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(node);
|
||||
return { destroy: () => ro.disconnect() };
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet header()}
|
||||
<th>Event</th>
|
||||
<th>Division</th>
|
||||
<th>Players</th>
|
||||
{/snippet}
|
||||
|
||||
{#snippet row(d: any)}
|
||||
<td>{d.name}</td>
|
||||
<td>{d.division}</td>
|
||||
<td>
|
||||
<div class="">
|
||||
{#each d.registeredPlayers as bracket}
|
||||
<div class="flex justify-center">
|
||||
{#each bracket.items as player}
|
||||
<div
|
||||
style="--theme-color: {player.teamColor};"
|
||||
class="player-box w-min-0 m-1 flex-1 flex-col rounded-md border-2"
|
||||
>
|
||||
<div>{player.firstName} {player.lastName}</div>
|
||||
<div></div>
|
||||
{#if player.placement != 0}
|
||||
<div>{player.placement}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</td>
|
||||
{/snippet}
|
||||
|
||||
<svelte:window onbeforeunload={() => scoreEndpoint?.close()} />
|
||||
|
||||
<div class="flex max-h-[150vh] flex-col object-contain p-[2vw]">
|
||||
{#each leaderboard as team (team.name)}
|
||||
<div
|
||||
style="--theme-color: {team.color};"
|
||||
class="score-box mx-[10vw] mb-2 grid aspect-3/1 min-h-0 min-w-[70vw] flex-1 grid-cols-1 grid-rows-1 overflow-hidden rounded-2xl border-5 *:col-span-full *:row-end-[-1] *:flex *:items-center *:justify-center first:aspect-2/1"
|
||||
<div class="page">
|
||||
<!-- ═══════════ LEADERBOARD ═══════════ -->
|
||||
<section class="leaderboard">
|
||||
<!-- Winner — always full-width -->
|
||||
{#if leaderboard[0]}
|
||||
{@const team = leaderboard[0]}
|
||||
<a
|
||||
href="/team/{team.id}"
|
||||
class="score-box winner"
|
||||
style="--c:{team.color}"
|
||||
aria-label="{team.name}, 1st place, {team.points} points"
|
||||
>
|
||||
<div class="black-ops-one-regular @container uppercase opacity-60">
|
||||
<svg viewBox="0 0.1 100 0.6" preserveAspectRatio="none" class="h-full w-full fill-current">
|
||||
<div class="score-ghost" aria-hidden="true">
|
||||
<svg viewBox="0 0.1 100 0.6" preserveAspectRatio="none" class="ghost-svg">
|
||||
<text
|
||||
x="0"
|
||||
y="0.7"
|
||||
@@ -94,49 +104,442 @@
|
||||
dominant-baseline="auto"
|
||||
textLength="100"
|
||||
lengthAdjust="spacingAndGlyphs"
|
||||
class="goldman-bold"
|
||||
font-family="'Black Ops One',system-ui">{team.name}</text
|
||||
>
|
||||
{team.name}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-[20cqh]">
|
||||
<p>{team.points.toString().padStart(3, '0')}</p>
|
||||
<div class="score-fg">
|
||||
<div class="score-meta">
|
||||
<span class="score-rank">1st place</span>
|
||||
<span class="score-name goldman">{team.name}</span>
|
||||
</div>
|
||||
<span class="score-pts goldman">{team.points.toString().padStart(3, '0')}</span>
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<!-- Runners-up: stretch to fill row, max 5 wide, 1 col on small -->
|
||||
{#if leaderboard.length > 1}
|
||||
<div class="runners-grid" style="--runner-count:{Math.min(leaderboard.length - 1, 5)}">
|
||||
{#each leaderboard.slice(1) as team, i (team.name)}
|
||||
<a
|
||||
href="/team/{team.id}"
|
||||
class="score-box runner"
|
||||
style="--c:{team.color}"
|
||||
aria-label="{team.name}, {ordinal(i + 2)} place, {team.points} points"
|
||||
>
|
||||
<div class="score-ghost" aria-hidden="true">
|
||||
<svg viewBox="0 0.1 100 0.6" preserveAspectRatio="none" class="ghost-svg">
|
||||
<text
|
||||
x="0"
|
||||
y="0.7"
|
||||
font-size="1"
|
||||
dominant-baseline="auto"
|
||||
textLength="100"
|
||||
lengthAdjust="spacingAndGlyphs"
|
||||
font-family="'Black Ops One',system-ui">{team.name}</text
|
||||
>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="score-fg">
|
||||
<div class="score-meta">
|
||||
<span class="score-rank">{ordinal(i + 2)}</span>
|
||||
<span class="score-name goldman">{team.name}</span>
|
||||
</div>
|
||||
<span class="score-pts goldman">{team.points.toString().padStart(3, '0')}</span>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- ═══════════ EVENTS TABLE ═══════════ -->
|
||||
<p class="section-label">Events</p>
|
||||
|
||||
<section class="events-scroll">
|
||||
<div class="events">
|
||||
{#each eventTable as event (event.id)}
|
||||
<div class="event-card" bind:this={eventRefs[event.id]}>
|
||||
<div class="event-header">
|
||||
<a href="/event/{event.id}" class="event-name goldman">{event.name}</a>
|
||||
<span class="event-division">{event.division}</span>
|
||||
</div>
|
||||
|
||||
<div class="brackets">
|
||||
{#each event.registeredPlayers as bracket, bi}
|
||||
{#if bi > 0}
|
||||
<div class="bracket-sep" aria-hidden="true"></div>
|
||||
{/if}
|
||||
<div class="bracket-row">
|
||||
{#each bracket.items as player}
|
||||
<div class="player-box" style="--c:{player.teamColor}">
|
||||
<div class="player-ghost" aria-hidden="true">
|
||||
<svg viewBox="0 0.1 100 0.6" preserveAspectRatio="none" class="ghost-svg">
|
||||
<text
|
||||
x="0"
|
||||
y="0.7"
|
||||
font-size="1"
|
||||
dominant-baseline="auto"
|
||||
textLength="100"
|
||||
lengthAdjust="spacingAndGlyphs"
|
||||
font-family="'Black Ops One',system-ui">{player.firstName}</text
|
||||
>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="player-name-wrap" use:marquee>
|
||||
<span class="marquee-inner">
|
||||
{player.firstName}
|
||||
{player.lastName}
|
||||
</span>
|
||||
</div>
|
||||
{#if player.placement !== 0}
|
||||
<div class="player-placement goldman">{ordinal(player.placement)}</div>
|
||||
{:else}
|
||||
<div class="player-placement-gap"></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={() =>
|
||||
// Onclick send a request to the post endpoint
|
||||
fetch('/api/teams', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})}
|
||||
>
|
||||
Send update
|
||||
</button>
|
||||
|
||||
<Table data={eventTable} maxHeight="500px" focusId="20" {header} {row} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@import url('https://cdn.jsdelivr.net/npm/@catppuccin/palette/css/catppuccin.css');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Black+Ops+One&display=swap');
|
||||
|
||||
.black-ops-one-regular {
|
||||
:global(html),
|
||||
:global(body) {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
:global(body > div), /* SvelteKit's root wrapper */
|
||||
:global(#svelte) {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 150vh;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
.goldman {
|
||||
font-family: 'Black Ops One', system-ui;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* ── Leaderboard ── */
|
||||
.leaderboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px 14px 6px;
|
||||
}
|
||||
|
||||
.score-box {
|
||||
color: var(--theme-color);
|
||||
border-color: var(--theme-color);
|
||||
background-color: color-mix(in srgb, var(--theme-color), transparent 90%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 14px;
|
||||
border: 2px solid var(--c);
|
||||
color: var(--c);
|
||||
background: color-mix(in srgb, var(--c) 10%, transparent);
|
||||
/* Grid stacking: ghost + fg share the same cell */
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
text-decoration: none;
|
||||
transition: filter 0.15s ease;
|
||||
}
|
||||
.score-box:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
.score-box > * {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.winner {
|
||||
aspect-ratio: 2 / 1;
|
||||
border-width: 3px;
|
||||
min-height: 80px;
|
||||
}
|
||||
.runner {
|
||||
aspect-ratio: 4 / 1;
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
/* Ghost SVG: fill the cell edge-to-edge, text flush to bottom */
|
||||
.score-ghost {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.18;
|
||||
fill: currentColor;
|
||||
}
|
||||
.ghost-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Foreground */
|
||||
.score-fg {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 14px;
|
||||
gap: 8px;
|
||||
}
|
||||
.score-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.score-rank {
|
||||
font-size: 10px;
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.5;
|
||||
line-height: 1;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.score-name {
|
||||
font-size: clamp(11px, 3vw, 20px);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.score-pts {
|
||||
font-size: clamp(20px, 5.5vw, 40px);
|
||||
letter-spacing: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.winner .score-name {
|
||||
font-size: clamp(16px, 5vw, 32px);
|
||||
}
|
||||
.winner .score-pts {
|
||||
font-size: clamp(26px, 7.5vw, 52px);
|
||||
}
|
||||
|
||||
/*
|
||||
* Runners-up grid:
|
||||
* - 1 column on small screens (<480px)
|
||||
* - 2 columns on medium (480–699px)
|
||||
* - 4 columns on large (≥700px), max 5
|
||||
* --runner-count drives the actual column count on large screens
|
||||
* so fewer than 5 teams still fill the whole row.
|
||||
*/
|
||||
.runners-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@media (min-width: 480px) {
|
||||
.runners-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.winner {
|
||||
aspect-ratio: 2 / 1;
|
||||
}
|
||||
|
||||
.runner {
|
||||
aspect-ratio: 2 / 1;
|
||||
}
|
||||
}
|
||||
@media (min-width: 700px) {
|
||||
.runners-grid {
|
||||
/* clamp actual count to 4 on large, but use --runner-count
|
||||
so e.g. 2 teams still fill 2 equal columns not 2 of 4 */
|
||||
grid-template-columns: repeat(min(var(--runner-count), 4), 1fr);
|
||||
}
|
||||
.winner {
|
||||
aspect-ratio: 3 / 1;
|
||||
}
|
||||
.runner {
|
||||
aspect-ratio: 3 / 2;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Section label ── */
|
||||
.section-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 2.5px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.4;
|
||||
padding: 12px 16px 4px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Events scrollable container ── */
|
||||
.events-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
/* max-height: 900px; */
|
||||
min-height: 0;
|
||||
padding: 0 14px 24px;
|
||||
/* Thin custom scrollbar */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, currentColor 30%, transparent) transparent;
|
||||
}
|
||||
.events {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ── Event card ── */
|
||||
.event-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
/* Focus highlight pulse — added/removed by $effect */
|
||||
.event-card.highlight-pulse {
|
||||
animation: card-pulse 1.2s ease-out forwards;
|
||||
}
|
||||
@keyframes card-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 3px currentColor;
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0px currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.event-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 9px 13px 7px;
|
||||
border-bottom: 1px solid color-mix(in srgb, currentColor 10%, transparent);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.event-name {
|
||||
font-size: 15px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.event-name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.event-division {
|
||||
font-size: 10px;
|
||||
letter-spacing: 1.2px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* ── Brackets ── */
|
||||
.brackets {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.bracket-sep {
|
||||
height: 1px;
|
||||
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||
margin: 0 10px;
|
||||
}
|
||||
.bracket-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 7px 9px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── Player boxes ── */
|
||||
.player-box {
|
||||
flex: 1 1 0; /* equal widths, no min-content bias */
|
||||
max-width: 160px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
border: 1.5px solid var(--c);
|
||||
color: var(--c);
|
||||
background: color-mix(in srgb, var(--c) 10%, transparent);
|
||||
padding: 5px 7px 5px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
/* 1 col on small screens, 4 across on large */
|
||||
@media (max-width: 479px) {
|
||||
.bracket-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
.player-box {
|
||||
color: var(--theme-color);
|
||||
border-color: var(--theme-color);
|
||||
background-color: color-mix(in srgb, var(--theme-color), transparent 90%);
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
@media (min-width: 700px) {
|
||||
.player-box {
|
||||
max-width: calc(25% - 6px);
|
||||
} /* 4 per row */
|
||||
}
|
||||
|
||||
.player-ghost {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.15;
|
||||
fill: currentColor;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ghost-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.player-name-wrap {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.marquee-inner {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.marquee-inner.scrolling {
|
||||
animation: marquee-scroll 7s ease-in-out infinite;
|
||||
}
|
||||
@keyframes marquee-scroll {
|
||||
0%,
|
||||
20% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
70%,
|
||||
90% {
|
||||
transform: translateX(var(--scroll-dist, 0px));
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.player-placement {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 17px;
|
||||
line-height: 1.1;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.player-placement-gap {
|
||||
height: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user