Compare commits

...

31 Commits

Author SHA1 Message Date
62af341a1e very important changes 2026-07-03 11:10:01 +01:00
6019d67145 amended todo so that I have a plan 2026-07-03 08:07:17 +01:00
b7e060441c added unit to scoring page 2026-07-01 17:54:52 +01:00
3b964c4d9c player scores working yayyyy 2026-07-01 17:52:32 +01:00
d66caee7fd added TODO so that i know what im doing 2026-07-01 09:27:35 +01:00
c5473fec5c fixed login and logout and started on player screen 2026-06-30 17:11:37 +01:00
201821d53c had opencode clean up all my nonsense comments too 2026-06-29 15:11:53 +01:00
3be0033a32 opencode fixed my connection errors :) 2026-06-29 14:46:41 +01:00
07692fe0bd holy shit i can score an event properly!!!! this is awesome 2026-06-29 14:40:02 +01:00
7ae5b2fbbc ignored .devenv 2026-06-29 13:37:12 +01:00
ed98690bb6 holy shit i can start events now 2026-06-25 13:12:27 +01:00
2f3005ba2a some changed 2026-06-25 12:39:58 +01:00
fbc181c890 added login with pages and database, also correct errors in score submit 2026-06-22 13:36:34 +01:00
192564fb79 moved to devenv 2026-06-22 12:07:31 +01:00
72c93e88c1 scoring works omg 2026-06-10 14:26:59 +01:00
069e6cd22c the scoring page is basically done, i cant wait for the backend 2026-06-03 17:41:09 +01:00
d1abc83074 wow a scoring page! sure hope that the endpoint exists... oh 2026-05-30 17:29:18 +01:00
10010631f5 added a header so i can navigate 2026-05-30 17:29:02 +01:00
a44ee668c8 i cant read, and it punishes me 2026-05-30 17:28:46 +01:00
685fba8c71 made the scrolling work 2026-05-30 16:14:53 +01:00
83d9b78d2b scorers page about to get a claude makeover 2026-05-30 16:14:43 +01:00
7d16e45e4a added the winner to events from database, and then made that available through dbm 2026-05-30 16:14:22 +01:00
95fac2070a added seeding the winner 2026-05-30 16:13:50 +01:00
521f5b5a46 added finished event for testing 2026-05-30 16:13:46 +01:00
99a29cc92b moved all of the css to global because why not 2026-05-28 16:54:35 +01:00
cce45fc57e started on events view, looks shit so far 2026-05-26 13:08:36 +01:00
a0d91333ae made claude make me a ui that looks actually nice 2026-05-26 13:08:20 +01:00
102af95084 moved global emitter to new file, streamlined registeredEvents again plus comments 2026-05-26 13:07:38 +01:00
59839d79aa realised that caching was pointless, made getResultPresets return all presets if there is no preset specified 2026-05-26 13:06:57 +01:00
a6839c268b changed seed data for testing 2026-05-26 13:06:26 +01:00
fa0827afe7 moved the emitter to its own file 2026-05-26 11:37:39 +01:00
52 changed files with 2264 additions and 444 deletions

2
.gitignore vendored
View File

@@ -26,3 +26,5 @@ vite.config.ts.timestamp-*
.session
local.db
/.devenv
/.devenv

36
TODO.md Normal file
View File

@@ -0,0 +1,36 @@
# UI - Medium priority
player view - all registered events and scores for those events
team view - all registered players and all points gained from them - Do i need this?
# Login stuff - HIGH PRIORITY
disableable register button on the login page for end users
make a way to toggle on and off the register page
protect the register endpoint?? (low prio)
# Ledger - HIGH PRIORITY
ledger view
inserting manually into ledger
# Database - HIGH PRIORITY
make events score based off of highest score instead of avg
load results from the excel sheet i made
export whole contest results to csv
# OPTIONAL (low priority)
chat thing
protect all the endpoints?????? (pain in the ass)
> probably need just in case, seems possible with one endpoint/interface
## Non-seeded data
manual registering of teams
live editing of event/score presets
manual registering of events
manual registering of players
result amendments
## Easy shit
animations for all leaderboards
Ongoing since... or ongoing for... on the main page
displaying averages of loaded scores when there's more than one (easy, low prio)
fix the layout on event view to match the homepage view
fix the marquee animation thing and make it global

View File

@@ -6,6 +6,8 @@
"name": "score-system",
"dependencies": {
"@catppuccin/tailwindcss": "^1.0.0",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"csv-parse": "^6.2.1",
"prettier-plugin-svelte": "^3.5.1",
},
@@ -20,7 +22,7 @@
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.2",
"@types/bun": "^1.3.13",
"@types/bun": "^1.3.14",
"@types/node": "^22",
"drizzle-kit": "^0.31.10",
"drizzle-orm": "^0.45.2",
@@ -31,7 +33,7 @@
"svelte": "^5.55.2",
"svelte-check": "^4.4.6",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
"typescript": "^6.0.3",
"typescript-eslint": "^8.58.1",
"vite": "^8.0.7",
},
@@ -266,6 +268,14 @@
"@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
"@oslojs/binary": ["@oslojs/binary@1.0.0", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="],
"@oslojs/crypto": ["@oslojs/crypto@1.0.1", "", { "dependencies": { "@oslojs/asn1": "1.0.0", "@oslojs/binary": "1.0.0" } }, "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ=="],
"@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
"@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
@@ -350,7 +360,7 @@
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
@@ -422,7 +432,7 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],

65
devenv.lock Normal file
View File

@@ -0,0 +1,65 @@
{
"nodes": {
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1781981587,
"narHash": "sha256-xkBPfggcaDdbBl4fhHnhgVv2XPmzM2CtNBCpSwlK4fY=",
"owner": "cachix",
"repo": "devenv",
"rev": "1ad4a4a03466826fc43127211c341d268efab21b",
"type": "github"
},
"original": {
"dir": "src/modules",
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"nixpkgs": {
"inputs": {
"nixpkgs-src": "nixpkgs-src"
},
"locked": {
"lastModified": 1781620901,
"narHash": "sha256-UF6scQlG+6lRkZBUpn/3KNavhOo5G8kDWhjVHcno8uc=",
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "2df109b343d3c68efd752e32a444a1d9b9f89afa",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "rolling",
"repo": "devenv-nixpkgs",
"type": "github"
}
},
"nixpkgs-src": {
"flake": false,
"locked": {
"lastModified": 1781454065,
"narHash": "sha256-d2xfDjnfRuf/xYGdu9VVRHiav/2w5hDL/5cw2TuVAXw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9eac87a12312b8f60dd52e1c6e1a265f6fc7f5fc",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

15
devenv.nix Normal file
View File

@@ -0,0 +1,15 @@
{ pkgs, config, ... }: {
languages.typescript.enable = true;
dotenv.disableHint = true;
packages = with pkgs; [
bun
eslint_d
];
env.DEVSHELL_NAME = "󰏖 devenv/#fab387| Bun/yellow";
processes = {
server = {
ports.http.allocate = 5173;
exec = "bun --bun run dev";
};
};
}

0
devenv.yaml Normal file
View File

View File

@@ -20,8 +20,6 @@ export default defineConfig(
{
languageOptions: { globals: { ...globals.browser, ...globals.node } },
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
}
},
@@ -37,8 +35,6 @@ export default defineConfig(
}
},
{
// Override or add rule settings here, such as:
// 'svelte/button-has-type': 'error'
rules: {}
}
);

61
flake.lock generated
View File

@@ -1,61 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1777578337,
"narHash": "sha256-Ad49moKWeXtKBJNy2ebiTQUEgdLyvGmTeykAQ9xM+Z4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "15f4ee454b1dce334612fa6843b3e05cf546efab",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"utils": "utils"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View File

@@ -1,42 +0,0 @@
{
description = "A simple Rust development environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
utils.url = "github:numtide/flake-utils";
};
outputs =
{
self,
nixpkgs,
utils,
}:
utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs { inherit system; };
in
{
devShells.default = pkgs.mkShell {
name = "flake-bunshell";
buildInputs = with pkgs; [
bun
eslint_d
];
# Environment variables
shellHook = ''
export DEVSHELL_NAME="󱄅 flake/#89dceb| Bun/yellow"
# Trigger zsh
if [[ -z "$ZSH_VERSION" ]]; then
exec zsh
fi
'';
};
}
);
}

View File

@@ -30,7 +30,7 @@
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.2",
"@types/bun": "^1.3.13",
"@types/bun": "^1.3.14",
"@types/node": "^22",
"drizzle-kit": "^0.31.10",
"drizzle-orm": "^0.45.2",
@@ -41,12 +41,14 @@
"svelte": "^5.55.2",
"svelte-check": "^4.4.6",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
"typescript": "^6.0.3",
"typescript-eslint": "^8.58.1",
"vite": "^8.0.7"
},
"dependencies": {
"@catppuccin/tailwindcss": "^1.0.0",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"csv-parse": "^6.2.1",
"prettier-plugin-svelte": "^3.5.1"
}

View File

@@ -4,7 +4,8 @@ event_name,preset,resultPreset
500m Relay,1,race
750m Race,1,race
1000m Long Distance,1,race
Javelin, 2,default
Shotput, 2,default
Long Jump, 2,three
Triple Jump, 2,default
Javelin, 1,three
Shotput, 1,three
Long Jump, 1,three
High Jump, 1,three
Triple Jump, 1,three
1 event_name preset resultPreset
4 500m Relay 1 race
5 750m Race 1 race
6 1000m Long Distance 1 race
7 Javelin 2 1 default three
8 Shotput 2 1 default three
9 Long Jump 2 1 three
10 Triple Jump High Jump 2 1 default three
11 Triple Jump 1 three

View File

@@ -1,37 +1,37 @@
event_type,division,event_state,time_completed
100m Sprint,Year 7,0,
100m Sprint,Year 8,0,
100m Sprint,Year 9,0,
100m Sprint,Year 10,0,
200m Sprint,Year 7,0,
200m Sprint,Year 8,0,
200m Sprint,Year 9,0,
200m Sprint,Year 10,0,
500m Relay,Year 7,0,
500m Relay,Year 8,0,
500m Relay,Year 9,0,
500m Relay,Year 10,0,
750m Race,Year 7,0,
750m Race,Year 8,0,
750m Race,Year 9,0,
750m Race,Year 10,0,
1000m Long Distance,Year 7,0,
1000m Long Distance,Year 8,0,
1000m Long Distance,Year 9,0,
1000m Long Distance,Year 10,0,
Javelin,Year 7,0,
Javelin,Year 8,0,
Javelin,Year 9,0,
Javelin,Year 10,0,
Shotput,Year 7,0,
Shotput,Year 8,0,
Shotput,Year 9,0,
Shotput,Year 10,0,
Long Jump,Year 7,0,
Long Jump,Year 8,0,
Long Jump,Year 9,0,
Long Jump,Year 10,0,
Triple Jump,Year 7,0,
Triple Jump,Year 8,0,
Triple Jump,Year 9,0,
Triple Jump,Year 10,0,
event_type,division,event_state,winner,time_completed
100m Sprint,Year 7,0,,
100m Sprint,Year 8,0,,
100m Sprint,Year 9,0,,
100m Sprint,Year 10,0,,
200m Sprint,Year 7,0,,
200m Sprint,Year 8,0,,
200m Sprint,Year 9,0,,
200m Sprint,Year 10,0,,
500m Relay,Year 7,0,,
500m Relay,Year 8,0,,
500m Relay,Year 9,0,,
500m Relay,Year 10,0,,
750m Race,Year 7,0,,
750m Race,Year 8,0,,
750m Race,Year 9,0,,
750m Race,Year 10,0,,
1000m Long Distance,Year 7,0,,
1000m Long Distance,Year 8,0,,
1000m Long Distance,Year 9,0,,
1000m Long Distance,Year 10,0,,
Javelin,Year 7,0,,
Javelin,Year 8,0,,
Javelin,Year 9,0,,
Javelin,Year 10,0,,
Shotput,Year 7,0,,
Shotput,Year 8,0,,
Shotput,Year 9,0,,
Shotput,Year 10,0,,
Long Jump,Year 7,0,,
Long Jump,Year 8,0,,
Long Jump,Year 9,0,,
Long Jump,Year 10,0,,
Triple Jump,Year 7,0,,
Triple Jump,Year 8,0,,
Triple Jump,Year 9,0,,
Triple Jump,Year 10,0,,
1 event_type division event_state winner time_completed
2 100m Sprint Year 7 0
3 100m Sprint Year 8 0
4 100m Sprint Year 9 0
5 100m Sprint Year 10 0
6 200m Sprint Year 7 0
7 200m Sprint Year 8 0
8 200m Sprint Year 9 0
9 200m Sprint Year 10 0
10 500m Relay Year 7 0
11 500m Relay Year 8 0
12 500m Relay Year 9 0
13 500m Relay Year 10 0
14 750m Race Year 7 0
15 750m Race Year 8 0
16 750m Race Year 9 0
17 750m Race Year 10 0
18 1000m Long Distance Year 7 0
19 1000m Long Distance Year 8 0
20 1000m Long Distance Year 9 0
21 1000m Long Distance Year 10 0
22 Javelin Year 7 0
23 Javelin Year 8 0
24 Javelin Year 9 0
25 Javelin Year 10 0
26 Shotput Year 7 0
27 Shotput Year 8 0
28 Shotput Year 9 0
29 Shotput Year 10 0
30 Long Jump Year 7 0
31 Long Jump Year 8 0
32 Long Jump Year 9 0
33 Long Jump Year 10 0
34 Triple Jump Year 7 0
35 Triple Jump Year 8 0
36 Triple Jump Year 9 0
37 Triple Jump Year 10 0

View File

@@ -59,11 +59,11 @@ Dante Sorensen,1000m Long Distance,0,C
Mia Nguyen,1000m Long Distance,0,C
Oluseun Alade,1000m Long Distance,0,C
Tobias Crane,1000m Long Distance,0,C
Alaric Olawale,100m Sprint,1,A
Ava Mackenzie,100m Sprint,2,A
Bjorn Oyelaran,100m Sprint,3,A
Jack Simmons,100m Sprint,4,A
Kai Johansson,100m Sprint,5,A
Alaric Olawale,100m Sprint,0,A
Ava Mackenzie,100m Sprint,0,A
Bjorn Oyelaran,100m Sprint,0,A
Jack Simmons,100m Sprint,0,A
Kai Johansson,100m Sprint,0,A
Clark Kent,100m Sprint,0,B
Cormac Halvorsen,100m Sprint,0,B
Marcus Okafor,100m Sprint,0,B
1 player_registered event_registered player_placement bracket
59 Mia Nguyen 1000m Long Distance 0 C
60 Oluseun Alade 1000m Long Distance 0 C
61 Tobias Crane 1000m Long Distance 0 C
62 Alaric Olawale 100m Sprint 1 0 A
63 Ava Mackenzie 100m Sprint 2 0 A
64 Bjorn Oyelaran 100m Sprint 3 0 A
65 Jack Simmons 100m Sprint 4 0 A
66 Kai Johansson 100m Sprint 5 0 A
67 Clark Kent 100m Sprint 0 B
68 Cormac Halvorsen 100m Sprint 0 B
69 Marcus Okafor 100m Sprint 0 B

View File

@@ -1,7 +1,6 @@
preset,placement,points
1,1,10
1,2,7
1,3,5
2,1,50
2,2,30
2,3,10
1,1,5
1,2,4
1,3,3
1,4,2
1,5,1
1 preset placement points
2 1 1 10 5
3 1 2 7 4
4 1 3 5 3
5 2 1 1 4 50 2
6 2 1 2 5 30 1
2 3 10

View File

@@ -28,7 +28,7 @@ function readCSV(filename: string): Record<string, any>[] {
async function seed() {
console.log('Resetting database...');
// Disable foreign keys globally during setup to prevent structural mismatches
// Temporarily disable FK checks during reset
await client.execute('PRAGMA foreign_keys = OFF');
await db.delete(schema.scoreLedger);
@@ -43,33 +43,45 @@ async function seed() {
await db.delete(schema.players);
await db.delete(schema.divisions);
await db.delete(schema.teams);
await db.delete(schema.scorers);
await db.delete(schema.sessions);
await client.execute('DELETE FROM sqlite_sequence');
console.log('Database reset complete. Seeding...');
// --- 1. Teams ---
let passwordHash = await Bun.password.hash('password');
await db
.insert(schema.scorers)
.values({
id: crypto.randomUUID(),
username: 'admin',
role: 'admin',
passwordHash: passwordHash
});
// Seed teams
const teamsCSV = readCSV('teams.csv');
for (const row of teamsCSV) {
await db.insert(schema.teams).values({ name: row.team_name, color: row.color });
console.log(` → Team: ${row.team_name} (${row.color})`);
}
// --- 2. Divisions ---
// Seed divisions
const divisionsCSV = readCSV('divisions.csv');
for (const row of divisionsCSV) {
await db.insert(schema.divisions).values({ name: row.div_name });
console.log(` → Division: ${row.div_name}`);
}
// --- 2.5 Brackets (Added Section) ---
// Seed brackets
const bracketsCSV = readCSV('brackets.csv');
for (const row of bracketsCSV) {
await db.insert(schema.brackets).values({ name: row.bracket_name });
console.log(` → Bracket: ${row.bracket_name}`);
}
// --- 2.75 resultPresets ---
// Seed result presets
const resultPresetsCSV = readCSV('resultPresets.csv');
for (const row of resultPresetsCSV) {
await db.insert(schema.resultPresets).values({
@@ -84,7 +96,7 @@ async function seed() {
);
}
// --- 3. Scoring Presets ---
// Seed scoring presets
const scoringPresetsCSV = readCSV('scoringPresets.csv');
for (const row of scoringPresetsCSV) {
await db.insert(schema.scoringPresets).values({
@@ -95,19 +107,19 @@ async function seed() {
console.log(` → Preset ${row.preset}: placement ${row.placement} = ${row.points}pts`);
}
// Maps for dynamic relational lookups
// Build lookup maps for relational seeding
const dbTeams = await db.select().from(schema.teams);
const dbDivisions = await db.select().from(schema.divisions);
const dbResults = await db.select().from(schema.resultPresets);
const dbBrackets = await db.select().from(schema.brackets); // Look up newly seeded brackets
const dbBrackets = await db.select().from(schema.brackets);
const teamMap = new Map(dbTeams.map((t) => [t.name, t.id]));
const divisionMap = new Map(dbDivisions.map((d) => [d.name, d.id]));
const divisionNameMap = new Map([...divisionMap.entries()].map(([name, id]) => [id, name]));
const bracketMap = new Map(dbBrackets.map((b) => [b.name, b.id])); // Map names to IDs
const resultPresetMap = new Map(dbResults.map((b) => [b.presetName, b.id])); // Map names to IDs
const bracketMap = new Map(dbBrackets.map((b) => [b.name, b.id]));
const resultPresetMap = new Map(dbResults.map((b) => [b.presetName, b.id]));
// --- 4. Players ---
// Seed players
const playersCSV = readCSV('players.csv');
for (const row of playersCSV) {
const teamId = teamMap.get(row.team);
@@ -125,14 +137,14 @@ async function seed() {
);
}
// --- 5. Event Types ---
// Seed event types
const eventTypesCSV = readCSV('eventTypes.csv');
for (const row of eventTypesCSV) {
const presetId = resultPresetMap.get(row.resultPreset);
if (!presetId) throw new Error(`Team "${row.resultPreset}" not found`);
await db.insert(schema.eventTypes).values({
name: row.event_name,
preset: row.preset,
scoringPreset: row.preset,
resultPreset: presetId
});
console.log(
@@ -143,12 +155,13 @@ async function seed() {
const dbEventTypes = await db.select().from(schema.eventTypes);
const eventTypeMap = new Map(dbEventTypes.map((et) => [et.name, et.id]));
// --- 6. Registered Events ---
// Seed registered events
const eventNameMap = new Map<string, number>();
const registeredEventsCSV = readCSV('registeredEvents.csv');
for (const row of registeredEventsCSV) {
const eventTypeId = eventTypeMap.get(row.event_type);
const teamId = teamMap.get(row.winner);
const divisionId = divisionMap.get(row.division);
if (!eventTypeId) throw new Error(`Event Type "${row.event_type}" not found`);
@@ -160,16 +173,19 @@ async function seed() {
eventType: eventTypeId,
division: divisionId,
state: row.event_state || 0,
timeCompleted: row.time_completed || null
timeCompleted: row.time_completed || null,
teamWinner: teamId || null
})
.returning();
console.log(` → Registered Event [id:${inserted.id}]: ${row.event_type} | ${row.division}`);
console.log(
` → Registered Event [id:${inserted.id}]: ${row.event_type} | ${row.division}, winner: ${teamId}, ${row.winner}`
);
// Map the textual event name (e.g., "100m Sprint") to the generated DB ID
// Map event name|division to the generated event ID
eventNameMap.set(`${row.event_type}|${row.division}`, inserted.id);
}
// --- 7. Registered Players ---
// Seed registered players (linking players to events)
const dbPlayers = await db.select().from(schema.players);
const playerMap = new Map(dbPlayers.map((p) => [`${p.firstName} ${p.lastName}`, p]));
@@ -179,7 +195,6 @@ async function seed() {
const divisionName = divisionNameMap.get(player?.division ?? -1);
const actualEventId = eventNameMap.get(`${row.event_registered}|${divisionName}`);
// Dynamic look up of the bracket row's primary key ID using the CSV text
const bracketId = bracketMap.get(row.bracket);
if (!player) throw new Error(`Player "${row.player_registered}" not found`);
@@ -192,7 +207,7 @@ async function seed() {
await db.insert(schema.registeredPlayers).values({
playerID: player.id,
registeredEventID: actualEventId,
bracket: bracketId, // Using the real relational ID instead of raw value
bracket: bracketId,
placement: row.player_placement || 0
});
console.log(
@@ -200,7 +215,7 @@ async function seed() {
);
}
// Re-enable Foreign Key constraints now that the data is built cleanly
// Re-enable FK checks
await client.execute('PRAGMA foreign_keys = ON');
console.log('\n✅ Seeding complete!');

3
src/app.d.ts vendored
View File

@@ -1,7 +1,6 @@
import type { User, Session } from 'better-auth/minimal';
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
// SvelteKit app type declarations
declare global {
namespace App {
interface Locals { user?: User; session?: Session }

View File

@@ -0,0 +1,31 @@
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import {
getSessionToken,
validateSessionToken,
setSessionTokenCookie,
deleteSessionTokenCookie
} from '$lib/server/auth';
export const handle: Handle = async ({ event, resolve }) => {
const token = getSessionToken(event);
if (!token) {
event.locals.user = null;
event.locals.session = null;
return resolve(event);
}
const { session, user } = await validateSessionToken(token);
if (session) {
setSessionTokenCookie(event, token, session.expiresAt);
} else {
deleteSessionTokenCookie(event);
}
event.locals.user = user;
event.locals.session = session;
return resolve(event);
};

View File

@@ -1 +1 @@
// place files you want to import through the `$lib` alias in this folder.
// Place shared exports accessible via $lib alias here

73
src/lib/server/auth.ts Normal file
View File

@@ -0,0 +1,73 @@
// src/lib/server/auth.ts
import { sha256 } from '@oslojs/crypto/sha2';
import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding';
import { eq } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { sessions, scorers as scorers } from '$lib/server/db/schema';
import type { RequestEvent } from '@sveltejs/kit';
const DAY_MS = 1000 * 60 * 60 * 24;
const SESSION_COOKIE = 'session';
export function generateSessionToken(): string {
const bytes = new Uint8Array(20);
crypto.getRandomValues(bytes);
return encodeBase32LowerCaseNoPadding(bytes);
}
export async function createSession(token: string, userId: string) {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const expiresAt = new Date(Date.now() + DAY_MS * 30);
await db.insert(sessions).values({ id: sessionId, userId, expiresAt });
return { id: sessionId, userId, expiresAt };
}
export async function validateSessionToken(token: string) {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const [row] = await db
.select({ session: sessions, user: scorers })
.from(sessions)
.innerJoin(scorers, eq(sessions.userId, scorers.id))
.where(eq(sessions.id, sessionId));
if (!row) return { session: null, user: null };
// Session expired, delete it
if (Date.now() >= row.session.expiresAt.getTime()) {
await db.delete(sessions).where(eq(sessions.id, sessionId));
return { session: null, user: null };
}
// Renew session if past the halfway point
if (Date.now() >= row.session.expiresAt.getTime() - DAY_MS * 15) {
const newExpiresAt = new Date(Date.now() + DAY_MS * 30);
await db.update(sessions).set({ expiresAt: newExpiresAt }).where(eq(sessions.id, sessionId));
row.session.expiresAt = newExpiresAt;
}
return { session: row.session, user: { id: row.user.id, username: row.user.username } };
}
export async function invalidateSession(sessionId: string) {
await db.delete(sessions).where(eq(sessions.id, sessionId));
}
export function setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date) {
event.cookies.set(SESSION_COOKIE, token, {
expires: expiresAt,
path: '/',
httpOnly: true,
secure: !import.meta.env.DEV,
sameSite: 'lax'
});
}
export function deleteSessionTokenCookie(event: RequestEvent) {
event.cookies.delete(SESSION_COOKIE, { path: '/' });
}
export function getSessionToken(event: RequestEvent) {
return event.cookies.get(SESSION_COOKIE) ?? null;
}

View File

@@ -1,24 +0,0 @@
import * as database from './databaseManager.ts';
import { globalEmitter } from './databaseManager.ts';
// TODO Implement caching of info at some point
// structure:
// databaseManager gets raw data from backend
// stored in variables in cacheManager
// sent to frontend using same function names in dataManager (not made)
let teamsCache;
export async function updateTeamsCache() {
teamsCache = database.getTeams();
}
globalEmitter.on('invalTeamsCache', updateTeamsCache);
let eventsCache;
export async function updateEventsCache() {
eventsCache = database.getRegisteredEvents();
}
globalEmitter.on('invalEventsCache', updateEventsCache);

View File

@@ -1,22 +1,9 @@
import { EventEmitter } from 'node:events';
import { db } from '$lib/server/db';
import { eq } from 'drizzle-orm';
import { sql, eq, and } from 'drizzle-orm';
import * as schema from '$lib/server/db/schema';
import { globalEmitter } from './globalEmitter';
// Emitter that emits
export const globalEmitter = new EventEmitter();
//// REFERENCE CODE
// Increment score for testing (remove ts)
// const increment = () => {
// testScore++;
// console.log('score incremented', testScore);
// globalEmitter.emit('scoreUpdate');
// };
// Increment scores when there is an emit
// globalEmitter.on('incrementScores', increment);
// For page.server.ts so that it doesnt look weird before loading
// Initial data for page load
export async function getAllInitialInfo() {
return {
teams: await getTeams(),
@@ -24,9 +11,12 @@ export async function getAllInitialInfo() {
};
}
// Get teams object from database
export async function getTeams() {
const allTeams = await db.select().from(schema.teamScoresView);
// Fetch teams with optional filter
export async function getTeams(teamId?: number) {
const allTeams = await db
.select()
.from(schema.teamScoresView)
.where(teamId ? eq(schema.teamScoresView.teamId, teamId) : undefined);
for (let team in allTeams) {
let currentTeam = allTeams[team];
if (!currentTeam.totalPoints) {
@@ -42,51 +32,95 @@ export async function getTeams() {
};
}
// Get all registered events from database
// Fetch registered events with optional filter
export async function getRegisteredEvents(eventId?: number) {
console.log('eventId: ', eventId);
async function getWinnerInfo(teamId: number) {
const teamInfo = await getTeams(teamId);
return teamInfo.teams[0];
}
async function getScoringPreset(presetId: number) {
const presets = await db
.select()
.from(schema.scoringPresets)
.where(presetId ? eq(schema.scoringPresets.presetID, presetId) : undefined);
return presets;
}
const allEvents = await db
.select()
.from(schema.registeredEventsView)
// If event id specified, get that event, otherwise get all events
.where(eventId ? eq(schema.registeredEventsView.eventId, eventId) : undefined);
return {
events: allEvents.map((events) => ({
const events = await Promise.all(
allEvents.map(async (events) => ({
id: events.eventId,
name: events.eventName,
division: events.division,
state: events.state,
completed: events.timeCompleted || 0,
resultPreset: events.resultPreset
resultPreset: events.resultPreset,
scoringPreset: events.scorePreset ? await getScoringPreset(events.scorePreset) : 'UNDECIDED',
winner: events.winner ? await getWinnerInfo(events.winner) : 'UNDECIDED'
}))
};
);
return { events };
}
// Get all players with an event id specified
export async function getAllRegisteredEventPlayers(eventId: number) {
export async function startEvent(eventId: number) {
let event = await db
.select()
.from(schema.registeredEventsView)
.where(eq(schema.registeredEventsView.eventId, eventId));
let requestedEvent = event[0];
if (requestedEvent.state != 0) {
console.log('not startable');
return false;
} else {
let replacedEvent = await db
.update(schema.registeredEvents)
.set({ state: 1 })
.where(eq(schema.registeredEvents.id, requestedEvent.eventId))
.returning();
globalEmitter.emit('eventUpdate');
return true;
}
}
// Fetch all players registered for a specific event
export async function getAllRegisteredEventPlayers(eventId: number, getScores?: boolean) {
const eventPlayers = await db
.select()
.from(schema.registeredEventPlayersView)
// where the registered player is registered for that event
.where(eq(schema.registeredEventPlayersView.eventId, eventId))
.orderBy(
schema.registeredEventPlayersView.bracket,
schema.registeredEventPlayersView.placement,
sql`CASE WHEN ${schema.registeredEventPlayersView.placement} = 0 THEN 999999 ELSE ${schema.registeredEventPlayersView.placement} END ASC`,
schema.registeredEventPlayersView.teamName
);
return {
eventPlayers: eventPlayers.map((players) => ({
// 1. Wrap the map in Promise.all and await it
const resolvedPlayers = await Promise.all(
eventPlayers.map(async (players) => ({
id: players.playerId,
firstName: players.firstName,
lastName: players.lastName,
registeredPlayerId: players.registeredPlayerId,
placement: players.placement,
bracket: players.bracket,
eventId: players.eventId,
eventName: players.eventName,
teamId: players.teamId,
teamName: players.teamName,
teamColor: players.teamColor
teamColor: players.teamColor,
playerScores: getScores == true ? await getPlayerScores(players.playerId, eventId) : undefined
}))
);
// 2. Return the fully resolved data
return {
eventPlayers: resolvedPlayers
};
}
@@ -98,59 +132,85 @@ export async function getAllBrackets() {
};
}
export async function getResultPreset(presetId: number) {
export async function getPlayerInfo(playerId: number) {
const playerInfo = await db.select().from(schema.players).where(eq(schema.players.id, playerId));
const teamInfo = await db
.select()
.from(schema.teams)
.where(eq(schema.teams.id, playerInfo[0].team));
return { ...playerInfo[0], teamInfo: teamInfo[0] };
}
export async function getPlayerScores(playerId: number, eventId?: number) {
const playerRegistrations = await getPlayerRegistrations(playerId, eventId);
let scoresObject: any[] = [];
for (let registration in playerRegistrations) {
let currentReg = playerRegistrations[registration];
let scores = await db
.select()
.from(schema.registeredResults)
.where(eq(schema.registeredResults.registeredPlayerId, currentReg.registeredPlayerId));
scoresObject.push(...scores);
}
return scoresObject;
}
export async function getPlayerRegistrations(playerId: number, eventId?: number) {
const playerRegistrations = await db
.select()
.from(schema.registeredEventPlayersView)
.where(
and(
eq(schema.registeredEventPlayersView.playerId, playerId),
eventId ? eq(schema.registeredEventPlayersView.eventId, eventId) : undefined
)
);
return playerRegistrations;
}
export async function getResultPreset(presetId?: number) {
const resultPresets = await db
.select()
.from(schema.resultPresets)
.where(eq(schema.resultPresets.id, presetId));
.where(presetId ? eq(schema.resultPresets.id, presetId) : undefined);
return {
resultPresets: resultPresets
};
}
// Moved the function the registeredEvents endpoint
// Just merges the results of the previous 3 functions into a standard format
// Merge events, players, brackets, and presets into a frontend-ready structure
export async function getRegisteredEventsWithPlayers(eventId?: number) {
// Get updated events from database
let newEvents = await getRegisteredEvents(eventId);
let registeredEventList = newEvents['events'];
// Get all possible brackets from the database
let brackets = await getAllBrackets();
// Initilise the final eventList
let fullEventList: any[] = [];
// For every event
for (let registeredEvent in registeredEventList) {
let event = registeredEventList[registeredEvent];
// Get the info about the result preset for the ui
let resultPreset = await getResultPreset(event.resultPreset);
let registeredPlayers = await getAllRegisteredEventPlayers(
event.id,
eventId != undefined ? true : undefined
);
// Get all players for the event
let registeredPlayers = await getAllRegisteredEventPlayers(event.id);
// Map the players into an [] with structure {id: number, name: string, items: any[]}
// So that the players are sorted by bracket for the frontend
// Group players by bracket category for the frontend
const bracketOrder = brackets.brackets.map((category) => {
return {
...category,
// Filter the items that match the current bracket name
items: registeredPlayers.eventPlayers.filter((item) => item.bracket === category.name)
};
});
// append the player info and result preset to the event object
let eventWithPlayers = {
...event,
registeredPlayers: bracketOrder,
...resultPreset
};
// combine all of the events into one array
fullEventList.push(eventWithPlayers);
}
// Send to client
return fullEventList;
}

View File

@@ -1,6 +1,21 @@
import { sql, eq } from 'drizzle-orm';
import { integer, sqliteTable, text, sqliteView } from 'drizzle-orm/sqlite-core';
export const scorers = sqliteTable('users', {
id: text('id').primaryKey(),
username: text('username').notNull().unique(),
role: text('scorer_role').notNull().default('scorer'),
passwordHash: text('password_hash').notNull()
});
export const sessions = sqliteTable('sessions', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => scorers.id),
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull()
});
export const players = sqliteTable('players', {
id: integer('players_id').primaryKey({ autoIncrement: true }),
firstName: text('firstName').notNull(),
@@ -46,7 +61,7 @@ export const resultPresets = sqliteTable('resultPresets', {
export const eventTypes = sqliteTable('eventTypes', {
id: integer('eventTypes_id').primaryKey({ autoIncrement: true }),
name: text('event_name').notNull(),
preset: integer('preset')
scoringPreset: integer('preset')
.references(() => scoringPresets.presetID)
.notNull(),
resultPreset: integer('result_preset')
@@ -63,7 +78,8 @@ export const registeredEvents = sqliteTable('registeredEvents', {
.references(() => divisions.id)
.notNull(),
state: integer('event_state').notNull().default(0),
timeCompleted: integer('time_completed')
timeCompleted: integer('time_completed'),
teamWinner: integer('event_team_winner').references(() => teams.id)
});
export const registeredPlayers = sqliteTable('registeredPlayers', {
@@ -131,17 +147,20 @@ export const registeredEventsView = sqliteView('registeredEventsView').as((qb) =
division: divisions.name,
state: registeredEvents.state,
timeCompleted: registeredEvents.timeCompleted,
winner: registeredEvents.teamWinner,
scorePreset: eventTypes.scoringPreset,
resultPreset: eventTypes.resultPreset
})
.from(registeredEvents)
.innerJoin(eventTypes, eq(registeredEvents.eventType, eventTypes.id))
.innerJoin(divisions, eq(registeredEvents.division, divisions.id))
.innerJoin(divisions, eq(registeredEvents.division, divisions.id));
});
export const registeredEventPlayersView = sqliteView('registeredEventPlayersView').as((qb) => {
return qb
.select({
playerId: players.id,
registeredPlayerId: registeredPlayers.id,
firstName: players.firstName,
lastName: players.lastName,
placement: registeredPlayers.placement,

View File

@@ -1,34 +1,77 @@
export async function generateEndpoint(
startFunction?: (enqueue: (data: any) => void) => void | Promise<void | (() => void)>
startFunction?: (enqueue: (data: any) => void) => void | Promise<void | (() => void)>,
request?: Request
) {
let streamController: ReadableStreamDefaultController | null = null;
let cleanupFunction: (() => void) | void = undefined;
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
let cleanedUp = false;
const safeCleanup = () => {
if (cleanedUp) return;
cleanedUp = true;
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
if (typeof cleanupFunction === 'function') {
cleanupFunction();
cleanupFunction = undefined;
}
};
const enqueue = (data: any) => {
if (cleanedUp) return;
let transferdata = JSON.stringify(data);
// stringify data and add to controller queue
if (streamController) {
try {
streamController.enqueue(`data: ${transferdata}\n\n`);
} else {
console.log('no controller');
} catch {
safeCleanup();
}
}
};
const stream = new ReadableStream({
async start(controller) {
streamController = controller;
heartbeatInterval = setInterval(() => {
try {
streamController!.enqueue(': keepalive\n\n');
} catch {
safeCleanup();
}
}, 30000);
heartbeatInterval.unref();
if (startFunction) {
try {
const result = await startFunction(enqueue);
if (typeof result === 'function') {
cleanupFunction = result;
}
} catch (err) {
safeCleanup();
throw err;
}
}
},
async cancel() {
if (cleanupFunction) cleanupFunction();
safeCleanup();
}
});
if (request?.signal) {
if (request.signal.aborted) {
safeCleanup();
} else {
request.signal.addEventListener('abort', () => safeCleanup());
}
}
return {
response: new Response(stream, {
headers: {

View File

@@ -0,0 +1,4 @@
import { EventEmitter } from 'node:events';
export const globalEmitter = new EventEmitter();
globalEmitter.setMaxListeners(1000);

View File

@@ -13,33 +13,26 @@
let containerRef = $state<HTMLDivElement | null>(null);
let activeId = $state<string | number | null>(null);
/**
* Public function to scroll a specific row into the center of the viewport.
* Can be called manually by the parent component.
*/
/** Scroll a specific row to the center of the viewport */
export function scrollToId(id: string | number) {
if (!containerRef) return;
const targetRow = containerRef.querySelector(`#row-${id}`) as HTMLElement | null;
if (targetRow) {
// Update local state to highlight the centered row
activeId = id;
// Calculate the midpoint math to center the row
const rowOffsetTop = targetRow.offsetTop;
const rowHeight = targetRow.offsetHeight;
const containerHeight = containerRef.clientHeight;
// Target = Row position - (half of container space) + (half of row height adjustment)
const centerScrollTarget = rowOffsetTop - containerHeight / 2 + rowHeight / 2;
// Using behavior: 'auto' for an instantaneous snap instead of smooth scrolling
containerRef.scrollTo({
top: centerScrollTarget,
behavior: 'auto'
});
}
}
}
</script>
<div bind:this={containerRef} class="table-container" style="max-height: {maxHeight};">

View File

@@ -10,7 +10,7 @@ export const fitText: Action<HTMLElement> = (node) => {
node.style.transformOrigin = 'top left';
node.style.transform = 'none';
// Step 1: fit to height
// Fit font size to container height
let size = 1;
node.style.fontSize = size + 'px';
while (node.scrollHeight <= container!.clientHeight) {
@@ -19,7 +19,7 @@ export const fitText: Action<HTMLElement> = (node) => {
}
node.style.fontSize = size - 1 + 'px';
// Step 2: stretch width to fill container
// Stretch width to fill container
const scaleX = container!.clientWidth / node.scrollWidth;
node.style.transform = `scaleX(${scaleX})`;
}

View File

@@ -0,0 +1,15 @@
// alternative, if role isn't on locals.user
import { eq } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { scorers } from '$lib/server/db/schema';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
if (!locals.user) return { user: null };
const [row] = await db
.select({ role: scorers.role })
.from(scorers)
.where(eq(scorers.id, locals.user.id));
return { user: { ...locals.user, role: row?.role ?? 'scorer' } };
};

View File

@@ -1,10 +1,47 @@
<script lang="ts">
import './layout.css';
import favicon from '$lib/assets/favicon.svg';
import type { LayoutData } from './$types';
let { children } = $props();
let { children, data }: { children: import('svelte').Snippet; data: LayoutData } = $props();
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
<div class="header goldman flex h-15 w-full">
<a
class="align-text-middle justify-left mx-3 my-1 h-auto content-center rounded-sm border-2
border-solid border-red-500 px-2"
href="/">home</a
>
<div class="w-full"></div>
{#if data.user?.role === 'admin'}
<a
class="align-text-middle justify-right mx-3 my-1 h-auto content-center rounded-sm border-2
border-solid border-red-500 px-2"
href="/ledger">ledger</a
>
{/if}
{#if data.user}
<a
class="align-text-middle justify-right mx-3 my-1 h-auto content-center rounded-sm border-2
border-solid border-red-500 px-2"
href="/login">logout</a
>
{:else}
<a
class="align-text-middle justify-right mx-3 my-1 h-auto content-center rounded-sm border-2
border-solid border-red-500 px-2"
href="/login">login</a
>
{/if}
</div>
{@render children()}
<style>
.header {
background-color: var(--ctp-mocha-crust);
}
</style>

View File

@@ -1,6 +1,6 @@
import { getAllInitialInfo } from '$lib/server/databaseManager';
// Literally only here so that the frontend has the right structure
// Provide initial data for the home page
export const load = async () => {
return await getAllInitialInfo();
};

View File

@@ -1,92 +1,110 @@
<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');
// Map event IDs to their DOM elements for scrolling
let eventRefs = $state<Record<number, HTMLElement>>({});
// when you get a message do something
onMount(() => {
// Subscribe to live team score updates via SSE
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');
// Subscribe to live event updates via SSE
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]);
}
function sortPlayers(items: any) {
return [...items].sort((a, b) => {
// If a is unranked and b is ranked, move a down
if (a.placement === 0 && b.placement !== 0) return 1;
// If a is ranked and b is unranked, move a up
if (a.placement !== 0 && b.placement === 0) return -1;
// If both are ranked, sort numerically ascending (1st, 2nd, 3rd...)
return a.placement - b.placement;
});
}
// Scroll to and highlight the currently ongoing event
$effect(() => {
if (focusEventId == null) return;
tick().then(() => {
const el = eventRefs[focusEventId!];
el.scrollIntoView({ alignToTop: true, behavior: 'instant', container: 'nearest' });
tick().then(() => {
window.scrollTo(0, 0);
});
});
});
// Svelte action that enables CSS marquee when text overflows
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="/"
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 +112,128 @@
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, responsive grid -->
{#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="/"
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"
class:ongoing-event={event.state == 1}
class:completed-event={event.state == 2}
bind:this={eventRefs[event.id]}
style={event.state == 2 ? `--event-color: ${event.winner.color}` : ''}
>
<div class="event-header">
<a href="/event/{event.id}" class="event-name goldman">{event.name}</a>
<span class="event-division">{event.division}</span>
{#if event.state == 1}
<span class="event-status goldman">ONGOING</span>
{:else if event.state == 2}
<span
class="event-winner event-status goldman"
style="--winner-color:{event.winner.color}"
>Won By {event.winner.name} at {new Date(event.completed)
.toLocaleTimeString()
.slice(0, -3)}</span
>
{/if}
</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">
<div class="brackets-name flex items-center">
<span class="brackets-name-text align-text-middle">{bracket.name}</span>
<div class="bracket-vertical-sep"></div>
</div>
{#each sortPlayers(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>
<a href="/stats/player/{player.id}" class="marquee-inner">
{player.firstName}
{player.lastName}
</a>
</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} />
<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 {
font-family: 'Black Ops One', system-ui;
font-weight: 400;
font-style: normal;
}
.score-box {
color: var(--theme-color);
border-color: var(--theme-color);
background-color: color-mix(in srgb, var(--theme-color), transparent 90%);
}
.player-box {
color: var(--theme-color);
border-color: var(--theme-color);
background-color: color-mix(in srgb, var(--theme-color), transparent 90%);
}
</style>
</section>
</div>

View File

@@ -0,0 +1,117 @@
import { globalEmitter } from '$lib/server/globalEmitter';
import { eq } from 'drizzle-orm';
import { db } from '$lib/server/db';
import * as schema from '$lib/server/db/schema';
import { getRegisteredEvents } from '$lib/server/databaseManager';
export async function POST({ request }: any) {
let responseBody = await request.json();
if (!responseBody) {
return new Error('nuh uh');
} else {
if (responseBody.eventId) {
let eventData = await getRegisteredEvents(responseBody.eventId);
if (eventData.events[0].state != 1) {
return new Error();
}
let scoringPreset = eventData.events[0].scoringPreset;
// Create ledger entry to record this scoring event
let newLedgerEntry = await db
.insert(schema.mainLedger)
.values({ registeredEvent: responseBody.eventId })
.returning();
let ledgerEntryId = newLedgerEntry[0].id;
function getPoints(
scoringPreset: { placement: number; points: number }[] | string,
position: number
): number {
const preset =
typeof scoringPreset === 'string' ? JSON.parse(scoringPreset) : scoringPreset;
return (
preset.find((s: { placement: number; points: number }) => s.placement === position)
?.points ?? 0
);
}
// Accumulate scores per team, then batch insert
const teamScores = new Map<any, number>();
for (let bracket in responseBody.brackets) {
for (let player in responseBody.brackets[bracket].players) {
let currentPlayer = responseBody.brackets[bracket].players[player];
let currentPlayerTeam = currentPlayer.teamId;
let currentPlayerPosition = currentPlayer.position;
if (currentPlayerPosition > 0) {
let score = getPoints(scoringPreset, currentPlayerPosition);
if (currentPlayer.scores.length > 0) {
if (score > 0) {
const currentTeamScore = teamScores.get(currentPlayerTeam) || 0;
teamScores.set(currentPlayerTeam, currentTeamScore + score);
}
for (let result in currentPlayer.scores) {
let currentResult = currentPlayer.scores[result];
let newScoreEntry = await db
.insert(schema.registeredResults)
.values({
registeredPlayerId: currentPlayer.registeredPlayerId,
resultIndex: parseInt(result),
result: currentResult
})
.returning();
console.log(newScoreEntry);
}
// Update player placement in the database
let newPlayerPlacement = await db
.update(schema.registeredPlayers)
.set({ placement: currentPlayerPosition })
.where(eq(schema.registeredPlayers.id, currentPlayer.registeredPlayerId))
.returning();
}
}
}
}
// Batch insert team scores into the ledger
if (teamScores.size > 0) {
const ledgerEntries = Array.from(teamScores.entries()).map(([teamID, points]) => ({
ledgerID: ledgerEntryId,
teamID,
points
}));
let newScores = await db.insert(schema.scoreLedger).values(ledgerEntries).returning();
}
// Determine the winning team from accumulated scores
let highestScore = -1;
let winningTeamId = null;
for (let [teamID, points] of teamScores.entries()) {
if (points > highestScore) {
highestScore = points;
winningTeamId = teamID;
}
}
if (winningTeamId) {
let teamWinnerUpdate = await db
.update(schema.registeredEvents)
.set({ teamWinner: winningTeamId, state: 2, timeCompleted: Date.now() })
.where(eq(schema.registeredEvents.id, responseBody.eventId))
.returning();
}
}
globalEmitter.emit('scoreUpdate');
globalEmitter.emit('eventUpdate');
return new Response('coolsies');
}
}

View File

@@ -0,0 +1,17 @@
import { globalEmitter } from '$lib/server/globalEmitter';
import { eq } from 'drizzle-orm';
import { db } from '$lib/server/db';
import * as schema from '$lib/server/db/schema';
import { startEvent } from '$lib/server/databaseManager';
export async function POST({ request }: any) {
let responseBody = await request.json();
if (!responseBody) {
return new Error('send a response dummy');
} else {
console.log(responseBody);
startEvent(responseBody.eventId);
return new Response('ok');
}
}

View File

@@ -0,0 +1,12 @@
import { redirect } from '@sveltejs/kit';
import { invalidateSession, deleteSessionTokenCookie } from '$lib/server/auth';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
if (event.locals.session) {
await invalidateSession(event.locals.session.id);
}
deleteSessionTokenCookie(event);
throw redirect(303, '/login');
};

View File

@@ -1,37 +1,33 @@
import {
globalEmitter,
getRegisteredEvents,
getRegisteredEventsWithPlayers
} from '$lib/server/databaseManager';
import { getRegisteredEventsWithPlayers } from '$lib/server/databaseManager';
import { globalEmitter } from '$lib/server/globalEmitter';
import { generateEndpoint } from '$lib/server/endpoint';
export async function GET() {
// Generate stream endpoint
export async function GET({ request }) {
const endpoint = generateEndpoint(async (enqueue) => {
// Get the all the events with the players seperated into brackets
let eventList = async () => {
let newEventList = await getRegisteredEventsWithPlayers();
enqueue(newEventList);
};
// Send the eventList to the client when a connection is made
// TODO make it so that this only happens on an initial post request
// Send initial data on connection, then subscribe to updates
eventList();
globalEmitter.on('eventUpdate', eventList);
// Simply return the cleanup function here
return () => {
globalEmitter.off('eventUpdate', eventList);
};
});
}, request);
return (await endpoint).response;
}
export async function POST({ request }: any) {
let responseBody = await request.json();
if (!responseBody) {
return new Response('nuh uh');
} else {
let eventRequested = responseBody.eventId;
let eventList = await getRegisteredEventsWithPlayers(eventRequested);
return new Response(JSON.stringify(eventList));
}
}

View File

@@ -1,9 +1,7 @@
import { globalEmitter, getAllRegisteredEventPlayers } from '$lib/server/databaseManager';
import { getAllRegisteredEventPlayers } from '$lib/server/databaseManager';
import { globalEmitter } from '$lib/server/globalEmitter';
import { generateEndpoint } from '$lib/server/endpoint';
// Expose post request
export async function POST({ request }: any) {
// When post request recieved increment testscores by 1
// Return ok so the frontend is happy
return new Response('ok');
}

View File

@@ -1,30 +1,26 @@
import { globalEmitter, getTeams } from '$lib/server/databaseManager';
import { getTeams } from '$lib/server/databaseManager';
import { globalEmitter } from '$lib/server/globalEmitter';
import { generateEndpoint } from '$lib/server/endpoint';
// Expose post request
export async function POST({ request }: any) {
// When post request recieved increment testscores by 1
globalEmitter.emit('incrementScores');
// Return ok so the frontend is happy
return new Response('ok');
}
export async function GET() {
export async function GET({ request }) {
const endpoint = generateEndpoint(async (enqueue) => {
// Function to grab score from database and add it to message queue
let newScore = async () => {
let newScores = await getTeams();
enqueue(newScores);
};
// Initial Sync
// Send initial team scores, then subscribe to updates
newScore();
globalEmitter.on('scoreUpdate', newScore);
// Simply return the cleanup function here
return () => {
globalEmitter.off('scoreUpdate', newScore);
};
});
}, request);
return (await endpoint).response;
}

View File

@@ -0,0 +1,14 @@
import { eq } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { scorers } from '$lib/server/db/schema';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) return { user: null };
const [row] = await db
.select({ role: scorers.role })
.from(scorers)
.where(eq(scorers.id, locals.user.id));
return { user: { ...locals.user, role: row?.role ?? 'scorer' } };
};

View File

@@ -1,7 +1,31 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import type { PageProps } from './$types';
let { params }: PageProps = $props();
let { params, data }: PageProps = $props();
function ordinal(n: number) {
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return n + (s[(v - 20) % 10] ?? s[v] ?? s[0]);
}
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() };
}
let eventId = params.eventId;
@@ -17,7 +41,22 @@
'Content-type': 'application/json; charset=UTF-8'
}
});
return response.json();
const data = await response.json();
// Sort players: placed first (ascending), unplaced last
if (data && data[0] && data[0].registeredPlayers) {
data[0].registeredPlayers.forEach((bracket: any) => {
bracket.items.sort((a: any, b: any) => {
if (a.placement === 0 && b.placement === 0) return 0;
if (a.placement === 0) return 1;
if (b.placement === 0) return -1;
return a.placement - b.placement;
});
});
}
return data;
}
let eventDataPromise = getEventData();
@@ -36,5 +75,87 @@
<div>loading</div>
{:then eventData}
{@const event = eventData[0]}
<div>{console.log(event)} {event.name}</div>
{console.log(event)}
<div class="flex justify-center">
<div class="w-full flex-col px-[5vw] text-center">
<div
style:background-color={event.state === 1
? 'color-mix(in srgb, #fe640b 18%, transparent)'
: event.state === 2
? 'color-mix(in srgb, #a6e3a1 18%, transparent)'
: ''}
class="align-text-middle my-7 h-10 w-full rounded-2xl border-2 border-solid border-ctp-surface1"
>
{event.name} - {event.division}
{#if event.state == 1}- ONGOING
{:else if event.state == 2}- FINISHED
{/if}
</div>
{#each event.registeredPlayers as bracket, bi}
{#if bi > 0}
<div class="bracket-sep" aria-hidden="true"></div>
{/if}
<div class="bracket-row">
<div class="brackets-name flex items-center">
<span class="brackets-name-text align-text-middle">{bracket.name}</span>
<div class="bracket-vertical-sep"></div>
</div>
{#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=" text-xl">
{player.firstName}
{player.lastName}
</span>
</div>
{#if player.placement !== 0}
<div class="player-placement goldman">{ordinal(player.placement)}</div>
<div class="resultContainer flex justify-center">
{#each player.playerScores as score}
<div class="mx-5 my-1 rounded border-2" style="border-color:{player.teamColor}">
Run {score.resultIndex + 1}: {score.result}{event.resultPresets[0].unit}
</div>
{/each}
</div>
{:else}
<div class="player-placement-gap"></div>
{/if}
</div>
{/each}
</div>
{/each}
</div>
</div>
{#if data.user}
<div class="mt-10 flex w-full justify-center">
<a
class="flex justify-center rounded border-2 border-solid border-white bg-ctp-surface2 p-4"
href="/event/scoring/{eventId}">Score This Event</a
>
</div>
{/if}
{/await}
<style>
.resultContainer {
flex-direction: column;
}
@media (max-width: 479px) {
.resultContainer {
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,9 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) {
throw redirect(303, '/login');
}
return { user: locals.user };
};

View File

@@ -0,0 +1,409 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import type { PageProps } from './$types';
import { flip } from 'svelte/animate';
let { params }: PageProps = $props();
function ordinal(n: number) {
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return n + (s[(v - 20) % 10] ?? s[v] ?? s[0]);
}
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() };
}
let eventId = parseInt(params.eventId);
let eventEndpoint: EventSource;
type Player = { firstName: string; lastName: string; teamColor: string; [key: string]: any };
type Bracket = { name: string; items: Player[]; [key: string]: any };
let event = $state<any>(null);
let brackets = $state<Bracket[]>([]);
let loading = $state(true);
let dropTarget = $state<{ bi: number; pi: number } | null>(null);
let submitStatus = $state<'idle' | 'submitting' | 'done' | 'error'>('idle');
let dragSrc = $state<{ bi: number; pi: number } | null>(null);
let sortByScore = $state(true);
let numResults = $derived(event?.resultPresets[0]?.numberOfResults ?? 2);
let useAverage = $derived(event?.resultPresets[0]?.averageResults ?? true);
let pendingScores = $state<Record<string, string[]>>({});
let committedScores = $state<Record<string, (number | null)[]>>({});
let hydrated = $state(false);
$effect(() => {
if (!hydrated || !eventId) return;
localStorage.setItem(
`scores-${eventId}`,
JSON.stringify({
pendingScores: $state.snapshot(pendingScores),
committedScores: $state.snapshot(committedScores)
})
);
});
$effect(() => {
if (!hydrated || !eventId) return;
localStorage.setItem(`sortByScore-${eventId}`, String(sortByScore));
});
function average(scores: (number | null)[], fallback = Infinity): number {
const valid = scores.filter((s): s is number => s !== null);
if (valid.length === 0) return fallback;
return valid.reduce((a, b) => a + b, 0) / valid.length;
}
function best(scores: (number | null)[], fallback = Infinity): number {
const valid = scores.filter((s): s is number => s !== null);
if (valid.length === 0) return fallback;
return Math.min(...valid);
}
function getPoints(placement: number): number {
return event?.scoringPreset?.find((s: any) => s.placement === placement)?.points ?? 0;
}
let lowerIsBetter = $derived(event?.resultPresets[0]?.lowerIsBetter === 1);
let displayBrackets = $derived(
brackets.map((bracket) => ({
...bracket,
items: sortByScore
? [...bracket.items].sort((a, b) => {
const scoresA = committedScores[a.id]?.filter((s) => s !== null) ?? [];
const scoresB = committedScores[b.id]?.filter((s) => s !== null) ?? [];
const hasA = scoresA.length > 0;
const hasB = scoresB.length > 0;
// Push unranked players to the bottom, then sort by score
if (!hasA && !hasB) return 0;
if (!hasA) return 1;
if (!hasB) return -1;
const fallback = lowerIsBetter ? Infinity : -Infinity;
const sa = useAverage
? average(committedScores[a.id] ?? [], fallback)
: best(committedScores[a.id] ?? [], fallback);
const sb = useAverage
? average(committedScores[b.id] ?? [], fallback)
: best(committedScores[b.id] ?? [], fallback);
return lowerIsBetter ? sa - sb : sb - sa;
})
: bracket.items
}))
);
let highestPlayer: number = 0;
onMount(async () => {
const response = await fetch('/api/registeredEvents', {
method: 'POST',
body: JSON.stringify({ eventId }),
headers: { 'Content-type': 'application/json; charset=UTF-8' }
});
const data = await response.json();
console.log(data[0]);
event = data[0];
brackets = data[0].registeredPlayers.map((b: any) => ({
...b,
items: [...b.items]
}));
for (let bracket in brackets) {
for (let player in brackets[bracket].items) {
if (parseInt(player) > highestPlayer) {
highestPlayer = parseInt(player);
}
}
}
const savedScores = localStorage.getItem(`scores-${eventId}`);
if (savedScores) {
const parsed = JSON.parse(savedScores);
pendingScores = parsed.pendingScores ?? {};
committedScores = parsed.committedScores ?? {};
}
const savedSort = localStorage.getItem(`sortByScore-${eventId}`);
if (savedSort !== null) sortByScore = savedSort === 'true';
hydrated = true;
loading = false;
eventEndpoint = new EventSource('/api/registeredEvents');
eventEndpoint.onmessage = async (e) => {
const response = await fetch('/api/registeredEvents', {
method: 'POST',
body: JSON.stringify({ eventId }),
headers: { 'Content-type': 'application/json; charset=UTF-8' }
});
const data = await response.json();
console.log('sone', data[0]);
event = data[0];
brackets = data[0].registeredPlayers.map((b: any) => ({
...b,
items: [...b.items]
}));
};
});
onDestroy(() => {
eventEndpoint?.close();
});
function onDragStart(bi: number, pi: number) {
dragSrc = { bi, pi };
}
function onDragOver(e: DragEvent, bi: number, pi: number) {
if (dragSrc?.bi === bi) {
e.preventDefault();
dropTarget = { bi, pi };
}
}
function onDrop(bi: number, pi: number) {
if (!dragSrc || dragSrc.bi !== bi) return;
const [src] = brackets[dragSrc.bi].items.splice(dragSrc.pi, 1);
brackets[bi].items.splice(pi, 0, src);
brackets = [...brackets];
dragSrc = null;
dropTarget = null;
}
function onDragEnd() {
dragSrc = null;
dropTarget = null;
}
async function startEvent() {
try {
const res = await fetch('/api/eventStart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
eventId: eventId
})
});
} catch {
return new Error();
}
}
async function submitResults() {
submitStatus = 'submitting';
try {
const res = await fetch('/api/eventResults', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
eventId,
brackets: displayBrackets.map((b) => ({
name: b.name,
players: b.items.map((p, i) => ({
...p,
position: i + 1,
points: getPoints(i + 1),
scores: committedScores[p.id] ?? [],
average: average(committedScores[p.id] ?? [])
}))
}))
})
});
console.log(res);
if (res.ok) {
localStorage.removeItem(`scores-${eventId}`);
localStorage.removeItem(`sortByScore-${eventId}`);
submitStatus = 'done';
} else {
submitStatus = 'error';
}
} catch {
submitStatus = 'error';
}
}
</script>
{#if loading}
<div>Loading Player Data</div>
{:else}
<div class="flex justify-center">
<div class="w-full flex-col px-[2vw] text-center">
<div
style:background-color={event.state === 1
? 'color-mix(in srgb, #fe640b 18%, transparent)'
: event.state === 2
? 'color-mix(in srgb, #a6e3a1 18%, transparent)'
: ''}
class="align-text-middle my-7 h-10 w-full rounded-2xl border-2 border-solid border-ctp-surface1"
>
{event.name} - {event.division} - scoring {#if event.state == 1}- ONGOING
{:else if event.state == 2}- FINISHED
{/if}
</div>
{#if event.state == 0}
<button onclick={startEvent} class="mb-4 rounded border-2 border-ctp-peach p-2"
>Start event</button
>
{/if}
<div class="flex flex-row justify-center">
<div class="flex w-50 min-w-0 flex-col">
<div class="brackets-name text-bold">=</div>
{#each Array.from({ length: highestPlayer + 1 }, (_, i) => i) as placement}
<div style="--player-color:white" class="scoring-player-card flex-1">
<div
class="player-name-wrap flex cursor-grab flex-col active:cursor-grabbing"
style="color:white"
role="option"
aria-selected={false}
draggable="true"
tabindex="0"
>
<span>{ordinal(placement + 1)}</span>
<span
>{event.scoringPreset[placement]
? event.scoringPreset[placement].points
: 0}</span
>
<span>pts</span>
</div>
</div>
{/each}
</div>
{#each displayBrackets as bracket, bi}
<div class="flex w-full min-w-0 flex-col">
<div class="brackets-name text-bold">
<span class="brackets-name-text" role="listbox" aria-label={bracket.name}
>{bracket.name}</span
>
</div>
{#each bracket.items as player, pi (player.id)}
{@const playerScores = committedScores[player.id] ?? []}
{@const validScores = playerScores.filter((s): s is number => s !== null)}
{@const avgScore =
validScores.length > 0
? validScores.reduce((a, b) => a + b, 0) / validScores.length
: 0}
<div
animate:flip={{ duration: 300 }}
class="scoring-player-card"
style="--player-color:{player.teamColor}"
>
<div
class="player-name-wrap cursor-grab active:cursor-grabbing"
class:opacity-50={dragSrc?.bi === bi && dragSrc?.pi === pi}
style="color:{player.teamColor}"
role="option"
aria-selected={false}
draggable="true"
tabindex="0"
ondragstart={() => onDragStart(bi, pi)}
class:drop-target={dropTarget?.bi === bi && dropTarget?.pi === pi}
ondragover={(e) => onDragOver(e, bi, pi)}
ondrop={() => onDrop(bi, pi)}
ondragend={onDragEnd}
>
<div use:marquee>
{player.firstName}
{player.lastName}
</div>
<div class="result-input-containers flex flex-col">
{#if event.state == 1}
{#each Array.from({ length: numResults }, (_, i) => i) as run}
<input
type="number"
placeholder="run {run + 1}"
class="text-black"
disabled={event.state != 1}
value={pendingScores[player.id]?.[run] ?? ''}
oninput={(e) => {
const current = [
...(pendingScores[player.id] ?? Array(numResults).fill(''))
];
current[run] = e.currentTarget.value;
pendingScores[player.id] = current;
}}
onblur={(e) => {
const val = parseFloat(e.currentTarget.value);
const current = [
...(committedScores[player.id] ?? Array(numResults).fill(null))
];
current[run] = isNaN(val) ? null : val;
committedScores[player.id] = current;
}}
/>
{/each}
{#if event.resultPresets[0].averageResults == 1}
<div class="text-sm opacity-60">
avg: {avgScore.toFixed(2)}
</div>
{/if}
{:else if player.playerScores.length > 0}
{#each player.playerScores as score}
<div class="border-red border-2 px-2">
{score.result}{event.resultPresets[0].unit}
</div>
{/each}
{/if}
</div>
</div>
</div>
{/each}
</div>
{/each}
</div>
<button
class="mt-4 rounded px-6 py-2 text-white"
class:bg-green-600={submitStatus === 'idle'}
class:bg-gray-400={submitStatus === 'submitting'}
class:bg-blue-600={submitStatus === 'done'}
class:bg-red-600={submitStatus === 'error'}
disabled={submitStatus === 'submitting' || submitStatus === 'done'}
onclick={submitResults}
>
{#if submitStatus === 'idle'}Submit Results
{:else if submitStatus === 'submitting'}Submitting…
{:else if submitStatus === 'done'}Submitted ✓
{:else}Error — Retry{/if}
</button>
</div>
</div>
{/if}
<style>
.drop-target {
outline: 2px dotted currentColor;
}
.scoring-player-card {
margin: 5px;
min-height: 100px;
background: color-mix(in srgb, var(--player-color) 10%, transparent);
}
</style>

View File

@@ -1,5 +1,386 @@
@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');
@import 'tailwindcss';
@import '@catppuccin/tailwindcss/mocha.css';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
: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;
}
/* Leaderboard */
.leaderboard {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 14px 6px;
}
.score-box {
position: relative;
overflow: hidden;
border-radius: 14px;
border: 2px solid var(--c);
color: var(--c);
background: color-mix(in srgb, var(--c) 10%, transparent);
/* Ghost + foreground stacked in same grid 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 fills cell, text sits at bottom */
.score-ghost {
width: 100%;
height: 100%;
opacity: 0.18;
fill: currentColor;
}
.ghost-svg {
width: 100%;
height: 100%;
display: block;
}
/* Foreground content layer */
.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);
}
/* Responsive runners-up: 1 col <480px, 2 cols 480-699px, 4 cols ≥700px */
.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 {
/* Use --runner-count so fewer teams fill the row evenly */
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;
box-shadow: inset 0px 48px 20px -26px rgba(0, 0, 0, 0.35);
border-radius: 25px;
overflow-y: auto;
/* max-height: 900px; */
min-height: 0;
padding: 0 14px 24px;
/* Thin scrollbar styling */
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, currentColor 30%, transparent) transparent;
}
.events {
display: flex;
flex-direction: column;
gap: 10px;
}
.event-status {
align-self: center;
justify-self: end;
text-align: end;
flex: 1;
}
.event-winner {
color: var(--winner-color);
}
/* 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;
}
.ongoing-event {
background-color: color-mix(in srgb, currentColor 18%, transparent);
color: var(--ctp-latte-peach);
}
.completed-event {
border-color: var(--event-color);
/* If you want a subtle background tint like ongoing events usually have: */
background-color: color-mix(in srgb, var(--event-color) 10%, transparent);
}
/* Pulse animation for focused event card */
.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;
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;
}
/* Mobile: single column layout */
@media (max-width: 479px) {
.brackets {
flex-direction: row;
align-items: stretch;
}
.bracket-sep {
width: 1px;
height: auto;
margin: 10px 0;
align-self: stretch;
}
.bracket-row {
flex-direction: column;
min-width: 0;
flex: 1;
}
.player-box {
max-width: 100%;
flex: 1; /* equal height across all player boxes in the column */
}
.bracket-row {
flex: 1;
flex-direction: column;
align-items: stretch; /* player boxes fill the full column width */
}
.brackets-name {
text-align: center;
align-items: center;
}
.brackets-name-text {
text-align: center;
}
}
@media (min-width: 700px) {
.player-box {
max-width: calc(25% - 6px);
}
}
.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;
text-decoration: none;
overflow: hidden;
white-space: nowrap;
}
.player-name-wrap:hover {
text-decoration: underline;
}
.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: 50px;
}

View File

@@ -0,0 +1,23 @@
// src/routes/admin/+page.server.ts
import { error, redirect } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { scorers } from '$lib/server/db/schema';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) {
throw redirect(303, '/login');
}
const [row] = await db
.select({ role: scorers.role })
.from(scorers)
.where(eq(scorers.id, locals.user.id));
if (row?.role !== 'admin') {
throw error(403, 'Forbidden');
}
return { user: locals.user };
};

View File

@@ -0,0 +1 @@
<div>some</div>

View File

@@ -0,0 +1,32 @@
// src/routes/login/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { scorers } from '$lib/server/db/schema';
import { generateSessionToken, createSession, setSessionTokenCookie } from '$lib/server/auth';
import type { Actions } from '../login/$types';
import type { PageServerLoad } from '../login/$types';
export const load: PageServerLoad = async ({ locals }) => {
return { user: locals.user };
};
export const actions: Actions = {
default: async (event) => {
const data = await event.request.formData();
const username = data.get('username') as string;
const password = data.get('password') as string;
const [user] = await db.select().from(scorers).where(eq(scorers.username, username));
if (!user || !(await Bun.password.verify(password, user.passwordHash))) {
return fail(400, { message: 'Invalid credentials' });
}
const token = generateSessionToken();
const session = await createSession(token, user.id);
setSessionTokenCookie(event, token, session.expiresAt);
throw redirect(303, '/');
}
};

View File

@@ -0,0 +1,90 @@
<script lang="ts">
import type { ActionData, PageData } from './$types';
export let data: PageData;
export let form: ActionData;
</script>
<div class="auth-card">
{#if data.user}
<div class="align-center flex h-full w-full flex-col text-center">
<h1>Already logged in</h1>
<p>You're signed in as <strong>{data.user.username}</strong></p>
<a href="/">Go to home</a>
<form method="POST" action="/api/logout">
<button type="submit">Log out</button>
</form>
</div>
{:else}
<h1>Log in</h1>
<form method="POST">
<label>
Username
<input class="text-black" name="username" type="text" autocomplete="username" required />
</label>
<label>
Password
<input
class="text-black"
name="password"
type="password"
autocomplete="current-password"
required
/>
</label>
{#if form?.message}
<p class="error">{form.message}</p>
{/if}
<button type="submit">Log in</button>
</form>
{/if}
<!-- <p class="switch">No account? <a href="/signup">Sign up</a></p> -->
</div>
<style>
.auth-card {
max-width: 320px;
margin: 4rem auto;
padding: 2rem;
border: 1px solid #ddd;
border-radius: 8px;
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.9rem;
}
input {
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
padding: 0.6rem;
border: none;
border-radius: 4px;
background: #333;
color: #fff;
cursor: pointer;
}
.error {
color: #c0392b;
font-size: 0.85rem;
margin: 0;
}
.switch {
margin-top: 1rem;
font-size: 0.85rem;
text-align: center;
}
</style>

View File

@@ -0,0 +1,35 @@
// src/routes/signup/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { scorers } from '$lib/server/db/schema';
import { generateSessionToken, createSession, setSessionTokenCookie } from '$lib/server/auth';
import type { Actions } from '../signup/$types';
export const actions: Actions = {
default: async (event) => {
const data = await event.request.formData();
const username = data.get('username') as string;
const password = data.get('password') as string;
if (!username || !password || password.length < 8) {
return fail(400, { message: 'Username and an 8+ character password are required' });
}
const [existing] = await db.select().from(scorers).where(eq(scorers.username, username));
if (existing) {
return fail(400, { message: 'Username already taken' });
}
const passwordHash = await Bun.password.hash(password);
const userId = crypto.randomUUID();
await db.insert(scorers).values({ id: userId, username, passwordHash });
const token = generateSessionToken();
const session = await createSession(token, userId);
setSessionTokenCookie(event, token, session.expiresAt);
throw redirect(303, '/');
}
};

View File

@@ -0,0 +1,83 @@
<script lang="ts">
import type { ActionData } from './$types';
export let form: ActionData;
</script>
<div class="auth-card">
<h1>Sign up</h1>
<form method="POST">
<label>
Username
<input name="username" class="text-black" type="text" autocomplete="username" required />
</label>
<label>
Password
<input
class="text-black"
name="password"
type="password"
autocomplete="new-password"
minlength="8"
required
/>
<small>At least 8 characters</small>
</label>
{#if form?.message}
<p class="error">{form.message}</p>
{/if}
<button type="submit">Create account</button>
</form>
<p class="switch">Already have an account? <a href="/login">Log in</a></p>
</div>
<style>
.auth-card {
max-width: 320px;
margin: 4rem auto;
padding: 2rem;
border: 1px solid #ddd;
border-radius: 8px;
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.9rem;
}
input {
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}
small {
color: #888;
}
button {
padding: 0.6rem;
border: none;
border-radius: 4px;
background: #333;
color: #fff;
cursor: pointer;
}
.error {
color: #c0392b;
font-size: 0.85rem;
margin: 0;
}
.switch {
margin-top: 1rem;
font-size: 0.85rem;
text-align: center;
}
</style>

View File

@@ -0,0 +1,14 @@
import { getPlayerInfo } from '$lib/server/databaseManager';
import type { PageServerLoad } from './$types';
// export const load: PageServerLoad = async ({ params }) => {
// return {
// data: await getPlayerInfo(parseInt(params.playerId))
// };
// };
export const load: PageServerLoad = async ({ params }) => {
return {
playerInfo: getPlayerInfo(parseInt(params.playerId))
};
};

View File

@@ -0,0 +1,75 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
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>
{#await data.playerInfo}
<div>Loading...</div>
{:then playerInfo}
{console.log(playerInfo)}
<div class="mt-5 flex justify-center">
<div
class="player-single-box aspect-square w-full max-w-[95vw] justify-center md:aspect-2/1 lg:aspect-2/1"
style="--c:{playerInfo.teamInfo.color}"
>
<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">{playerInfo.firstName}</text
>
</svg>
</div>
<div class=" goldman player-name-wrap text-5xl" use:marquee>
{playerInfo.firstName}
{playerInfo.lastName}
</div>
</div>
</div>
{/await}
<style>
.player-single-box {
flex: 1 1 0;
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;
}
.player-name-wrap {
position: relative;
z-index: 1;
text-decoration: none;
overflow: hidden;
white-space: nowrap;
}
</style>

View File

@@ -3,13 +3,10 @@ import adapter from '@sveltejs/adapter-auto';
/** @type {import('@sveltejs/kit').Config} */
const config = {
compilerOptions: {
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
// Force runes mode (except in node_modules). Remove when Svelte 6 makes it default.
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
},
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter(),
typescript: {

View File

@@ -5,14 +5,22 @@
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"types": ["bun"],
"types": ["bun", "node"],
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
},
"include": [
"src/**/*.js",
"src/**/*.ts",
"src/**/*.svelte",
"scripts/**/*.ts"
]
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//

View File

@@ -2,4 +2,11 @@ import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({ plugins: [tailwindcss(), sveltekit()] });
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
watch: {
ignored: ['**/.devenv/**']
}
}
});