Compare commits
13 Commits
ec56e3794e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b636243ff | |||
| 17acf0a497 | |||
| a3ead298af | |||
| 8394ad45c8 | |||
| feecae7792 | |||
| 7f9a03eed6 | |||
| 097ff77c8a | |||
| df8f5bc9a9 | |||
| 5e03a5798f | |||
| 4df2bc697d | |||
| 07c27ffaaa | |||
| ac4f02f597 | |||
| f93f407d66 |
11
TODO.md
11
TODO.md
@@ -2,15 +2,6 @@
|
|||||||
player view - all registered events and scores for those events
|
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?
|
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
|
# Database - HIGH PRIORITY
|
||||||
load results from the excel sheet i made
|
load results from the excel sheet i made
|
||||||
export whole contest results to csv
|
export whole contest results to csv
|
||||||
@@ -29,7 +20,5 @@ result amendments
|
|||||||
|
|
||||||
## Easy shit
|
## Easy shit
|
||||||
animations for all leaderboards
|
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 layout on event view to match the homepage view
|
||||||
fix the marquee animation thing and make it global
|
fix the marquee animation thing and make it global
|
||||||
|
|||||||
7450
package-lock.json
generated
Normal file
7450
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@
|
|||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@libsql/client": "^0.17.2",
|
"@libsql/client": "^0.17.2",
|
||||||
"@sveltejs/adapter-auto": "^7.0.1",
|
"@sveltejs/adapter-auto": "^7.0.1",
|
||||||
|
"@sveltejs/adapter-node": "^5.5.7",
|
||||||
"@sveltejs/kit": "^2.57.0",
|
"@sveltejs/kit": "^2.57.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
|
|||||||
1
run.sh
Normal file
1
run.sh
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DATABASE_URL="file:local.db" ORIGIN="http://localhost:3000" node build/index.js
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
div_name
|
div_name
|
||||||
|
Seniors
|
||||||
Year 7
|
Year 7
|
||||||
Year 8
|
Year 8
|
||||||
Year 9
|
Year 9
|
||||||
|
|||||||
|
@@ -1,9 +1,9 @@
|
|||||||
event_name,preset,resultPreset
|
event_name,preset,resultPreset
|
||||||
100m Sprint,1,race
|
100m Sprint,1,race
|
||||||
200m Sprint,1,race
|
200m Sprint,1,race
|
||||||
500m Relay,1,race
|
400m Race,1,race
|
||||||
750m Race,1,race
|
800m Race,1,race
|
||||||
1000m Long Distance,1,race
|
1500m Long Distance,1,race
|
||||||
Javelin, 1,three
|
Javelin, 1,three
|
||||||
Shotput, 1,three
|
Shotput, 1,three
|
||||||
Long Jump, 1,three
|
Long Jump, 1,three
|
||||||
|
|||||||
|
@@ -1,151 +0,0 @@
|
|||||||
firstName,lastName,team,division
|
|
||||||
Alex,Mercer,East,Year 7
|
|
||||||
Sarah,Connor,West,Year 8
|
|
||||||
Miles,Morales,Laud,Year 9
|
|
||||||
Bruce,Wayne,School,Year 10
|
|
||||||
Clark,Kent,County,Year 10
|
|
||||||
Jamie,Fletcher,East,Year 7
|
|
||||||
Priya,Sharma,West,Year 7
|
|
||||||
Owen,Davies,Laud,Year 7
|
|
||||||
Isla,Thompson,School,Year 7
|
|
||||||
Noah,Patel,County,Year 7
|
|
||||||
Chloe,Griffiths,East,Year 8
|
|
||||||
Ethan,Brooks,West,Year 8
|
|
||||||
Amara,Osei,Laud,Year 8
|
|
||||||
Callum,Reid,School,Year 8
|
|
||||||
Zara,Hussain,County,Year 8
|
|
||||||
Luca,Martini,East,Year 9
|
|
||||||
Hannah,Walters,West,Year 9
|
|
||||||
Tobias,Crane,Laud,Year 9
|
|
||||||
Fatima,Al-Rashid,School,Year 9
|
|
||||||
Reuben,Stone,County,Year 9
|
|
||||||
Ava,Mackenzie,East,Year 10
|
|
||||||
Dylan,Hargreaves,West,Year 10
|
|
||||||
Nadia,Kowalski,Laud,Year 10
|
|
||||||
Marcus,Okafor,School,Year 10
|
|
||||||
Ellie,Sutton,County,Year 10
|
|
||||||
Ben,Whitfield,East,Year 7
|
|
||||||
Mia,Nguyen,West,Year 9
|
|
||||||
Jude,Barlow,Laud,Year 8
|
|
||||||
Sofia,Mendez,School,Year 7
|
|
||||||
Aaron,Fitzgerald,County,Year 9
|
|
||||||
Leah,Armstrong,East,Year 8
|
|
||||||
Theo,Baldwin,West,Year 10
|
|
||||||
Aisha,Ibrahim,Laud,Year 7
|
|
||||||
Connor,Gallagher,School,Year 9
|
|
||||||
Ruby,Thornton,County,Year 8
|
|
||||||
Finn,McCabe,East,Year 9
|
|
||||||
Yasmin,Khalid,West,Year 7
|
|
||||||
Jack,Simmons,Laud,Year 10
|
|
||||||
Niamh,O'Brien,School,Year 8
|
|
||||||
Leo,Chambers,County,Year 7
|
|
||||||
Grace,Henson,East,Year 10
|
|
||||||
Ravi,Anand,West,Year 8
|
|
||||||
Sienna,Park,Laud,Year 9
|
|
||||||
Oliver,Nwosu,School,Year 10
|
|
||||||
Tara,Lawson,County,Year 9
|
|
||||||
Elliot,Pearce,East,Year 7
|
|
||||||
Demi,Adeyemi,West,Year 9
|
|
||||||
Samuel,Kerr,Laud,Year 8
|
|
||||||
Imogen,Hardy,School,Year 7
|
|
||||||
Kai,Johansson,County,Year 10
|
|
||||||
Maya,Adebayo,School,Year 10
|
|
||||||
Liam,Lindqvist,West,Year 8
|
|
||||||
Zoe,Ferrara,West,Year 8
|
|
||||||
Freya,Okonkwo,West,Year 7
|
|
||||||
Kofi,Castellanos,Laud,Year 10
|
|
||||||
Harriet,Mbeki,County,Year 7
|
|
||||||
Tariq,Holroyd,County,Year 10
|
|
||||||
Pippa,Nazari,West,Year 10
|
|
||||||
Declan,Ashworth,County,Year 8
|
|
||||||
Ananya,Drummond,Laud,Year 10
|
|
||||||
Rhys,Chakraborty,West,Year 8
|
|
||||||
Celeste,Malone,School,Year 8
|
|
||||||
Jerome,Vasquez,County,Year 9
|
|
||||||
Ingrid,Okeke,East,Year 7
|
|
||||||
Dante,Sorensen,School,Year 9
|
|
||||||
Moira,Kimura,School,Year 7
|
|
||||||
Soren,Petrov,Laud,Year 7
|
|
||||||
Lydia,Andersen,School,Year 8
|
|
||||||
Hector,Voss,Laud,Year 7
|
|
||||||
Xanthe,Reyes,School,Year 8
|
|
||||||
Caden,Nakamura,County,Year 10
|
|
||||||
Petra,Brennan,East,Year 9
|
|
||||||
Idris,Svensson,West,Year 8
|
|
||||||
Wren,Otieno,West,Year 10
|
|
||||||
Bastian,Mirza,County,Year 8
|
|
||||||
Orla,Yamamoto,County,Year 8
|
|
||||||
Kieran,Diallo,East,Year 7
|
|
||||||
Fleur,Henriksen,East,Year 8
|
|
||||||
Matteo,Costa,School,Year 10
|
|
||||||
Saoirse,Takahashi,East,Year 10
|
|
||||||
Felix,Mensah,East,Year 8
|
|
||||||
Indira,Eriksson,Laud,Year 7
|
|
||||||
Rowan,Chukwu,School,Year 9
|
|
||||||
Margot,Brandt,West,Year 10
|
|
||||||
Ezra,Magnusson,West,Year 7
|
|
||||||
Blythe,Eze,West,Year 10
|
|
||||||
Cormac,Halvorsen,Laud,Year 10
|
|
||||||
Emeka,Abubakar,West,Year 9
|
|
||||||
Zinnia,Lindberg,School,Year 7
|
|
||||||
Caspian,Odunbaku,West,Year 8
|
|
||||||
Rafferty,Bjornstad,County,Year 10
|
|
||||||
Seraphina,Haugen,East,Year 7
|
|
||||||
Evander,Solberg,East,Year 8
|
|
||||||
Niobe,Dahl,Laud,Year 8
|
|
||||||
Alaric,Olawale,West,Year 10
|
|
||||||
Cyrus,Skoglund,West,Year 8
|
|
||||||
Isolde,Ihejirika,County,Year 9
|
|
||||||
Theron,Nzeogwu,School,Year 8
|
|
||||||
Briar,Igwe,Laud,Year 7
|
|
||||||
Caius,Falola,Laud,Year 8
|
|
||||||
Elowen,Bergstrom,East,Year 9
|
|
||||||
Stellan,Amaechi,East,Year 7
|
|
||||||
Vesper,Thorvaldsen,Laud,Year 8
|
|
||||||
Dorian,Obiora,School,Year 10
|
|
||||||
Thessaly,Hellstrom,Laud,Year 10
|
|
||||||
Oisin,Achebe,School,Year 10
|
|
||||||
Calixto,Gustafsson,Laud,Year 7
|
|
||||||
Astrid,Emecheta,West,Year 7
|
|
||||||
Leandro,Danielsson,Laud,Year 10
|
|
||||||
Ottilie,Onyekachi,School,Year 8
|
|
||||||
Hamish,Ekstrom,Laud,Year 8
|
|
||||||
Xiomara,Azikiwe,Laud,Year 7
|
|
||||||
Aarav,Holm,Laud,Year 8
|
|
||||||
Nkechi,Nwachukwu,West,Year 10
|
|
||||||
Torben,Lund,Laud,Year 10
|
|
||||||
Siobhan,Onyeka,East,Year 10
|
|
||||||
Emiliano,Sandberg,School,Year 9
|
|
||||||
Adaeze,Oduya,East,Year 9
|
|
||||||
Callista,Hedlund,Laud,Year 10
|
|
||||||
Bram,Afolabi,County,Year 8
|
|
||||||
Nneka,Stromberg,Laud,Year 10
|
|
||||||
Solange,Ezeoke,Laud,Year 10
|
|
||||||
Peregrine,Lundgren,County,Year 8
|
|
||||||
Zephyr,Anyanwu,Laud,Year 10
|
|
||||||
Liora,Wikstrom,County,Year 10
|
|
||||||
Cashel,Okereke,West,Year 10
|
|
||||||
Ife,Osborn,Laud,Year 8
|
|
||||||
Araminta,Uchenna,Laud,Year 7
|
|
||||||
Ptolemy,Strand,Laud,Year 9
|
|
||||||
Sunniva,Obi,West,Year 8
|
|
||||||
Dagny,Norberg,Laud,Year 10
|
|
||||||
Leif,Ekwueme,West,Year 10
|
|
||||||
Amara,Akesson,Laud,Year 10
|
|
||||||
Caoimhe,Onyia,East,Year 7
|
|
||||||
Sefton,Hellgren,County,Year 8
|
|
||||||
Oluseun,Alade,East,Year 9
|
|
||||||
Ximena,Thorsen,West,Year 10
|
|
||||||
Vigdis,Ekene,West,Year 9
|
|
||||||
Obinna,Skoog,School,Year 10
|
|
||||||
Birgitta,Nwosu,County,Year 9
|
|
||||||
Nnamdi,Bergqvist,West,Year 7
|
|
||||||
Soleil,Anozie,East,Year 9
|
|
||||||
Aderemi,Sjogren,East,Year 7
|
|
||||||
Thorsten,Nwofor,East,Year 10
|
|
||||||
Chisom,Karlsson,Laud,Year 9
|
|
||||||
Viggo,Ezeh,County,Year 9
|
|
||||||
Adaora,Sjoberg,West,Year 7
|
|
||||||
Bjorn,Oyelaran,School,Year 10
|
|
||||||
Uchenna,Friberg,West,Year 9
|
|
||||||
Sigrid,Adebayo,School,Year 9
|
|
||||||
|
|||||||
|
151
scripts/data/players.csv.some
Normal file
151
scripts/data/players.csv.some
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
firstName,lastName,team,division
|
||||||
|
Alex,Mercer,East,Year 7
|
||||||
|
Sarah,Connor,West,Year 8
|
||||||
|
Miles,Morales,Laud,Year 9
|
||||||
|
Bruce,Wayne,School,Year 10
|
||||||
|
Clark,Kent,County,Year 10
|
||||||
|
Jamie,Fletcher,East,Year 7
|
||||||
|
Priya,Sharma,West,Year 7
|
||||||
|
Owen,Davies,Laud,Year 7
|
||||||
|
Isla,Thompson,School,Year 7
|
||||||
|
Noah,Patel,County,Year 7
|
||||||
|
Chloe,Griffiths,East,Year 8
|
||||||
|
Ethan,Brooks,West,Year 8
|
||||||
|
Amara,Osei,Laud,Year 8
|
||||||
|
Callum,Reid,School,Year 8
|
||||||
|
Zara,Hussain,County,Year 8
|
||||||
|
Luca,Martini,East,Year 9
|
||||||
|
Hannah,Walters,West,Year 9
|
||||||
|
Tobias,Crane,Laud,Year 9
|
||||||
|
Fatima,Al-Rashid,School,Year 9
|
||||||
|
Reuben,Stone,County,Year 9
|
||||||
|
Ava,Mackenzie,East,Year 10
|
||||||
|
Dylan,Hargreaves,West,Year 10
|
||||||
|
Nadia,Kowalski,Laud,Year 10
|
||||||
|
Marcus,Okafor,School,Year 10
|
||||||
|
Ellie,Sutton,County,Year 10
|
||||||
|
Ben,Whitfield,East,Year 7
|
||||||
|
Mia,Nguyen,West,Year 9
|
||||||
|
Jude,Barlow,Laud,Year 8
|
||||||
|
Sofia,Mendez,School,Year 7
|
||||||
|
Aaron,Fitzgerald,County,Year 9
|
||||||
|
Leah,Armstrong,East,Year 8
|
||||||
|
Theo,Baldwin,West,Year 10
|
||||||
|
Aisha,Ibrahim,Laud,Year 7
|
||||||
|
Connor,Gallagher,School,Year 9
|
||||||
|
Ruby,Thornton,County,Year 8
|
||||||
|
Finn,McCabe,East,Year 9
|
||||||
|
Yasmin,Khalid,West,Year 7
|
||||||
|
Jack,Simmons,Laud,Year 10
|
||||||
|
Niamh,O'Brien,School,Year 8
|
||||||
|
Leo,Chambers,County,Year 7
|
||||||
|
Grace,Henson,East,Year 10
|
||||||
|
Ravi,Anand,West,Year 8
|
||||||
|
Sienna,Park,Laud,Year 9
|
||||||
|
Oliver,Nwosu,School,Year 10
|
||||||
|
Tara,Lawson,County,Year 9
|
||||||
|
Elliot,Pearce,East,Year 7
|
||||||
|
Demi,Adeyemi,West,Year 9
|
||||||
|
Samuel,Kerr,Laud,Year 8
|
||||||
|
Imogen,Hardy,School,Year 7
|
||||||
|
Kai,Johansson,County,Year 10
|
||||||
|
Maya,Adebayo,School,Year 10
|
||||||
|
Liam,Lindqvist,West,Year 8
|
||||||
|
Zoe,Ferrara,West,Year 8
|
||||||
|
Freya,Okonkwo,West,Year 7
|
||||||
|
Kofi,Castellanos,Laud,Year 10
|
||||||
|
Harriet,Mbeki,County,Year 7
|
||||||
|
Tariq,Holroyd,County,Year 10
|
||||||
|
Pippa,Nazari,West,Year 10
|
||||||
|
Declan,Ashworth,County,Year 8
|
||||||
|
Ananya,Drummond,Laud,Year 10
|
||||||
|
Rhys,Chakraborty,West,Year 8
|
||||||
|
Celeste,Malone,School,Year 8
|
||||||
|
Jerome,Vasquez,County,Year 9
|
||||||
|
Ingrid,Okeke,East,Year 7
|
||||||
|
Dante,Sorensen,School,Year 9
|
||||||
|
Moira,Kimura,School,Year 7
|
||||||
|
Soren,Petrov,Laud,Year 7
|
||||||
|
Lydia,Andersen,School,Year 8
|
||||||
|
Hector,Voss,Laud,Year 7
|
||||||
|
Xanthe,Reyes,School,Year 8
|
||||||
|
Caden,Nakamura,County,Year 10
|
||||||
|
Petra,Brennan,East,Year 9
|
||||||
|
Idris,Svensson,West,Year 8
|
||||||
|
Wren,Otieno,West,Year 10
|
||||||
|
Bastian,Mirza,County,Year 8
|
||||||
|
Orla,Yamamoto,County,Year 8
|
||||||
|
Kieran,Diallo,East,Year 7
|
||||||
|
Fleur,Henriksen,East,Year 8
|
||||||
|
Matteo,Costa,School,Year 10
|
||||||
|
Saoirse,Takahashi,East,Year 10
|
||||||
|
Felix,Mensah,East,Year 8
|
||||||
|
Indira,Eriksson,Laud,Year 7
|
||||||
|
Rowan,Chukwu,School,Year 9
|
||||||
|
Margot,Brandt,West,Year 10
|
||||||
|
Ezra,Magnusson,West,Year 7
|
||||||
|
Blythe,Eze,West,Year 10
|
||||||
|
Cormac,Halvorsen,Laud,Year 10
|
||||||
|
Emeka,Abubakar,West,Year 9
|
||||||
|
Zinnia,Lindberg,School,Year 7
|
||||||
|
Caspian,Odunbaku,West,Year 8
|
||||||
|
Rafferty,Bjornstad,County,Year 10
|
||||||
|
Seraphina,Haugen,East,Year 7
|
||||||
|
Evander,Solberg,East,Year 8
|
||||||
|
Niobe,Dahl,Laud,Year 8
|
||||||
|
Alaric,Olawale,West,Year 10
|
||||||
|
Cyrus,Skoglund,West,Year 8
|
||||||
|
Isolde,Ihejirika,County,Year 9
|
||||||
|
Theron,Nzeogwu,School,Year 8
|
||||||
|
Briar,Igwe,Laud,Year 7
|
||||||
|
Caius,Falola,Laud,Year 8
|
||||||
|
Elowen,Bergstrom,East,Year 9
|
||||||
|
Stellan,Amaechi,East,Year 7
|
||||||
|
Vesper,Thorvaldsen,Laud,Year 8
|
||||||
|
Dorian,Obiora,School,Year 10
|
||||||
|
Thessaly,Hellstrom,Laud,Year 10
|
||||||
|
Oisin,Achebe,School,Year 10
|
||||||
|
Calixto,Gustafsson,Laud,Year 7
|
||||||
|
Astrid,Emecheta,West,Year 7
|
||||||
|
Leandro,Danielsson,Laud,Year 10
|
||||||
|
Ottilie,Onyekachi,School,Year 8
|
||||||
|
Hamish,Ekstrom,Laud,Year 8
|
||||||
|
Xiomara,Azikiwe,Laud,Year 7
|
||||||
|
Aarav,Holm,Laud,Year 8
|
||||||
|
Nkechi,Nwachukwu,West,Year 10
|
||||||
|
Torben,Lund,Laud,Year 10
|
||||||
|
Siobhan,Onyeka,East,Year 10
|
||||||
|
Emiliano,Sandberg,School,Year 9
|
||||||
|
Adaeze,Oduya,East,Year 9
|
||||||
|
Callista,Hedlund,Laud,Year 10
|
||||||
|
Bram,Afolabi,County,Year 8
|
||||||
|
Nneka,Stromberg,Laud,Year 10
|
||||||
|
Solange,Ezeoke,Laud,Year 10
|
||||||
|
Peregrine,Lundgren,County,Year 8
|
||||||
|
Zephyr,Anyanwu,Laud,Year 10
|
||||||
|
Liora,Wikstrom,County,Year 10
|
||||||
|
Cashel,Okereke,West,Year 10
|
||||||
|
Ife,Osborn,Laud,Year 8
|
||||||
|
Araminta,Uchenna,Laud,Year 7
|
||||||
|
Ptolemy,Strand,Laud,Year 9
|
||||||
|
Sunniva,Obi,West,Year 8
|
||||||
|
Dagny,Norberg,Laud,Year 10
|
||||||
|
Leif,Ekwueme,West,Year 10
|
||||||
|
Amara,Akesson,Laud,Year 10
|
||||||
|
Caoimhe,Onyia,East,Year 7
|
||||||
|
Sefton,Hellgren,County,Year 8
|
||||||
|
Oluseun,Alade,East,Year 9
|
||||||
|
Ximena,Thorsen,West,Year 10
|
||||||
|
Vigdis,Ekene,West,Year 9
|
||||||
|
Obinna,Skoog,School,Year 10
|
||||||
|
Birgitta,Nwosu,County,Year 9
|
||||||
|
Nnamdi,Bergqvist,West,Year 7
|
||||||
|
Soleil,Anozie,East,Year 9
|
||||||
|
Aderemi,Sjogren,East,Year 7
|
||||||
|
Thorsten,Nwofor,East,Year 10
|
||||||
|
Chisom,Karlsson,Laud,Year 9
|
||||||
|
Viggo,Ezeh,County,Year 9
|
||||||
|
Adaora,Sjoberg,West,Year 7
|
||||||
|
Bjorn,Oyelaran,School,Year 10
|
||||||
|
Uchenna,Friberg,West,Year 9
|
||||||
|
Sigrid,Adebayo,School,Year 9
|
||||||
@@ -1,541 +0,0 @@
|
|||||||
player_registered,event_registered,player_placement,bracket
|
|
||||||
Ava Mackenzie,1000m Long Distance,0,A
|
|
||||||
Bjorn Oyelaran,1000m Long Distance,0,A
|
|
||||||
Clark Kent,1000m Long Distance,0,A
|
|
||||||
Leif Ekwueme,1000m Long Distance,0,A
|
|
||||||
Zephyr Anyanwu,1000m Long Distance,0,A
|
|
||||||
Dylan Hargreaves,1000m Long Distance,0,B
|
|
||||||
Nadia Kowalski,1000m Long Distance,0,B
|
|
||||||
Obinna Skoog,1000m Long Distance,0,B
|
|
||||||
Rafferty Bjornstad,1000m Long Distance,0,B
|
|
||||||
Siobhan Onyeka,1000m Long Distance,0,B
|
|
||||||
Ellie Sutton,1000m Long Distance,0,C
|
|
||||||
Jack Simmons,1000m Long Distance,0,C
|
|
||||||
Matteo Costa,1000m Long Distance,0,C
|
|
||||||
Thorsten Nwofor,1000m Long Distance,0,C
|
|
||||||
Wren Otieno,1000m Long Distance,0,C
|
|
||||||
Harriet Mbeki,1000m Long Distance,0,A
|
|
||||||
Hector Voss,1000m Long Distance,0,A
|
|
||||||
Jamie Fletcher,1000m Long Distance,0,A
|
|
||||||
Sofia Mendez,1000m Long Distance,0,A
|
|
||||||
Yasmin Khalid,1000m Long Distance,0,A
|
|
||||||
Caoimhe Onyia,1000m Long Distance,0,B
|
|
||||||
Freya Okonkwo,1000m Long Distance,0,B
|
|
||||||
Imogen Hardy,1000m Long Distance,0,B
|
|
||||||
Leo Chambers,1000m Long Distance,0,B
|
|
||||||
Xiomara Azikiwe,1000m Long Distance,0,B
|
|
||||||
Aderemi Sjogren,1000m Long Distance,0,C
|
|
||||||
Araminta Uchenna,1000m Long Distance,0,C
|
|
||||||
Ezra Magnusson,1000m Long Distance,0,C
|
|
||||||
Isla Thompson,1000m Long Distance,0,C
|
|
||||||
Noah Patel,1000m Long Distance,0,C
|
|
||||||
Amara Osei,1000m Long Distance,0,A
|
|
||||||
Ethan Brooks,1000m Long Distance,0,A
|
|
||||||
Evander Solberg,1000m Long Distance,0,A
|
|
||||||
Theron Nzeogwu,1000m Long Distance,0,A
|
|
||||||
Zara Hussain,1000m Long Distance,0,A
|
|
||||||
Chloe Griffiths,1000m Long Distance,0,B
|
|
||||||
Lydia Andersen,1000m Long Distance,0,B
|
|
||||||
Niobe Dahl,1000m Long Distance,0,B
|
|
||||||
Orla Yamamoto,1000m Long Distance,0,B
|
|
||||||
Zoe Ferrara,1000m Long Distance,0,B
|
|
||||||
Fleur Henriksen,1000m Long Distance,0,C
|
|
||||||
Ife Osborn,1000m Long Distance,0,C
|
|
||||||
Rhys Chakraborty,1000m Long Distance,0,C
|
|
||||||
Ruby Thornton,1000m Long Distance,0,C
|
|
||||||
Xanthe Reyes,1000m Long Distance,0,C
|
|
||||||
Emeka Abubakar,1000m Long Distance,0,A
|
|
||||||
Finn McCabe,1000m Long Distance,0,A
|
|
||||||
Miles Morales,1000m Long Distance,0,A
|
|
||||||
Rowan Chukwu,1000m Long Distance,0,A
|
|
||||||
Tara Lawson,1000m Long Distance,0,A
|
|
||||||
Adaeze Oduya,1000m Long Distance,0,B
|
|
||||||
Chisom Karlsson,1000m Long Distance,0,B
|
|
||||||
Demi Adeyemi,1000m Long Distance,0,B
|
|
||||||
Sigrid Adebayo,1000m Long Distance,0,B
|
|
||||||
Viggo Ezeh,1000m Long Distance,0,B
|
|
||||||
Aaron Fitzgerald,1000m Long Distance,0,C
|
|
||||||
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,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
|
|
||||||
Siobhan Onyeka,100m Sprint,0,B
|
|
||||||
Ximena Thorsen,100m Sprint,0,B
|
|
||||||
Bruce Wayne,100m Sprint,0,C
|
|
||||||
Leandro Danielsson,100m Sprint,0,C
|
|
||||||
Tariq Holroyd,100m Sprint,0,C
|
|
||||||
Thorsten Nwofor,100m Sprint,0,C
|
|
||||||
Wren Otieno,100m Sprint,0,C
|
|
||||||
Indira Eriksson,100m Sprint,0,A
|
|
||||||
Jamie Fletcher,100m Sprint,0,A
|
|
||||||
Noah Patel,100m Sprint,0,A
|
|
||||||
Priya Sharma,100m Sprint,0,A
|
|
||||||
Sofia Mendez,100m Sprint,0,A
|
|
||||||
Adaora Sjoberg,100m Sprint,0,B
|
|
||||||
Aisha Ibrahim,100m Sprint,0,B
|
|
||||||
Elliot Pearce,100m Sprint,0,B
|
|
||||||
Harriet Mbeki,100m Sprint,0,B
|
|
||||||
Isla Thompson,100m Sprint,0,B
|
|
||||||
Alex Mercer,100m Sprint,0,C
|
|
||||||
Imogen Hardy,100m Sprint,0,C
|
|
||||||
Leo Chambers,100m Sprint,0,C
|
|
||||||
Owen Davies,100m Sprint,0,C
|
|
||||||
Yasmin Khalid,100m Sprint,0,C
|
|
||||||
Amara Osei,100m Sprint,0,A
|
|
||||||
Bastian Mirza,100m Sprint,0,A
|
|
||||||
Evander Solberg,100m Sprint,0,A
|
|
||||||
Sunniva Obi,100m Sprint,0,A
|
|
||||||
Xanthe Reyes,100m Sprint,0,A
|
|
||||||
Declan Ashworth,100m Sprint,0,B
|
|
||||||
Felix Mensah,100m Sprint,0,B
|
|
||||||
Ife Osborn,100m Sprint,0,B
|
|
||||||
Liam Lindqvist,100m Sprint,0,B
|
|
||||||
Theron Nzeogwu,100m Sprint,0,B
|
|
||||||
Chloe Griffiths,100m Sprint,0,C
|
|
||||||
Hamish Ekstrom,100m Sprint,0,C
|
|
||||||
Lydia Andersen,100m Sprint,0,C
|
|
||||||
Orla Yamamoto,100m Sprint,0,C
|
|
||||||
Ravi Anand,100m Sprint,0,C
|
|
||||||
Dante Sorensen,100m Sprint,0,A
|
|
||||||
Mia Nguyen,100m Sprint,0,A
|
|
||||||
Petra Brennan,100m Sprint,0,A
|
|
||||||
Reuben Stone,100m Sprint,0,A
|
|
||||||
Tobias Crane,100m Sprint,0,A
|
|
||||||
Jerome Vasquez,100m Sprint,0,B
|
|
||||||
Luca Martini,100m Sprint,0,B
|
|
||||||
Miles Morales,100m Sprint,0,B
|
|
||||||
Rowan Chukwu,100m Sprint,0,B
|
|
||||||
Vigdis Ekene,100m Sprint,0,B
|
|
||||||
Emeka Abubakar,100m Sprint,0,C
|
|
||||||
Sienna Park,100m Sprint,0,C
|
|
||||||
Sigrid Adebayo,100m Sprint,0,C
|
|
||||||
Soleil Anozie,100m Sprint,0,C
|
|
||||||
Viggo Ezeh,100m Sprint,0,C
|
|
||||||
Caden Nakamura,200m Sprint,0,A
|
|
||||||
Cashel Okereke,200m Sprint,0,A
|
|
||||||
Cormac Halvorsen,200m Sprint,0,A
|
|
||||||
Oisin Achebe,200m Sprint,0,A
|
|
||||||
Thorsten Nwofor,200m Sprint,0,A
|
|
||||||
Ananya Drummond,200m Sprint,0,B
|
|
||||||
Blythe Eze,200m Sprint,0,B
|
|
||||||
Oliver Nwosu,200m Sprint,0,B
|
|
||||||
Rafferty Bjornstad,200m Sprint,0,B
|
|
||||||
Siobhan Onyeka,200m Sprint,0,B
|
|
||||||
Bjorn Oyelaran,200m Sprint,0,C
|
|
||||||
Clark Kent,200m Sprint,0,C
|
|
||||||
Grace Henson,200m Sprint,0,C
|
|
||||||
Nadia Kowalski,200m Sprint,0,C
|
|
||||||
Theo Baldwin,200m Sprint,0,C
|
|
||||||
Ezra Magnusson,200m Sprint,0,A
|
|
||||||
Harriet Mbeki,200m Sprint,0,A
|
|
||||||
Indira Eriksson,200m Sprint,0,A
|
|
||||||
Jamie Fletcher,200m Sprint,0,A
|
|
||||||
Moira Kimura,200m Sprint,0,A
|
|
||||||
Calixto Gustafsson,200m Sprint,0,B
|
|
||||||
Imogen Hardy,200m Sprint,0,B
|
|
||||||
Noah Patel,200m Sprint,0,B
|
|
||||||
Seraphina Haugen,200m Sprint,0,B
|
|
||||||
Yasmin Khalid,200m Sprint,0,B
|
|
||||||
Briar Igwe,200m Sprint,0,C
|
|
||||||
Kieran Diallo,200m Sprint,0,C
|
|
||||||
Leo Chambers,200m Sprint,0,C
|
|
||||||
Priya Sharma,200m Sprint,0,C
|
|
||||||
Zinnia Lindberg,200m Sprint,0,C
|
|
||||||
Evander Solberg,200m Sprint,0,A
|
|
||||||
Liam Lindqvist,200m Sprint,0,A
|
|
||||||
Lydia Andersen,200m Sprint,0,A
|
|
||||||
Peregrine Lundgren,200m Sprint,0,A
|
|
||||||
Samuel Kerr,200m Sprint,0,A
|
|
||||||
Caius Falola,200m Sprint,0,B
|
|
||||||
Declan Ashworth,200m Sprint,0,B
|
|
||||||
Fleur Henriksen,200m Sprint,0,B
|
|
||||||
Ottilie Onyekachi,200m Sprint,0,B
|
|
||||||
Sunniva Obi,200m Sprint,0,B
|
|
||||||
Bastian Mirza,200m Sprint,0,C
|
|
||||||
Callum Reid,200m Sprint,0,C
|
|
||||||
Chloe Griffiths,200m Sprint,0,C
|
|
||||||
Ife Osborn,200m Sprint,0,C
|
|
||||||
Zoe Ferrara,200m Sprint,0,C
|
|
||||||
Connor Gallagher,200m Sprint,0,A
|
|
||||||
Demi Adeyemi,200m Sprint,0,A
|
|
||||||
Elowen Bergstrom,200m Sprint,0,A
|
|
||||||
Isolde Ihejirika,200m Sprint,0,A
|
|
||||||
Miles Morales,200m Sprint,0,A
|
|
||||||
Emeka Abubakar,200m Sprint,0,B
|
|
||||||
Emiliano Sandberg,200m Sprint,0,B
|
|
||||||
Jerome Vasquez,200m Sprint,0,B
|
|
||||||
Sienna Park,200m Sprint,0,B
|
|
||||||
Soleil Anozie,200m Sprint,0,B
|
|
||||||
Aaron Fitzgerald,200m Sprint,0,C
|
|
||||||
Adaeze Oduya,200m Sprint,0,C
|
|
||||||
Dante Sorensen,200m Sprint,0,C
|
|
||||||
Mia Nguyen,200m Sprint,0,C
|
|
||||||
Ptolemy Strand,200m Sprint,0,C
|
|
||||||
Ananya Drummond,500m Relay,0,A
|
|
||||||
Clark Kent,500m Relay,0,A
|
|
||||||
Dylan Hargreaves,500m Relay,0,A
|
|
||||||
Grace Henson,500m Relay,0,A
|
|
||||||
Marcus Okafor,500m Relay,0,A
|
|
||||||
Amara Akesson,500m Relay,0,B
|
|
||||||
Kai Johansson,500m Relay,0,B
|
|
||||||
Maya Adebayo,500m Relay,0,B
|
|
||||||
Pippa Nazari,500m Relay,0,B
|
|
||||||
Thorsten Nwofor,500m Relay,0,B
|
|
||||||
Caden Nakamura,500m Relay,0,C
|
|
||||||
Leif Ekwueme,500m Relay,0,C
|
|
||||||
Nneka Stromberg,500m Relay,0,C
|
|
||||||
Oliver Nwosu,500m Relay,0,C
|
|
||||||
Siobhan Onyeka,500m Relay,0,C
|
|
||||||
Ben Whitfield,500m Relay,0,A
|
|
||||||
Harriet Mbeki,500m Relay,0,A
|
|
||||||
Moira Kimura,500m Relay,0,A
|
|
||||||
Nnamdi Bergqvist,500m Relay,0,A
|
|
||||||
Soren Petrov,500m Relay,0,A
|
|
||||||
Ezra Magnusson,500m Relay,0,B
|
|
||||||
Jamie Fletcher,500m Relay,0,B
|
|
||||||
Leo Chambers,500m Relay,0,B
|
|
||||||
Xiomara Azikiwe,500m Relay,0,B
|
|
||||||
Zinnia Lindberg,500m Relay,0,B
|
|
||||||
Alex Mercer,500m Relay,0,C
|
|
||||||
Calixto Gustafsson,500m Relay,0,C
|
|
||||||
Imogen Hardy,500m Relay,0,C
|
|
||||||
Noah Patel,500m Relay,0,C
|
|
||||||
Priya Sharma,500m Relay,0,C
|
|
||||||
Caius Falola,500m Relay,0,A
|
|
||||||
Declan Ashworth,500m Relay,0,A
|
|
||||||
Ethan Brooks,500m Relay,0,A
|
|
||||||
Fleur Henriksen,500m Relay,0,A
|
|
||||||
Lydia Andersen,500m Relay,0,A
|
|
||||||
Evander Solberg,500m Relay,0,B
|
|
||||||
Sarah Connor,500m Relay,0,B
|
|
||||||
Sefton Hellgren,500m Relay,0,B
|
|
||||||
Theron Nzeogwu,500m Relay,0,B
|
|
||||||
Vesper Thorvaldsen,500m Relay,0,B
|
|
||||||
Celeste Malone,500m Relay,0,C
|
|
||||||
Chloe Griffiths,500m Relay,0,C
|
|
||||||
Ife Osborn,500m Relay,0,C
|
|
||||||
Liam Lindqvist,500m Relay,0,C
|
|
||||||
Peregrine Lundgren,500m Relay,0,C
|
|
||||||
Adaeze Oduya,500m Relay,0,A
|
|
||||||
Connor Gallagher,500m Relay,0,A
|
|
||||||
Tara Lawson,500m Relay,0,A
|
|
||||||
Tobias Crane,500m Relay,0,A
|
|
||||||
Vigdis Ekene,500m Relay,0,A
|
|
||||||
Finn McCabe,500m Relay,0,B
|
|
||||||
Jerome Vasquez,500m Relay,0,B
|
|
||||||
Miles Morales,500m Relay,0,B
|
|
||||||
Sigrid Adebayo,500m Relay,0,B
|
|
||||||
Uchenna Friberg,500m Relay,0,B
|
|
||||||
Hannah Walters,500m Relay,0,C
|
|
||||||
Isolde Ihejirika,500m Relay,0,C
|
|
||||||
Oluseun Alade,500m Relay,0,C
|
|
||||||
Rowan Chukwu,500m Relay,0,C
|
|
||||||
Sienna Park,500m Relay,0,C
|
|
||||||
Ava Mackenzie,750m Race,0,A
|
|
||||||
Kai Johansson,750m Race,0,A
|
|
||||||
Oisin Achebe,750m Race,0,A
|
|
||||||
Solange Ezeoke,750m Race,0,A
|
|
||||||
Theo Baldwin,750m Race,0,A
|
|
||||||
Bruce Wayne,750m Race,0,B
|
|
||||||
Liora Wikstrom,750m Race,0,B
|
|
||||||
Nkechi Nwachukwu,750m Race,0,B
|
|
||||||
Thorsten Nwofor,750m Race,0,B
|
|
||||||
Torben Lund,750m Race,0,B
|
|
||||||
Matteo Costa,750m Race,0,C
|
|
||||||
Nadia Kowalski,750m Race,0,C
|
|
||||||
Pippa Nazari,750m Race,0,C
|
|
||||||
Siobhan Onyeka,750m Race,0,C
|
|
||||||
Tariq Holroyd,750m Race,0,C
|
|
||||||
Adaora Sjoberg,750m Race,0,A
|
|
||||||
Calixto Gustafsson,750m Race,0,A
|
|
||||||
Caoimhe Onyia,750m Race,0,A
|
|
||||||
Harriet Mbeki,750m Race,0,A
|
|
||||||
Sofia Mendez,750m Race,0,A
|
|
||||||
Elliot Pearce,750m Race,0,B
|
|
||||||
Indira Eriksson,750m Race,0,B
|
|
||||||
Leo Chambers,750m Race,0,B
|
|
||||||
Nnamdi Bergqvist,750m Race,0,B
|
|
||||||
Zinnia Lindberg,750m Race,0,B
|
|
||||||
Aderemi Sjogren,750m Race,0,C
|
|
||||||
Astrid Emecheta,750m Race,0,C
|
|
||||||
Hector Voss,750m Race,0,C
|
|
||||||
Isla Thompson,750m Race,0,C
|
|
||||||
Noah Patel,750m Race,0,C
|
|
||||||
Bastian Mirza,750m Race,0,A
|
|
||||||
Callum Reid,750m Race,0,A
|
|
||||||
Ethan Brooks,750m Race,0,A
|
|
||||||
Leah Armstrong,750m Race,0,A
|
|
||||||
Vesper Thorvaldsen,750m Race,0,A
|
|
||||||
Fleur Henriksen,750m Race,0,B
|
|
||||||
Jude Barlow,750m Race,0,B
|
|
||||||
Ottilie Onyekachi,750m Race,0,B
|
|
||||||
Peregrine Lundgren,750m Race,0,B
|
|
||||||
Sarah Connor,750m Race,0,B
|
|
||||||
Amara Osei,750m Race,0,C
|
|
||||||
Chloe Griffiths,750m Race,0,C
|
|
||||||
Declan Ashworth,750m Race,0,C
|
|
||||||
Niamh O'Brien,750m Race,0,C
|
|
||||||
Rhys Chakraborty,750m Race,0,C
|
|
||||||
Birgitta Nwosu,750m Race,0,A
|
|
||||||
Dante Sorensen,750m Race,0,A
|
|
||||||
Luca Martini,750m Race,0,A
|
|
||||||
Tobias Crane,750m Race,0,A
|
|
||||||
Vigdis Ekene,750m Race,0,A
|
|
||||||
Adaeze Oduya,750m Race,0,B
|
|
||||||
Isolde Ihejirika,750m Race,0,B
|
|
||||||
Mia Nguyen,750m Race,0,B
|
|
||||||
Sienna Park,750m Race,0,B
|
|
||||||
Sigrid Adebayo,750m Race,0,B
|
|
||||||
Aaron Fitzgerald,750m Race,0,C
|
|
||||||
Demi Adeyemi,750m Race,0,C
|
|
||||||
Emiliano Sandberg,750m Race,0,C
|
|
||||||
Oluseun Alade,750m Race,0,C
|
|
||||||
Ptolemy Strand,750m Race,0,C
|
|
||||||
Bjorn Oyelaran,Javelin,0,A
|
|
||||||
Clark Kent,Javelin,0,A
|
|
||||||
Dagny Norberg,Javelin,0,A
|
|
||||||
Leif Ekwueme,Javelin,0,A
|
|
||||||
Saoirse Takahashi,Javelin,0,A
|
|
||||||
Ellie Sutton,Javelin,0,B
|
|
||||||
Kofi Castellanos,Javelin,0,B
|
|
||||||
Matteo Costa,Javelin,0,B
|
|
||||||
Siobhan Onyeka,Javelin,0,B
|
|
||||||
Theo Baldwin,Javelin,0,B
|
|
||||||
Ananya Drummond,Javelin,0,C
|
|
||||||
Ava Mackenzie,Javelin,0,C
|
|
||||||
Caden Nakamura,Javelin,0,C
|
|
||||||
Oliver Nwosu,Javelin,0,C
|
|
||||||
Ximena Thorsen,Javelin,0,C
|
|
||||||
Aisha Ibrahim,Javelin,0,A
|
|
||||||
Ben Whitfield,Javelin,0,A
|
|
||||||
Leo Chambers,Javelin,0,A
|
|
||||||
Priya Sharma,Javelin,0,A
|
|
||||||
Sofia Mendez,Javelin,0,A
|
|
||||||
Indira Eriksson,Javelin,0,B
|
|
||||||
Isla Thompson,Javelin,0,B
|
|
||||||
Jamie Fletcher,Javelin,0,B
|
|
||||||
Nnamdi Bergqvist,Javelin,0,B
|
|
||||||
Noah Patel,Javelin,0,B
|
|
||||||
Adaora Sjoberg,Javelin,0,C
|
|
||||||
Calixto Gustafsson,Javelin,0,C
|
|
||||||
Caoimhe Onyia,Javelin,0,C
|
|
||||||
Harriet Mbeki,Javelin,0,C
|
|
||||||
Zinnia Lindberg,Javelin,0,C
|
|
||||||
Fleur Henriksen,Javelin,0,A
|
|
||||||
Liam Lindqvist,Javelin,0,A
|
|
||||||
Niamh O'Brien,Javelin,0,A
|
|
||||||
Orla Yamamoto,Javelin,0,A
|
|
||||||
Vesper Thorvaldsen,Javelin,0,A
|
|
||||||
Caius Falola,Javelin,0,B
|
|
||||||
Declan Ashworth,Javelin,0,B
|
|
||||||
Evander Solberg,Javelin,0,B
|
|
||||||
Ravi Anand,Javelin,0,B
|
|
||||||
Xanthe Reyes,Javelin,0,B
|
|
||||||
Chloe Griffiths,Javelin,0,C
|
|
||||||
Niobe Dahl,Javelin,0,C
|
|
||||||
Ottilie Onyekachi,Javelin,0,C
|
|
||||||
Peregrine Lundgren,Javelin,0,C
|
|
||||||
Sarah Connor,Javelin,0,C
|
|
||||||
Chisom Karlsson,Javelin,0,A
|
|
||||||
Connor Gallagher,Javelin,0,A
|
|
||||||
Hannah Walters,Javelin,0,A
|
|
||||||
Isolde Ihejirika,Javelin,0,A
|
|
||||||
Luca Martini,Javelin,0,A
|
|
||||||
Aaron Fitzgerald,Javelin,0,B
|
|
||||||
Demi Adeyemi,Javelin,0,B
|
|
||||||
Elowen Bergstrom,Javelin,0,B
|
|
||||||
Fatima Al-Rashid,Javelin,0,B
|
|
||||||
Sienna Park,Javelin,0,B
|
|
||||||
Adaeze Oduya,Javelin,0,C
|
|
||||||
Miles Morales,Javelin,0,C
|
|
||||||
Sigrid Adebayo,Javelin,0,C
|
|
||||||
Vigdis Ekene,Javelin,0,C
|
|
||||||
Viggo Ezeh,Javelin,0,C
|
|
||||||
Alaric Olawale,Long Jump,0,A
|
|
||||||
Ava Mackenzie,Long Jump,0,A
|
|
||||||
Bjorn Oyelaran,Long Jump,0,A
|
|
||||||
Liora Wikstrom,Long Jump,0,A
|
|
||||||
Nadia Kowalski,Long Jump,0,A
|
|
||||||
Blythe Eze,Long Jump,0,B
|
|
||||||
Marcus Okafor,Long Jump,0,B
|
|
||||||
Rafferty Bjornstad,Long Jump,0,B
|
|
||||||
Saoirse Takahashi,Long Jump,0,B
|
|
||||||
Torben Lund,Long Jump,0,B
|
|
||||||
Clark Kent,Long Jump,0,C
|
|
||||||
Dagny Norberg,Long Jump,0,C
|
|
||||||
Nkechi Nwachukwu,Long Jump,0,C
|
|
||||||
Obinna Skoog,Long Jump,0,C
|
|
||||||
Siobhan Onyeka,Long Jump,0,C
|
|
||||||
Elliot Pearce,Long Jump,0,A
|
|
||||||
Freya Okonkwo,Long Jump,0,A
|
|
||||||
Isla Thompson,Long Jump,0,A
|
|
||||||
Leo Chambers,Long Jump,0,A
|
|
||||||
Soren Petrov,Long Jump,0,A
|
|
||||||
Alex Mercer,Long Jump,0,B
|
|
||||||
Astrid Emecheta,Long Jump,0,B
|
|
||||||
Harriet Mbeki,Long Jump,0,B
|
|
||||||
Imogen Hardy,Long Jump,0,B
|
|
||||||
Indira Eriksson,Long Jump,0,B
|
|
||||||
Araminta Uchenna,Long Jump,0,C
|
|
||||||
Ezra Magnusson,Long Jump,0,C
|
|
||||||
Noah Patel,Long Jump,0,C
|
|
||||||
Sofia Mendez,Long Jump,0,C
|
|
||||||
Stellan Amaechi,Long Jump,0,C
|
|
||||||
Celeste Malone,Long Jump,0,A
|
|
||||||
Chloe Griffiths,Long Jump,0,A
|
|
||||||
Hamish Ekstrom,Long Jump,0,A
|
|
||||||
Liam Lindqvist,Long Jump,0,A
|
|
||||||
Orla Yamamoto,Long Jump,0,A
|
|
||||||
Bram Afolabi,Long Jump,0,B
|
|
||||||
Leah Armstrong,Long Jump,0,B
|
|
||||||
Ottilie Onyekachi,Long Jump,0,B
|
|
||||||
Rhys Chakraborty,Long Jump,0,B
|
|
||||||
Vesper Thorvaldsen,Long Jump,0,B
|
|
||||||
Amara Osei,Long Jump,0,C
|
|
||||||
Callum Reid,Long Jump,0,C
|
|
||||||
Caspian Odunbaku,Long Jump,0,C
|
|
||||||
Declan Ashworth,Long Jump,0,C
|
|
||||||
Felix Mensah,Long Jump,0,C
|
|
||||||
Fatima Al-Rashid,Long Jump,0,A
|
|
||||||
Finn McCabe,Long Jump,0,A
|
|
||||||
Reuben Stone,Long Jump,0,A
|
|
||||||
Sienna Park,Long Jump,0,A
|
|
||||||
Vigdis Ekene,Long Jump,0,A
|
|
||||||
Emeka Abubakar,Long Jump,0,B
|
|
||||||
Isolde Ihejirika,Long Jump,0,B
|
|
||||||
Oluseun Alade,Long Jump,0,B
|
|
||||||
Ptolemy Strand,Long Jump,0,B
|
|
||||||
Rowan Chukwu,Long Jump,0,B
|
|
||||||
Dante Sorensen,Long Jump,0,C
|
|
||||||
Hannah Walters,Long Jump,0,C
|
|
||||||
Jerome Vasquez,Long Jump,0,C
|
|
||||||
Soleil Anozie,Long Jump,0,C
|
|
||||||
Tobias Crane,Long Jump,0,C
|
|
||||||
Amara Akesson,Shotput,0,A
|
|
||||||
Caden Nakamura,Shotput,0,A
|
|
||||||
Cashel Okereke,Shotput,0,A
|
|
||||||
Oisin Achebe,Shotput,0,A
|
|
||||||
Saoirse Takahashi,Shotput,0,A
|
|
||||||
Grace Henson,Shotput,0,B
|
|
||||||
Kofi Castellanos,Shotput,0,B
|
|
||||||
Oliver Nwosu,Shotput,0,B
|
|
||||||
Tariq Holroyd,Shotput,0,B
|
|
||||||
Wren Otieno,Shotput,0,B
|
|
||||||
Ava Mackenzie,Shotput,0,C
|
|
||||||
Dagny Norberg,Shotput,0,C
|
|
||||||
Maya Adebayo,Shotput,0,C
|
|
||||||
Pippa Nazari,Shotput,0,C
|
|
||||||
Rafferty Bjornstad,Shotput,0,C
|
|
||||||
Aderemi Sjogren,Shotput,0,A
|
|
||||||
Briar Igwe,Shotput,0,A
|
|
||||||
Harriet Mbeki,Shotput,0,A
|
|
||||||
Sofia Mendez,Shotput,0,A
|
|
||||||
Yasmin Khalid,Shotput,0,A
|
|
||||||
Araminta Uchenna,Shotput,0,B
|
|
||||||
Ingrid Okeke,Shotput,0,B
|
|
||||||
Moira Kimura,Shotput,0,B
|
|
||||||
Nnamdi Bergqvist,Shotput,0,B
|
|
||||||
Noah Patel,Shotput,0,B
|
|
||||||
Astrid Emecheta,Shotput,0,C
|
|
||||||
Imogen Hardy,Shotput,0,C
|
|
||||||
Jamie Fletcher,Shotput,0,C
|
|
||||||
Leo Chambers,Shotput,0,C
|
|
||||||
Xiomara Azikiwe,Shotput,0,C
|
|
||||||
Chloe Griffiths,Shotput,0,A
|
|
||||||
Orla Yamamoto,Shotput,0,A
|
|
||||||
Rhys Chakraborty,Shotput,0,A
|
|
||||||
Samuel Kerr,Shotput,0,A
|
|
||||||
Theron Nzeogwu,Shotput,0,A
|
|
||||||
Aarav Holm,Shotput,0,B
|
|
||||||
Cyrus Skoglund,Shotput,0,B
|
|
||||||
Fleur Henriksen,Shotput,0,B
|
|
||||||
Xanthe Reyes,Shotput,0,B
|
|
||||||
Zara Hussain,Shotput,0,B
|
|
||||||
Ethan Brooks,Shotput,0,C
|
|
||||||
Hamish Ekstrom,Shotput,0,C
|
|
||||||
Leah Armstrong,Shotput,0,C
|
|
||||||
Niamh O'Brien,Shotput,0,C
|
|
||||||
Peregrine Lundgren,Shotput,0,C
|
|
||||||
Aaron Fitzgerald,Shotput,0,A
|
|
||||||
Chisom Karlsson,Shotput,0,A
|
|
||||||
Demi Adeyemi,Shotput,0,A
|
|
||||||
Emiliano Sandberg,Shotput,0,A
|
|
||||||
Luca Martini,Shotput,0,A
|
|
||||||
Adaeze Oduya,Shotput,0,B
|
|
||||||
Dante Sorensen,Shotput,0,B
|
|
||||||
Jerome Vasquez,Shotput,0,B
|
|
||||||
Mia Nguyen,Shotput,0,B
|
|
||||||
Miles Morales,Shotput,0,B
|
|
||||||
Connor Gallagher,Shotput,0,C
|
|
||||||
Finn McCabe,Shotput,0,C
|
|
||||||
Reuben Stone,Shotput,0,C
|
|
||||||
Tobias Crane,Shotput,0,C
|
|
||||||
Vigdis Ekene,Shotput,0,C
|
|
||||||
Bruce Wayne,Triple Jump,0,A
|
|
||||||
Clark Kent,Triple Jump,0,A
|
|
||||||
Grace Henson,Triple Jump,0,A
|
|
||||||
Kofi Castellanos,Triple Jump,0,A
|
|
||||||
Wren Otieno,Triple Jump,0,A
|
|
||||||
Caden Nakamura,Triple Jump,0,B
|
|
||||||
Cashel Okereke,Triple Jump,0,B
|
|
||||||
Oliver Nwosu,Triple Jump,0,B
|
|
||||||
Saoirse Takahashi,Triple Jump,0,B
|
|
||||||
Solange Ezeoke,Triple Jump,0,B
|
|
||||||
Amara Akesson,Triple Jump,0,C
|
|
||||||
Margot Brandt,Triple Jump,0,C
|
|
||||||
Obinna Skoog,Triple Jump,0,C
|
|
||||||
Rafferty Bjornstad,Triple Jump,0,C
|
|
||||||
Siobhan Onyeka,Triple Jump,0,C
|
|
||||||
Astrid Emecheta,Triple Jump,0,A
|
|
||||||
Indira Eriksson,Triple Jump,0,A
|
|
||||||
Ingrid Okeke,Triple Jump,0,A
|
|
||||||
Leo Chambers,Triple Jump,0,A
|
|
||||||
Moira Kimura,Triple Jump,0,A
|
|
||||||
Adaora Sjoberg,Triple Jump,0,B
|
|
||||||
Briar Igwe,Triple Jump,0,B
|
|
||||||
Noah Patel,Triple Jump,0,B
|
|
||||||
Sofia Mendez,Triple Jump,0,B
|
|
||||||
Stellan Amaechi,Triple Jump,0,B
|
|
||||||
Caoimhe Onyia,Triple Jump,0,C
|
|
||||||
Ezra Magnusson,Triple Jump,0,C
|
|
||||||
Harriet Mbeki,Triple Jump,0,C
|
|
||||||
Xiomara Azikiwe,Triple Jump,0,C
|
|
||||||
Zinnia Lindberg,Triple Jump,0,C
|
|
||||||
Caius Falola,Triple Jump,0,A
|
|
||||||
Evander Solberg,Triple Jump,0,A
|
|
||||||
Lydia Andersen,Triple Jump,0,A
|
|
||||||
Sunniva Obi,Triple Jump,0,A
|
|
||||||
Zara Hussain,Triple Jump,0,A
|
|
||||||
Fleur Henriksen,Triple Jump,0,B
|
|
||||||
Niobe Dahl,Triple Jump,0,B
|
|
||||||
Sefton Hellgren,Triple Jump,0,B
|
|
||||||
Xanthe Reyes,Triple Jump,0,B
|
|
||||||
Zoe Ferrara,Triple Jump,0,B
|
|
||||||
Felix Mensah,Triple Jump,0,C
|
|
||||||
Idris Svensson,Triple Jump,0,C
|
|
||||||
Orla Yamamoto,Triple Jump,0,C
|
|
||||||
Theron Nzeogwu,Triple Jump,0,C
|
|
||||||
Vesper Thorvaldsen,Triple Jump,0,C
|
|
||||||
Chisom Karlsson,Triple Jump,0,A
|
|
||||||
Mia Nguyen,Triple Jump,0,A
|
|
||||||
Oluseun Alade,Triple Jump,0,A
|
|
||||||
Rowan Chukwu,Triple Jump,0,A
|
|
||||||
Viggo Ezeh,Triple Jump,0,A
|
|
||||||
Dante Sorensen,Triple Jump,0,B
|
|
||||||
Demi Adeyemi,Triple Jump,0,B
|
|
||||||
Isolde Ihejirika,Triple Jump,0,B
|
|
||||||
Miles Morales,Triple Jump,0,B
|
|
||||||
Soleil Anozie,Triple Jump,0,B
|
|
||||||
Adaeze Oduya,Triple Jump,0,C
|
|
||||||
Connor Gallagher,Triple Jump,0,C
|
|
||||||
Tara Lawson,Triple Jump,0,C
|
|
||||||
Tobias Crane,Triple Jump,0,C
|
|
||||||
Vigdis Ekene,Triple Jump,0,C
|
|
||||||
|
|||||||
|
541
scripts/data/registeredPlayers.csv.some
Normal file
541
scripts/data/registeredPlayers.csv.some
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
player_registered,event_registered,player_placement,bracket
|
||||||
|
Ava Mackenzie,1000m Long Distance,0,A
|
||||||
|
Bjorn Oyelaran,1000m Long Distance,0,A
|
||||||
|
Clark Kent,1000m Long Distance,0,A
|
||||||
|
Leif Ekwueme,1000m Long Distance,0,A
|
||||||
|
Zephyr Anyanwu,1000m Long Distance,0,A
|
||||||
|
Dylan Hargreaves,1000m Long Distance,0,B
|
||||||
|
Nadia Kowalski,1000m Long Distance,0,B
|
||||||
|
Obinna Skoog,1000m Long Distance,0,B
|
||||||
|
Rafferty Bjornstad,1000m Long Distance,0,B
|
||||||
|
Siobhan Onyeka,1000m Long Distance,0,B
|
||||||
|
Ellie Sutton,1000m Long Distance,0,C
|
||||||
|
Jack Simmons,1000m Long Distance,0,C
|
||||||
|
Matteo Costa,1000m Long Distance,0,C
|
||||||
|
Thorsten Nwofor,1000m Long Distance,0,C
|
||||||
|
Wren Otieno,1000m Long Distance,0,C
|
||||||
|
Harriet Mbeki,1000m Long Distance,0,A
|
||||||
|
Hector Voss,1000m Long Distance,0,A
|
||||||
|
Jamie Fletcher,1000m Long Distance,0,A
|
||||||
|
Sofia Mendez,1000m Long Distance,0,A
|
||||||
|
Yasmin Khalid,1000m Long Distance,0,A
|
||||||
|
Caoimhe Onyia,1000m Long Distance,0,B
|
||||||
|
Freya Okonkwo,1000m Long Distance,0,B
|
||||||
|
Imogen Hardy,1000m Long Distance,0,B
|
||||||
|
Leo Chambers,1000m Long Distance,0,B
|
||||||
|
Xiomara Azikiwe,1000m Long Distance,0,B
|
||||||
|
Aderemi Sjogren,1000m Long Distance,0,C
|
||||||
|
Araminta Uchenna,1000m Long Distance,0,C
|
||||||
|
Ezra Magnusson,1000m Long Distance,0,C
|
||||||
|
Isla Thompson,1000m Long Distance,0,C
|
||||||
|
Noah Patel,1000m Long Distance,0,C
|
||||||
|
Amara Osei,1000m Long Distance,0,A
|
||||||
|
Ethan Brooks,1000m Long Distance,0,A
|
||||||
|
Evander Solberg,1000m Long Distance,0,A
|
||||||
|
Theron Nzeogwu,1000m Long Distance,0,A
|
||||||
|
Zara Hussain,1000m Long Distance,0,A
|
||||||
|
Chloe Griffiths,1000m Long Distance,0,B
|
||||||
|
Lydia Andersen,1000m Long Distance,0,B
|
||||||
|
Niobe Dahl,1000m Long Distance,0,B
|
||||||
|
Orla Yamamoto,1000m Long Distance,0,B
|
||||||
|
Zoe Ferrara,1000m Long Distance,0,B
|
||||||
|
Fleur Henriksen,1000m Long Distance,0,C
|
||||||
|
Ife Osborn,1000m Long Distance,0,C
|
||||||
|
Rhys Chakraborty,1000m Long Distance,0,C
|
||||||
|
Ruby Thornton,1000m Long Distance,0,C
|
||||||
|
Xanthe Reyes,1000m Long Distance,0,C
|
||||||
|
Emeka Abubakar,1000m Long Distance,0,A
|
||||||
|
Finn McCabe,1000m Long Distance,0,A
|
||||||
|
Miles Morales,1000m Long Distance,0,A
|
||||||
|
Rowan Chukwu,1000m Long Distance,0,A
|
||||||
|
Tara Lawson,1000m Long Distance,0,A
|
||||||
|
Adaeze Oduya,1000m Long Distance,0,B
|
||||||
|
Chisom Karlsson,1000m Long Distance,0,B
|
||||||
|
Demi Adeyemi,1000m Long Distance,0,B
|
||||||
|
Sigrid Adebayo,1000m Long Distance,0,B
|
||||||
|
Viggo Ezeh,1000m Long Distance,0,B
|
||||||
|
Aaron Fitzgerald,1000m Long Distance,0,C
|
||||||
|
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,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
|
||||||
|
Siobhan Onyeka,100m Sprint,0,B
|
||||||
|
Ximena Thorsen,100m Sprint,0,B
|
||||||
|
Bruce Wayne,100m Sprint,0,C
|
||||||
|
Leandro Danielsson,100m Sprint,0,C
|
||||||
|
Tariq Holroyd,100m Sprint,0,C
|
||||||
|
Thorsten Nwofor,100m Sprint,0,C
|
||||||
|
Wren Otieno,100m Sprint,0,C
|
||||||
|
Indira Eriksson,100m Sprint,0,A
|
||||||
|
Jamie Fletcher,100m Sprint,0,A
|
||||||
|
Noah Patel,100m Sprint,0,A
|
||||||
|
Priya Sharma,100m Sprint,0,A
|
||||||
|
Sofia Mendez,100m Sprint,0,A
|
||||||
|
Adaora Sjoberg,100m Sprint,0,B
|
||||||
|
Aisha Ibrahim,100m Sprint,0,B
|
||||||
|
Elliot Pearce,100m Sprint,0,B
|
||||||
|
Harriet Mbeki,100m Sprint,0,B
|
||||||
|
Isla Thompson,100m Sprint,0,B
|
||||||
|
Alex Mercer,100m Sprint,0,C
|
||||||
|
Imogen Hardy,100m Sprint,0,C
|
||||||
|
Leo Chambers,100m Sprint,0,C
|
||||||
|
Owen Davies,100m Sprint,0,C
|
||||||
|
Yasmin Khalid,100m Sprint,0,C
|
||||||
|
Amara Osei,100m Sprint,0,A
|
||||||
|
Bastian Mirza,100m Sprint,0,A
|
||||||
|
Evander Solberg,100m Sprint,0,A
|
||||||
|
Sunniva Obi,100m Sprint,0,A
|
||||||
|
Xanthe Reyes,100m Sprint,0,A
|
||||||
|
Declan Ashworth,100m Sprint,0,B
|
||||||
|
Felix Mensah,100m Sprint,0,B
|
||||||
|
Ife Osborn,100m Sprint,0,B
|
||||||
|
Liam Lindqvist,100m Sprint,0,B
|
||||||
|
Theron Nzeogwu,100m Sprint,0,B
|
||||||
|
Chloe Griffiths,100m Sprint,0,C
|
||||||
|
Hamish Ekstrom,100m Sprint,0,C
|
||||||
|
Lydia Andersen,100m Sprint,0,C
|
||||||
|
Orla Yamamoto,100m Sprint,0,C
|
||||||
|
Ravi Anand,100m Sprint,0,C
|
||||||
|
Dante Sorensen,100m Sprint,0,A
|
||||||
|
Mia Nguyen,100m Sprint,0,A
|
||||||
|
Petra Brennan,100m Sprint,0,A
|
||||||
|
Reuben Stone,100m Sprint,0,A
|
||||||
|
Tobias Crane,100m Sprint,0,A
|
||||||
|
Jerome Vasquez,100m Sprint,0,B
|
||||||
|
Luca Martini,100m Sprint,0,B
|
||||||
|
Miles Morales,100m Sprint,0,B
|
||||||
|
Rowan Chukwu,100m Sprint,0,B
|
||||||
|
Vigdis Ekene,100m Sprint,0,B
|
||||||
|
Emeka Abubakar,100m Sprint,0,C
|
||||||
|
Sienna Park,100m Sprint,0,C
|
||||||
|
Sigrid Adebayo,100m Sprint,0,C
|
||||||
|
Soleil Anozie,100m Sprint,0,C
|
||||||
|
Viggo Ezeh,100m Sprint,0,C
|
||||||
|
Caden Nakamura,200m Sprint,0,A
|
||||||
|
Cashel Okereke,200m Sprint,0,A
|
||||||
|
Cormac Halvorsen,200m Sprint,0,A
|
||||||
|
Oisin Achebe,200m Sprint,0,A
|
||||||
|
Thorsten Nwofor,200m Sprint,0,A
|
||||||
|
Ananya Drummond,200m Sprint,0,B
|
||||||
|
Blythe Eze,200m Sprint,0,B
|
||||||
|
Oliver Nwosu,200m Sprint,0,B
|
||||||
|
Rafferty Bjornstad,200m Sprint,0,B
|
||||||
|
Siobhan Onyeka,200m Sprint,0,B
|
||||||
|
Bjorn Oyelaran,200m Sprint,0,C
|
||||||
|
Clark Kent,200m Sprint,0,C
|
||||||
|
Grace Henson,200m Sprint,0,C
|
||||||
|
Nadia Kowalski,200m Sprint,0,C
|
||||||
|
Theo Baldwin,200m Sprint,0,C
|
||||||
|
Ezra Magnusson,200m Sprint,0,A
|
||||||
|
Harriet Mbeki,200m Sprint,0,A
|
||||||
|
Indira Eriksson,200m Sprint,0,A
|
||||||
|
Jamie Fletcher,200m Sprint,0,A
|
||||||
|
Moira Kimura,200m Sprint,0,A
|
||||||
|
Calixto Gustafsson,200m Sprint,0,B
|
||||||
|
Imogen Hardy,200m Sprint,0,B
|
||||||
|
Noah Patel,200m Sprint,0,B
|
||||||
|
Seraphina Haugen,200m Sprint,0,B
|
||||||
|
Yasmin Khalid,200m Sprint,0,B
|
||||||
|
Briar Igwe,200m Sprint,0,C
|
||||||
|
Kieran Diallo,200m Sprint,0,C
|
||||||
|
Leo Chambers,200m Sprint,0,C
|
||||||
|
Priya Sharma,200m Sprint,0,C
|
||||||
|
Zinnia Lindberg,200m Sprint,0,C
|
||||||
|
Evander Solberg,200m Sprint,0,A
|
||||||
|
Liam Lindqvist,200m Sprint,0,A
|
||||||
|
Lydia Andersen,200m Sprint,0,A
|
||||||
|
Peregrine Lundgren,200m Sprint,0,A
|
||||||
|
Samuel Kerr,200m Sprint,0,A
|
||||||
|
Caius Falola,200m Sprint,0,B
|
||||||
|
Declan Ashworth,200m Sprint,0,B
|
||||||
|
Fleur Henriksen,200m Sprint,0,B
|
||||||
|
Ottilie Onyekachi,200m Sprint,0,B
|
||||||
|
Sunniva Obi,200m Sprint,0,B
|
||||||
|
Bastian Mirza,200m Sprint,0,C
|
||||||
|
Callum Reid,200m Sprint,0,C
|
||||||
|
Chloe Griffiths,200m Sprint,0,C
|
||||||
|
Ife Osborn,200m Sprint,0,C
|
||||||
|
Zoe Ferrara,200m Sprint,0,C
|
||||||
|
Connor Gallagher,200m Sprint,0,A
|
||||||
|
Demi Adeyemi,200m Sprint,0,A
|
||||||
|
Elowen Bergstrom,200m Sprint,0,A
|
||||||
|
Isolde Ihejirika,200m Sprint,0,A
|
||||||
|
Miles Morales,200m Sprint,0,A
|
||||||
|
Emeka Abubakar,200m Sprint,0,B
|
||||||
|
Emiliano Sandberg,200m Sprint,0,B
|
||||||
|
Jerome Vasquez,200m Sprint,0,B
|
||||||
|
Sienna Park,200m Sprint,0,B
|
||||||
|
Soleil Anozie,200m Sprint,0,B
|
||||||
|
Aaron Fitzgerald,200m Sprint,0,C
|
||||||
|
Adaeze Oduya,200m Sprint,0,C
|
||||||
|
Dante Sorensen,200m Sprint,0,C
|
||||||
|
Mia Nguyen,200m Sprint,0,C
|
||||||
|
Ptolemy Strand,200m Sprint,0,C
|
||||||
|
Ananya Drummond,500m Relay,0,A
|
||||||
|
Clark Kent,500m Relay,0,A
|
||||||
|
Dylan Hargreaves,500m Relay,0,A
|
||||||
|
Grace Henson,500m Relay,0,A
|
||||||
|
Marcus Okafor,500m Relay,0,A
|
||||||
|
Amara Akesson,500m Relay,0,B
|
||||||
|
Kai Johansson,500m Relay,0,B
|
||||||
|
Maya Adebayo,500m Relay,0,B
|
||||||
|
Pippa Nazari,500m Relay,0,B
|
||||||
|
Thorsten Nwofor,500m Relay,0,B
|
||||||
|
Caden Nakamura,500m Relay,0,C
|
||||||
|
Leif Ekwueme,500m Relay,0,C
|
||||||
|
Nneka Stromberg,500m Relay,0,C
|
||||||
|
Oliver Nwosu,500m Relay,0,C
|
||||||
|
Siobhan Onyeka,500m Relay,0,C
|
||||||
|
Ben Whitfield,500m Relay,0,A
|
||||||
|
Harriet Mbeki,500m Relay,0,A
|
||||||
|
Moira Kimura,500m Relay,0,A
|
||||||
|
Nnamdi Bergqvist,500m Relay,0,A
|
||||||
|
Soren Petrov,500m Relay,0,A
|
||||||
|
Ezra Magnusson,500m Relay,0,B
|
||||||
|
Jamie Fletcher,500m Relay,0,B
|
||||||
|
Leo Chambers,500m Relay,0,B
|
||||||
|
Xiomara Azikiwe,500m Relay,0,B
|
||||||
|
Zinnia Lindberg,500m Relay,0,B
|
||||||
|
Alex Mercer,500m Relay,0,C
|
||||||
|
Calixto Gustafsson,500m Relay,0,C
|
||||||
|
Imogen Hardy,500m Relay,0,C
|
||||||
|
Noah Patel,500m Relay,0,C
|
||||||
|
Priya Sharma,500m Relay,0,C
|
||||||
|
Caius Falola,500m Relay,0,A
|
||||||
|
Declan Ashworth,500m Relay,0,A
|
||||||
|
Ethan Brooks,500m Relay,0,A
|
||||||
|
Fleur Henriksen,500m Relay,0,A
|
||||||
|
Lydia Andersen,500m Relay,0,A
|
||||||
|
Evander Solberg,500m Relay,0,B
|
||||||
|
Sarah Connor,500m Relay,0,B
|
||||||
|
Sefton Hellgren,500m Relay,0,B
|
||||||
|
Theron Nzeogwu,500m Relay,0,B
|
||||||
|
Vesper Thorvaldsen,500m Relay,0,B
|
||||||
|
Celeste Malone,500m Relay,0,C
|
||||||
|
Chloe Griffiths,500m Relay,0,C
|
||||||
|
Ife Osborn,500m Relay,0,C
|
||||||
|
Liam Lindqvist,500m Relay,0,C
|
||||||
|
Peregrine Lundgren,500m Relay,0,C
|
||||||
|
Adaeze Oduya,500m Relay,0,A
|
||||||
|
Connor Gallagher,500m Relay,0,A
|
||||||
|
Tara Lawson,500m Relay,0,A
|
||||||
|
Tobias Crane,500m Relay,0,A
|
||||||
|
Vigdis Ekene,500m Relay,0,A
|
||||||
|
Finn McCabe,500m Relay,0,B
|
||||||
|
Jerome Vasquez,500m Relay,0,B
|
||||||
|
Miles Morales,500m Relay,0,B
|
||||||
|
Sigrid Adebayo,500m Relay,0,B
|
||||||
|
Uchenna Friberg,500m Relay,0,B
|
||||||
|
Hannah Walters,500m Relay,0,C
|
||||||
|
Isolde Ihejirika,500m Relay,0,C
|
||||||
|
Oluseun Alade,500m Relay,0,C
|
||||||
|
Rowan Chukwu,500m Relay,0,C
|
||||||
|
Sienna Park,500m Relay,0,C
|
||||||
|
Ava Mackenzie,750m Race,0,A
|
||||||
|
Kai Johansson,750m Race,0,A
|
||||||
|
Oisin Achebe,750m Race,0,A
|
||||||
|
Solange Ezeoke,750m Race,0,A
|
||||||
|
Theo Baldwin,750m Race,0,A
|
||||||
|
Bruce Wayne,750m Race,0,B
|
||||||
|
Liora Wikstrom,750m Race,0,B
|
||||||
|
Nkechi Nwachukwu,750m Race,0,B
|
||||||
|
Thorsten Nwofor,750m Race,0,B
|
||||||
|
Torben Lund,750m Race,0,B
|
||||||
|
Matteo Costa,750m Race,0,C
|
||||||
|
Nadia Kowalski,750m Race,0,C
|
||||||
|
Pippa Nazari,750m Race,0,C
|
||||||
|
Siobhan Onyeka,750m Race,0,C
|
||||||
|
Tariq Holroyd,750m Race,0,C
|
||||||
|
Adaora Sjoberg,750m Race,0,A
|
||||||
|
Calixto Gustafsson,750m Race,0,A
|
||||||
|
Caoimhe Onyia,750m Race,0,A
|
||||||
|
Harriet Mbeki,750m Race,0,A
|
||||||
|
Sofia Mendez,750m Race,0,A
|
||||||
|
Elliot Pearce,750m Race,0,B
|
||||||
|
Indira Eriksson,750m Race,0,B
|
||||||
|
Leo Chambers,750m Race,0,B
|
||||||
|
Nnamdi Bergqvist,750m Race,0,B
|
||||||
|
Zinnia Lindberg,750m Race,0,B
|
||||||
|
Aderemi Sjogren,750m Race,0,C
|
||||||
|
Astrid Emecheta,750m Race,0,C
|
||||||
|
Hector Voss,750m Race,0,C
|
||||||
|
Isla Thompson,750m Race,0,C
|
||||||
|
Noah Patel,750m Race,0,C
|
||||||
|
Bastian Mirza,750m Race,0,A
|
||||||
|
Callum Reid,750m Race,0,A
|
||||||
|
Ethan Brooks,750m Race,0,A
|
||||||
|
Leah Armstrong,750m Race,0,A
|
||||||
|
Vesper Thorvaldsen,750m Race,0,A
|
||||||
|
Fleur Henriksen,750m Race,0,B
|
||||||
|
Jude Barlow,750m Race,0,B
|
||||||
|
Ottilie Onyekachi,750m Race,0,B
|
||||||
|
Peregrine Lundgren,750m Race,0,B
|
||||||
|
Sarah Connor,750m Race,0,B
|
||||||
|
Amara Osei,750m Race,0,C
|
||||||
|
Chloe Griffiths,750m Race,0,C
|
||||||
|
Declan Ashworth,750m Race,0,C
|
||||||
|
Niamh O'Brien,750m Race,0,C
|
||||||
|
Rhys Chakraborty,750m Race,0,C
|
||||||
|
Birgitta Nwosu,750m Race,0,A
|
||||||
|
Dante Sorensen,750m Race,0,A
|
||||||
|
Luca Martini,750m Race,0,A
|
||||||
|
Tobias Crane,750m Race,0,A
|
||||||
|
Vigdis Ekene,750m Race,0,A
|
||||||
|
Adaeze Oduya,750m Race,0,B
|
||||||
|
Isolde Ihejirika,750m Race,0,B
|
||||||
|
Mia Nguyen,750m Race,0,B
|
||||||
|
Sienna Park,750m Race,0,B
|
||||||
|
Sigrid Adebayo,750m Race,0,B
|
||||||
|
Aaron Fitzgerald,750m Race,0,C
|
||||||
|
Demi Adeyemi,750m Race,0,C
|
||||||
|
Emiliano Sandberg,750m Race,0,C
|
||||||
|
Oluseun Alade,750m Race,0,C
|
||||||
|
Ptolemy Strand,750m Race,0,C
|
||||||
|
Bjorn Oyelaran,Javelin,0,A
|
||||||
|
Clark Kent,Javelin,0,A
|
||||||
|
Dagny Norberg,Javelin,0,A
|
||||||
|
Leif Ekwueme,Javelin,0,A
|
||||||
|
Saoirse Takahashi,Javelin,0,A
|
||||||
|
Ellie Sutton,Javelin,0,B
|
||||||
|
Kofi Castellanos,Javelin,0,B
|
||||||
|
Matteo Costa,Javelin,0,B
|
||||||
|
Siobhan Onyeka,Javelin,0,B
|
||||||
|
Theo Baldwin,Javelin,0,B
|
||||||
|
Ananya Drummond,Javelin,0,C
|
||||||
|
Ava Mackenzie,Javelin,0,C
|
||||||
|
Caden Nakamura,Javelin,0,C
|
||||||
|
Oliver Nwosu,Javelin,0,C
|
||||||
|
Ximena Thorsen,Javelin,0,C
|
||||||
|
Aisha Ibrahim,Javelin,0,A
|
||||||
|
Ben Whitfield,Javelin,0,A
|
||||||
|
Leo Chambers,Javelin,0,A
|
||||||
|
Priya Sharma,Javelin,0,A
|
||||||
|
Sofia Mendez,Javelin,0,A
|
||||||
|
Indira Eriksson,Javelin,0,B
|
||||||
|
Isla Thompson,Javelin,0,B
|
||||||
|
Jamie Fletcher,Javelin,0,B
|
||||||
|
Nnamdi Bergqvist,Javelin,0,B
|
||||||
|
Noah Patel,Javelin,0,B
|
||||||
|
Adaora Sjoberg,Javelin,0,C
|
||||||
|
Calixto Gustafsson,Javelin,0,C
|
||||||
|
Caoimhe Onyia,Javelin,0,C
|
||||||
|
Harriet Mbeki,Javelin,0,C
|
||||||
|
Zinnia Lindberg,Javelin,0,C
|
||||||
|
Fleur Henriksen,Javelin,0,A
|
||||||
|
Liam Lindqvist,Javelin,0,A
|
||||||
|
Niamh O'Brien,Javelin,0,A
|
||||||
|
Orla Yamamoto,Javelin,0,A
|
||||||
|
Vesper Thorvaldsen,Javelin,0,A
|
||||||
|
Caius Falola,Javelin,0,B
|
||||||
|
Declan Ashworth,Javelin,0,B
|
||||||
|
Evander Solberg,Javelin,0,B
|
||||||
|
Ravi Anand,Javelin,0,B
|
||||||
|
Xanthe Reyes,Javelin,0,B
|
||||||
|
Chloe Griffiths,Javelin,0,C
|
||||||
|
Niobe Dahl,Javelin,0,C
|
||||||
|
Ottilie Onyekachi,Javelin,0,C
|
||||||
|
Peregrine Lundgren,Javelin,0,C
|
||||||
|
Sarah Connor,Javelin,0,C
|
||||||
|
Chisom Karlsson,Javelin,0,A
|
||||||
|
Connor Gallagher,Javelin,0,A
|
||||||
|
Hannah Walters,Javelin,0,A
|
||||||
|
Isolde Ihejirika,Javelin,0,A
|
||||||
|
Luca Martini,Javelin,0,A
|
||||||
|
Aaron Fitzgerald,Javelin,0,B
|
||||||
|
Demi Adeyemi,Javelin,0,B
|
||||||
|
Elowen Bergstrom,Javelin,0,B
|
||||||
|
Fatima Al-Rashid,Javelin,0,B
|
||||||
|
Sienna Park,Javelin,0,B
|
||||||
|
Adaeze Oduya,Javelin,0,C
|
||||||
|
Miles Morales,Javelin,0,C
|
||||||
|
Sigrid Adebayo,Javelin,0,C
|
||||||
|
Vigdis Ekene,Javelin,0,C
|
||||||
|
Viggo Ezeh,Javelin,0,C
|
||||||
|
Alaric Olawale,Long Jump,0,A
|
||||||
|
Ava Mackenzie,Long Jump,0,A
|
||||||
|
Bjorn Oyelaran,Long Jump,0,A
|
||||||
|
Liora Wikstrom,Long Jump,0,A
|
||||||
|
Nadia Kowalski,Long Jump,0,A
|
||||||
|
Blythe Eze,Long Jump,0,B
|
||||||
|
Marcus Okafor,Long Jump,0,B
|
||||||
|
Rafferty Bjornstad,Long Jump,0,B
|
||||||
|
Saoirse Takahashi,Long Jump,0,B
|
||||||
|
Torben Lund,Long Jump,0,B
|
||||||
|
Clark Kent,Long Jump,0,C
|
||||||
|
Dagny Norberg,Long Jump,0,C
|
||||||
|
Nkechi Nwachukwu,Long Jump,0,C
|
||||||
|
Obinna Skoog,Long Jump,0,C
|
||||||
|
Siobhan Onyeka,Long Jump,0,C
|
||||||
|
Elliot Pearce,Long Jump,0,A
|
||||||
|
Freya Okonkwo,Long Jump,0,A
|
||||||
|
Isla Thompson,Long Jump,0,A
|
||||||
|
Leo Chambers,Long Jump,0,A
|
||||||
|
Soren Petrov,Long Jump,0,A
|
||||||
|
Alex Mercer,Long Jump,0,B
|
||||||
|
Astrid Emecheta,Long Jump,0,B
|
||||||
|
Harriet Mbeki,Long Jump,0,B
|
||||||
|
Imogen Hardy,Long Jump,0,B
|
||||||
|
Indira Eriksson,Long Jump,0,B
|
||||||
|
Araminta Uchenna,Long Jump,0,C
|
||||||
|
Ezra Magnusson,Long Jump,0,C
|
||||||
|
Noah Patel,Long Jump,0,C
|
||||||
|
Sofia Mendez,Long Jump,0,C
|
||||||
|
Stellan Amaechi,Long Jump,0,C
|
||||||
|
Celeste Malone,Long Jump,0,A
|
||||||
|
Chloe Griffiths,Long Jump,0,A
|
||||||
|
Hamish Ekstrom,Long Jump,0,A
|
||||||
|
Liam Lindqvist,Long Jump,0,A
|
||||||
|
Orla Yamamoto,Long Jump,0,A
|
||||||
|
Bram Afolabi,Long Jump,0,B
|
||||||
|
Leah Armstrong,Long Jump,0,B
|
||||||
|
Ottilie Onyekachi,Long Jump,0,B
|
||||||
|
Rhys Chakraborty,Long Jump,0,B
|
||||||
|
Vesper Thorvaldsen,Long Jump,0,B
|
||||||
|
Amara Osei,Long Jump,0,C
|
||||||
|
Callum Reid,Long Jump,0,C
|
||||||
|
Caspian Odunbaku,Long Jump,0,C
|
||||||
|
Declan Ashworth,Long Jump,0,C
|
||||||
|
Felix Mensah,Long Jump,0,C
|
||||||
|
Fatima Al-Rashid,Long Jump,0,A
|
||||||
|
Finn McCabe,Long Jump,0,A
|
||||||
|
Reuben Stone,Long Jump,0,A
|
||||||
|
Sienna Park,Long Jump,0,A
|
||||||
|
Vigdis Ekene,Long Jump,0,A
|
||||||
|
Emeka Abubakar,Long Jump,0,B
|
||||||
|
Isolde Ihejirika,Long Jump,0,B
|
||||||
|
Oluseun Alade,Long Jump,0,B
|
||||||
|
Ptolemy Strand,Long Jump,0,B
|
||||||
|
Rowan Chukwu,Long Jump,0,B
|
||||||
|
Dante Sorensen,Long Jump,0,C
|
||||||
|
Hannah Walters,Long Jump,0,C
|
||||||
|
Jerome Vasquez,Long Jump,0,C
|
||||||
|
Soleil Anozie,Long Jump,0,C
|
||||||
|
Tobias Crane,Long Jump,0,C
|
||||||
|
Amara Akesson,Shotput,0,A
|
||||||
|
Caden Nakamura,Shotput,0,A
|
||||||
|
Cashel Okereke,Shotput,0,A
|
||||||
|
Oisin Achebe,Shotput,0,A
|
||||||
|
Saoirse Takahashi,Shotput,0,A
|
||||||
|
Grace Henson,Shotput,0,B
|
||||||
|
Kofi Castellanos,Shotput,0,B
|
||||||
|
Oliver Nwosu,Shotput,0,B
|
||||||
|
Tariq Holroyd,Shotput,0,B
|
||||||
|
Wren Otieno,Shotput,0,B
|
||||||
|
Ava Mackenzie,Shotput,0,C
|
||||||
|
Dagny Norberg,Shotput,0,C
|
||||||
|
Maya Adebayo,Shotput,0,C
|
||||||
|
Pippa Nazari,Shotput,0,C
|
||||||
|
Rafferty Bjornstad,Shotput,0,C
|
||||||
|
Aderemi Sjogren,Shotput,0,A
|
||||||
|
Briar Igwe,Shotput,0,A
|
||||||
|
Harriet Mbeki,Shotput,0,A
|
||||||
|
Sofia Mendez,Shotput,0,A
|
||||||
|
Yasmin Khalid,Shotput,0,A
|
||||||
|
Araminta Uchenna,Shotput,0,B
|
||||||
|
Ingrid Okeke,Shotput,0,B
|
||||||
|
Moira Kimura,Shotput,0,B
|
||||||
|
Nnamdi Bergqvist,Shotput,0,B
|
||||||
|
Noah Patel,Shotput,0,B
|
||||||
|
Astrid Emecheta,Shotput,0,C
|
||||||
|
Imogen Hardy,Shotput,0,C
|
||||||
|
Jamie Fletcher,Shotput,0,C
|
||||||
|
Leo Chambers,Shotput,0,C
|
||||||
|
Xiomara Azikiwe,Shotput,0,C
|
||||||
|
Chloe Griffiths,Shotput,0,A
|
||||||
|
Orla Yamamoto,Shotput,0,A
|
||||||
|
Rhys Chakraborty,Shotput,0,A
|
||||||
|
Samuel Kerr,Shotput,0,A
|
||||||
|
Theron Nzeogwu,Shotput,0,A
|
||||||
|
Aarav Holm,Shotput,0,B
|
||||||
|
Cyrus Skoglund,Shotput,0,B
|
||||||
|
Fleur Henriksen,Shotput,0,B
|
||||||
|
Xanthe Reyes,Shotput,0,B
|
||||||
|
Zara Hussain,Shotput,0,B
|
||||||
|
Ethan Brooks,Shotput,0,C
|
||||||
|
Hamish Ekstrom,Shotput,0,C
|
||||||
|
Leah Armstrong,Shotput,0,C
|
||||||
|
Niamh O'Brien,Shotput,0,C
|
||||||
|
Peregrine Lundgren,Shotput,0,C
|
||||||
|
Aaron Fitzgerald,Shotput,0,A
|
||||||
|
Chisom Karlsson,Shotput,0,A
|
||||||
|
Demi Adeyemi,Shotput,0,A
|
||||||
|
Emiliano Sandberg,Shotput,0,A
|
||||||
|
Luca Martini,Shotput,0,A
|
||||||
|
Adaeze Oduya,Shotput,0,B
|
||||||
|
Dante Sorensen,Shotput,0,B
|
||||||
|
Jerome Vasquez,Shotput,0,B
|
||||||
|
Mia Nguyen,Shotput,0,B
|
||||||
|
Miles Morales,Shotput,0,B
|
||||||
|
Connor Gallagher,Shotput,0,C
|
||||||
|
Finn McCabe,Shotput,0,C
|
||||||
|
Reuben Stone,Shotput,0,C
|
||||||
|
Tobias Crane,Shotput,0,C
|
||||||
|
Vigdis Ekene,Shotput,0,C
|
||||||
|
Bruce Wayne,Triple Jump,0,A
|
||||||
|
Clark Kent,Triple Jump,0,A
|
||||||
|
Grace Henson,Triple Jump,0,A
|
||||||
|
Kofi Castellanos,Triple Jump,0,A
|
||||||
|
Wren Otieno,Triple Jump,0,A
|
||||||
|
Caden Nakamura,Triple Jump,0,B
|
||||||
|
Cashel Okereke,Triple Jump,0,B
|
||||||
|
Oliver Nwosu,Triple Jump,0,B
|
||||||
|
Saoirse Takahashi,Triple Jump,0,B
|
||||||
|
Solange Ezeoke,Triple Jump,0,B
|
||||||
|
Amara Akesson,Triple Jump,0,C
|
||||||
|
Margot Brandt,Triple Jump,0,C
|
||||||
|
Obinna Skoog,Triple Jump,0,C
|
||||||
|
Rafferty Bjornstad,Triple Jump,0,C
|
||||||
|
Siobhan Onyeka,Triple Jump,0,C
|
||||||
|
Astrid Emecheta,Triple Jump,0,A
|
||||||
|
Indira Eriksson,Triple Jump,0,A
|
||||||
|
Ingrid Okeke,Triple Jump,0,A
|
||||||
|
Leo Chambers,Triple Jump,0,A
|
||||||
|
Moira Kimura,Triple Jump,0,A
|
||||||
|
Adaora Sjoberg,Triple Jump,0,B
|
||||||
|
Briar Igwe,Triple Jump,0,B
|
||||||
|
Noah Patel,Triple Jump,0,B
|
||||||
|
Sofia Mendez,Triple Jump,0,B
|
||||||
|
Stellan Amaechi,Triple Jump,0,B
|
||||||
|
Caoimhe Onyia,Triple Jump,0,C
|
||||||
|
Ezra Magnusson,Triple Jump,0,C
|
||||||
|
Harriet Mbeki,Triple Jump,0,C
|
||||||
|
Xiomara Azikiwe,Triple Jump,0,C
|
||||||
|
Zinnia Lindberg,Triple Jump,0,C
|
||||||
|
Caius Falola,Triple Jump,0,A
|
||||||
|
Evander Solberg,Triple Jump,0,A
|
||||||
|
Lydia Andersen,Triple Jump,0,A
|
||||||
|
Sunniva Obi,Triple Jump,0,A
|
||||||
|
Zara Hussain,Triple Jump,0,A
|
||||||
|
Fleur Henriksen,Triple Jump,0,B
|
||||||
|
Niobe Dahl,Triple Jump,0,B
|
||||||
|
Sefton Hellgren,Triple Jump,0,B
|
||||||
|
Xanthe Reyes,Triple Jump,0,B
|
||||||
|
Zoe Ferrara,Triple Jump,0,B
|
||||||
|
Felix Mensah,Triple Jump,0,C
|
||||||
|
Idris Svensson,Triple Jump,0,C
|
||||||
|
Orla Yamamoto,Triple Jump,0,C
|
||||||
|
Theron Nzeogwu,Triple Jump,0,C
|
||||||
|
Vesper Thorvaldsen,Triple Jump,0,C
|
||||||
|
Chisom Karlsson,Triple Jump,0,A
|
||||||
|
Mia Nguyen,Triple Jump,0,A
|
||||||
|
Oluseun Alade,Triple Jump,0,A
|
||||||
|
Rowan Chukwu,Triple Jump,0,A
|
||||||
|
Viggo Ezeh,Triple Jump,0,A
|
||||||
|
Dante Sorensen,Triple Jump,0,B
|
||||||
|
Demi Adeyemi,Triple Jump,0,B
|
||||||
|
Isolde Ihejirika,Triple Jump,0,B
|
||||||
|
Miles Morales,Triple Jump,0,B
|
||||||
|
Soleil Anozie,Triple Jump,0,B
|
||||||
|
Adaeze Oduya,Triple Jump,0,C
|
||||||
|
Connor Gallagher,Triple Jump,0,C
|
||||||
|
Tara Lawson,Triple Jump,0,C
|
||||||
|
Tobias Crane,Triple Jump,0,C
|
||||||
|
Vigdis Ekene,Triple Jump,0,C
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
team_name,color
|
team_name,color
|
||||||
|
County,red
|
||||||
East,pink
|
East,pink
|
||||||
West,yellow
|
|
||||||
Laud,blue
|
Laud,blue
|
||||||
School,green
|
School,green
|
||||||
County,red
|
West,yellow
|
||||||
|
|||||||
|
@@ -50,7 +50,8 @@ async function seed() {
|
|||||||
|
|
||||||
console.log('Database reset complete. Seeding...');
|
console.log('Database reset complete. Seeding...');
|
||||||
|
|
||||||
let passwordHash = await Bun.password.hash('password');
|
const { hash } = await import('../src/lib/server/password.js');
|
||||||
|
let passwordHash = await hash('password');
|
||||||
await db
|
await db
|
||||||
.insert(schema.scorers)
|
.insert(schema.scorers)
|
||||||
.values({
|
.values({
|
||||||
@@ -126,9 +127,24 @@ async function seed() {
|
|||||||
const divisionId = divisionMap.get(row.division);
|
const divisionId = divisionMap.get(row.division);
|
||||||
if (!teamId) throw new Error(`Team "${row.team}" not found`);
|
if (!teamId) throw new Error(`Team "${row.team}" not found`);
|
||||||
|
|
||||||
|
let firstName = row.firstName;
|
||||||
|
let lastName = row.lastName;
|
||||||
|
|
||||||
|
// If lastName is empty, default to a space (column is nullable in practice)
|
||||||
|
if (!lastName) {
|
||||||
|
lastName = ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
// If firstName contains a space, treat the second word as lastName (only if lastName was blank)
|
||||||
|
if (lastName === ' ' && firstName && firstName.includes(' ')) {
|
||||||
|
const parts = firstName.split(' ');
|
||||||
|
firstName = parts[0];
|
||||||
|
lastName = parts.slice(1).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
await db.insert(schema.players).values({
|
await db.insert(schema.players).values({
|
||||||
firstName: row.firstName,
|
firstName,
|
||||||
lastName: row.lastName,
|
lastName,
|
||||||
team: teamId,
|
team: teamId,
|
||||||
division: divisionId || null
|
division: divisionId || null
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,11 +3,26 @@ import { sql, eq, and, desc, inArray } from 'drizzle-orm';
|
|||||||
import * as schema from '$lib/server/db/schema';
|
import * as schema from '$lib/server/db/schema';
|
||||||
import { globalEmitter } from './globalEmitter';
|
import { globalEmitter } from './globalEmitter';
|
||||||
|
|
||||||
|
export async function getSetting(key: string): Promise<string | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ value: schema.settings.value })
|
||||||
|
.from(schema.settings)
|
||||||
|
.where(eq(schema.settings.key, key));
|
||||||
|
return row?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSetting(key: string, value: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.insert(schema.settings)
|
||||||
|
.values({ key, value })
|
||||||
|
.onConflictDoUpdate({ target: schema.settings.key, set: { value } });
|
||||||
|
}
|
||||||
|
|
||||||
// Initial data for page load
|
// Initial data for page load
|
||||||
export async function getAllInitialInfo() {
|
export async function getAllInitialInfo() {
|
||||||
return {
|
return {
|
||||||
teams: await getTeams(),
|
teams: await getTeams(),
|
||||||
events: await getRegisteredEvents()
|
events: await getRegisteredEventsWithPlayers()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +230,81 @@ export async function getRegisteredEventsWithPlayers(eventId?: number) {
|
|||||||
return fullEventList;
|
return fullEventList;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPlayerEvents(playerId: number) {
|
||||||
|
const registrations = await getPlayerRegistrations(playerId);
|
||||||
|
if (registrations.length === 0) return [];
|
||||||
|
|
||||||
|
const events = await Promise.all(
|
||||||
|
registrations.map(async (reg) => {
|
||||||
|
const [event] = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.registeredEventsView)
|
||||||
|
.where(eq(schema.registeredEventsView.eventId, reg.eventId));
|
||||||
|
|
||||||
|
if (!event) return null;
|
||||||
|
|
||||||
|
let awardedPoints = 0;
|
||||||
|
if (event.scorePreset && reg.placement > 0) {
|
||||||
|
const [pointsRow] = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.scoringPresets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.scoringPresets.presetID, event.scorePreset),
|
||||||
|
eq(schema.scoringPresets.placement, reg.placement)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (pointsRow) awardedPoints = pointsRow.points;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.registeredResults)
|
||||||
|
.where(eq(schema.registeredResults.registeredPlayerId, reg.registeredPlayerId));
|
||||||
|
|
||||||
|
let winnerInfo: { name: string; color: string; points: number } | null = null;
|
||||||
|
if (event.winner) {
|
||||||
|
const teamData = await getTeams(event.winner);
|
||||||
|
winnerInfo = teamData.teams[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let unit = '';
|
||||||
|
if (event.resultPreset) {
|
||||||
|
const { resultPresets } = await getResultPreset(event.resultPreset);
|
||||||
|
if (resultPresets.length > 0) {
|
||||||
|
unit = resultPresets[0].unit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: reg.eventId,
|
||||||
|
name: reg.eventName,
|
||||||
|
division: event.division,
|
||||||
|
state: event.state,
|
||||||
|
completed: event.timeCompleted || 0,
|
||||||
|
winner: winnerInfo,
|
||||||
|
bracket: reg.bracket,
|
||||||
|
placement: reg.placement,
|
||||||
|
points: awardedPoints,
|
||||||
|
unit,
|
||||||
|
results: results.map((r) => ({
|
||||||
|
index: r.resultIndex,
|
||||||
|
result: r.result
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return events
|
||||||
|
.filter((e) => e !== null)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.completed && b.completed) return b.completed - a.completed;
|
||||||
|
if (a.completed) return -1;
|
||||||
|
if (b.completed) return 1;
|
||||||
|
return b.id - a.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function getLedgerEntries(limit: number | 'all' = 100) {
|
export async function getLedgerEntries(limit: number | 'all' = 100) {
|
||||||
let query = db
|
let query = db
|
||||||
.select({
|
.select({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { drizzle } from 'drizzle-orm/libsql';
|
import { createClient } from './libsql-wrapper';
|
||||||
import { createClient } from '@libsql/client';
|
import { construct } from 'drizzle-orm/libsql/driver-core';
|
||||||
import * as schema from './schema';
|
import * as schema from './schema';
|
||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
@@ -7,4 +7,4 @@ if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
|||||||
|
|
||||||
const client = createClient({ url: env.DATABASE_URL });
|
const client = createClient({ url: env.DATABASE_URL });
|
||||||
|
|
||||||
export const db = drizzle(client, { schema });
|
export const db = construct(client, { schema });
|
||||||
|
|||||||
7
src/lib/server/db/libsql-wrapper.ts
Normal file
7
src/lib/server/db/libsql-wrapper.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createRequire } from 'node:module';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const libsql = require('@libsql/client');
|
||||||
|
|
||||||
|
export const createClient = libsql.createClient;
|
||||||
|
export default libsql;
|
||||||
@@ -156,6 +156,11 @@ export const registeredEventsView = sqliteView('registeredEventsView').as((qb) =
|
|||||||
.innerJoin(divisions, eq(registeredEvents.division, divisions.id));
|
.innerJoin(divisions, eq(registeredEvents.division, divisions.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const settings = sqliteTable('settings', {
|
||||||
|
key: text('key').primaryKey(),
|
||||||
|
value: text('value').notNull()
|
||||||
|
});
|
||||||
|
|
||||||
export const registeredEventPlayersView = sqliteView('registeredEventPlayersView').as((qb) => {
|
export const registeredEventPlayersView = sqliteView('registeredEventPlayersView').as((qb) => {
|
||||||
return qb
|
return qb
|
||||||
.select({
|
.select({
|
||||||
|
|||||||
24
src/lib/server/password.ts
Normal file
24
src/lib/server/password.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
|
||||||
|
const scryptAsync = promisify(scrypt);
|
||||||
|
const SALT_LEN = 32;
|
||||||
|
const KEY_LEN = 64;
|
||||||
|
|
||||||
|
export async function hash(password: string): Promise<string> {
|
||||||
|
const salt = randomBytes(SALT_LEN);
|
||||||
|
const derivedKey = (await scryptAsync(password, salt, KEY_LEN)) as Buffer;
|
||||||
|
return `${salt.toString('hex')}:${derivedKey.toString('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verify(password: string, stored: string): Promise<boolean> {
|
||||||
|
const [saltHex, keyHex] = stored.split(':');
|
||||||
|
if (!saltHex || !keyHex) return false;
|
||||||
|
|
||||||
|
const salt = Buffer.from(saltHex, 'hex');
|
||||||
|
const key = Buffer.from(keyHex, 'hex');
|
||||||
|
const derivedKey = (await scryptAsync(password, salt, KEY_LEN)) as Buffer;
|
||||||
|
|
||||||
|
if (key.length !== derivedKey.length) return false;
|
||||||
|
return timingSafeEqual(key, derivedKey);
|
||||||
|
}
|
||||||
@@ -4,6 +4,32 @@
|
|||||||
import type { LayoutData } from './$types';
|
import type { LayoutData } from './$types';
|
||||||
|
|
||||||
let { children, data }: { children: import('svelte').Snippet; data: LayoutData } = $props();
|
let { children, data }: { children: import('svelte').Snippet; data: LayoutData } = $props();
|
||||||
|
|
||||||
|
let adminOpen = $state(false);
|
||||||
|
let wrapperRef = $state<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
function toggleAdmin() {
|
||||||
|
adminOpen = !adminOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAdmin() {
|
||||||
|
adminOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!adminOpen) return;
|
||||||
|
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (wrapperRef && !wrapperRef.contains(e.target as Node)) {
|
||||||
|
closeAdmin();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => document.addEventListener('click', handler), 0);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
document.removeEventListener('click', handler);
|
||||||
|
};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||||
@@ -17,11 +43,27 @@
|
|||||||
<div class="w-full"></div>
|
<div class="w-full"></div>
|
||||||
|
|
||||||
{#if data.user?.role === 'admin'}
|
{#if data.user?.role === 'admin'}
|
||||||
<a
|
<div class="dropdown-wrapper">
|
||||||
|
<button
|
||||||
class="align-text-middle justify-right mx-3 my-1 h-auto content-center rounded-sm border-2
|
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"
|
border-solid border-red-500 px-2"
|
||||||
href="/ledger">ledger</a
|
onclick={toggleAdmin}
|
||||||
>
|
>
|
||||||
|
admin {adminOpen ? '▲' : '▼'}
|
||||||
|
</button>
|
||||||
|
{#if adminOpen}
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
|
<div class="dropdown-menu" role="menu" tabindex="-1">
|
||||||
|
<a href="/ledger" role="menuitem">ledger</a>
|
||||||
|
<a href="/registrations" role="menuitem">registrations</a>
|
||||||
|
<a href="/manage-events" role="menuitem">events</a>
|
||||||
|
<a href="/import-csv" role="menuitem">import</a>
|
||||||
|
<a href="/manage-players" role="menuitem">players</a>
|
||||||
|
<a href="/manage-users" role="menuitem">users</a>
|
||||||
|
<a href="/settings" role="menuitem">settings</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if data.user}
|
{#if data.user}
|
||||||
<a
|
<a
|
||||||
@@ -44,4 +86,34 @@
|
|||||||
.header {
|
.header {
|
||||||
background-color: var(--ctp-mocha-crust);
|
background-color: var(--ctp-mocha-crust);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 140px;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
border: 2px solid var(--ctp-mocha-red);
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: var(--ctp-mocha-crust);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu a {
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu a:hover {
|
||||||
|
background-color: color-mix(in srgb, currentColor 12%, transparent);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { getAllInitialInfo } from '$lib/server/databaseManager';
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { getAllInitialInfo, getSetting } from '$lib/server/databaseManager';
|
||||||
|
|
||||||
// Provide initial data for the home page
|
// Provide initial data for the home page
|
||||||
export const load = async () => {
|
export const load = async ({ locals }) => {
|
||||||
|
const requireLogin = (await getSetting('requireLoginForLeaderboard')) === 'true';
|
||||||
|
if (requireLogin && !locals.user) {
|
||||||
|
throw redirect(303, '/login');
|
||||||
|
}
|
||||||
|
|
||||||
return await getAllInitialInfo();
|
return await getAllInitialInfo();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy, tick } from 'svelte';
|
import { onMount, onDestroy, tick } from 'svelte';
|
||||||
|
import { flip } from 'svelte/animate';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
data
|
data
|
||||||
@@ -8,8 +9,8 @@
|
|||||||
focusEventId?: number | null;
|
focusEventId?: number | null;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let teams = $derived(data.teams.teams);
|
let teams = $state(data.teams.teams);
|
||||||
let eventTable = $derived(data.events.events);
|
let eventTable = $state(data.events);
|
||||||
|
|
||||||
let scoreEndpoint: EventSource;
|
let scoreEndpoint: EventSource;
|
||||||
let eventEndpoint: EventSource;
|
let eventEndpoint: EventSource;
|
||||||
@@ -63,7 +64,7 @@
|
|||||||
if (focusEventId == null) return;
|
if (focusEventId == null) return;
|
||||||
tick().then(() => {
|
tick().then(() => {
|
||||||
const el = eventRefs[focusEventId!];
|
const el = eventRefs[focusEventId!];
|
||||||
el.scrollIntoView({ alignToTop: true, behavior: 'instant', container: 'nearest' });
|
el.scrollIntoView({ block: 'start', behavior: 'instant' });
|
||||||
tick().then(() => {
|
tick().then(() => {
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
});
|
});
|
||||||
@@ -132,6 +133,7 @@
|
|||||||
{#each leaderboard.slice(1) as team, i (team.name)}
|
{#each leaderboard.slice(1) as team, i (team.name)}
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
|
animate:flip={{ duration: 300 }}
|
||||||
class="score-box runner"
|
class="score-box runner"
|
||||||
style="--c:{team.color}"
|
style="--c:{team.color}"
|
||||||
aria-label="{team.name}, {ordinal(i + 2)} place, {team.points} points"
|
aria-label="{team.name}, {ordinal(i + 2)} place, {team.points} points"
|
||||||
@@ -169,6 +171,7 @@
|
|||||||
<div class="events">
|
<div class="events">
|
||||||
{#each eventTable as event (event.id)}
|
{#each eventTable as event (event.id)}
|
||||||
<div
|
<div
|
||||||
|
animate:flip={{ duration: 300 }}
|
||||||
class="event-card"
|
class="event-card"
|
||||||
class:ongoing-event={event.state == 1}
|
class:ongoing-event={event.state == 1}
|
||||||
class:completed-event={event.state == 2}
|
class:completed-event={event.state == 2}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export async function POST({ request }: any) {
|
|||||||
return new Error('send a response dummy');
|
return new Error('send a response dummy');
|
||||||
} else {
|
} else {
|
||||||
console.log(responseBody);
|
console.log(responseBody);
|
||||||
startEvent(responseBody.eventId);
|
await startEvent(responseBody.eventId);
|
||||||
return new Response('ok');
|
return new Response('ok');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/routes/api/eventTypes/+server.ts
Normal file
21
src/routes/api/eventTypes/+server.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { eventTypes } from '$lib/server/db/schema';
|
||||||
|
|
||||||
|
export async function POST({ request, locals }) {
|
||||||
|
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { name, scoringPreset, resultPreset } = body;
|
||||||
|
|
||||||
|
if (!name || scoringPreset == null || resultPreset == null) {
|
||||||
|
return json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [inserted] = await db
|
||||||
|
.insert(eventTypes)
|
||||||
|
.values({ name, scoringPreset, resultPreset })
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return json(inserted);
|
||||||
|
}
|
||||||
20
src/routes/api/playerEvents/[playerId]/+server.ts
Normal file
20
src/routes/api/playerEvents/[playerId]/+server.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { getPlayerEvents } from '$lib/server/databaseManager';
|
||||||
|
import { globalEmitter } from '$lib/server/globalEmitter';
|
||||||
|
import { generateEndpoint } from '$lib/server/endpoint';
|
||||||
|
|
||||||
|
export async function GET({ params, request }) {
|
||||||
|
const playerId = parseInt(params.playerId);
|
||||||
|
const endpoint = generateEndpoint(async (enqueue) => {
|
||||||
|
let eventList = async () => {
|
||||||
|
let events = await getPlayerEvents(playerId);
|
||||||
|
enqueue(events);
|
||||||
|
};
|
||||||
|
eventList();
|
||||||
|
globalEmitter.on('eventUpdate', eventList);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
globalEmitter.off('eventUpdate', eventList);
|
||||||
|
};
|
||||||
|
}, request);
|
||||||
|
return (await endpoint).response;
|
||||||
|
}
|
||||||
24
src/routes/api/registerEvent/+server.ts
Normal file
24
src/routes/api/registerEvent/+server.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { registeredEvents } from '$lib/server/db/schema';
|
||||||
|
import { globalEmitter } from '$lib/server/globalEmitter';
|
||||||
|
|
||||||
|
export async function POST({ request, locals }) {
|
||||||
|
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { eventType, division } = body;
|
||||||
|
|
||||||
|
if (eventType == null || division == null) {
|
||||||
|
return json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [inserted] = await db
|
||||||
|
.insert(registeredEvents)
|
||||||
|
.values({ eventType, division, state: 0 })
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
globalEmitter.emit('eventUpdate');
|
||||||
|
|
||||||
|
return json(inserted);
|
||||||
|
}
|
||||||
@@ -1,7 +1,55 @@
|
|||||||
import { getAllRegisteredEventPlayers } from '$lib/server/databaseManager';
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { registeredPlayers, registeredEvents, scorers } from '$lib/server/db/schema';
|
||||||
import { globalEmitter } from '$lib/server/globalEmitter';
|
import { globalEmitter } from '$lib/server/globalEmitter';
|
||||||
import { generateEndpoint } from '$lib/server/endpoint';
|
|
||||||
|
|
||||||
export async function POST({ request }: any) {
|
export async function POST({ request, locals }: any) {
|
||||||
return new Response('ok');
|
if (!locals.user) throw error(401, 'Unauthorized');
|
||||||
|
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');
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { eventId, playerId, bracketId } = body;
|
||||||
|
|
||||||
|
if (typeof eventId !== 'number' || typeof playerId !== 'number' || typeof bracketId !== 'number') {
|
||||||
|
throw error(400, 'eventId, playerId, and bracketId are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await db
|
||||||
|
.select({ state: registeredEvents.state })
|
||||||
|
.from(registeredEvents)
|
||||||
|
.where(eq(registeredEvents.id, eventId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!event) throw error(404, 'Event not found');
|
||||||
|
if (event.state !== 0) throw error(403, 'Cannot register players for events that are ongoing or finished');
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select({ id: registeredPlayers.id })
|
||||||
|
.from(registeredPlayers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(registeredPlayers.playerID, playerId),
|
||||||
|
eq(registeredPlayers.registeredEventID, eventId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw error(409, 'Player is already registered for this event');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [inserted] = await db
|
||||||
|
.insert(registeredPlayers)
|
||||||
|
.values({
|
||||||
|
playerID: playerId,
|
||||||
|
registeredEventID: eventId,
|
||||||
|
bracket: bracketId
|
||||||
|
})
|
||||||
|
.returning({ id: registeredPlayers.id });
|
||||||
|
|
||||||
|
globalEmitter.emit('eventUpdate');
|
||||||
|
|
||||||
|
return json({ id: inserted.id });
|
||||||
}
|
}
|
||||||
|
|||||||
69
src/routes/api/registrations/[id]/+server.ts
Normal file
69
src/routes/api/registrations/[id]/+server.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { registeredPlayers, registeredEvents, scorers } from '$lib/server/db/schema';
|
||||||
|
import { globalEmitter } from '$lib/server/globalEmitter';
|
||||||
|
|
||||||
|
export async function PATCH({ params, request, locals }: any) {
|
||||||
|
if (!locals.user) throw error(401, 'Unauthorized');
|
||||||
|
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');
|
||||||
|
|
||||||
|
const id = parseInt(params.id);
|
||||||
|
if (isNaN(id)) throw error(400, 'Invalid registration ID');
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
if (typeof body.playerId !== 'number') throw error(400, 'playerId is required');
|
||||||
|
|
||||||
|
const reg = await db
|
||||||
|
.select({
|
||||||
|
registeredEventID: registeredPlayers.registeredEventID
|
||||||
|
})
|
||||||
|
.from(registeredPlayers)
|
||||||
|
.where(eq(registeredPlayers.id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!reg) throw error(404, 'Registration not found');
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(registeredPlayers)
|
||||||
|
.set({ playerID: body.playerId })
|
||||||
|
.where(eq(registeredPlayers.id, id));
|
||||||
|
|
||||||
|
globalEmitter.emit('eventUpdate');
|
||||||
|
return json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE({ params, locals }: any) {
|
||||||
|
if (!locals.user) throw error(401, 'Unauthorized');
|
||||||
|
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');
|
||||||
|
|
||||||
|
const id = parseInt(params.id);
|
||||||
|
if (isNaN(id)) throw error(400, 'Invalid registration ID');
|
||||||
|
|
||||||
|
const reg = await db
|
||||||
|
.select({
|
||||||
|
registeredEventID: registeredPlayers.registeredEventID
|
||||||
|
})
|
||||||
|
.from(registeredPlayers)
|
||||||
|
.where(eq(registeredPlayers.id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!reg) throw error(404, 'Registration not found');
|
||||||
|
|
||||||
|
const event = await db
|
||||||
|
.select({ state: registeredEvents.state })
|
||||||
|
.from(registeredEvents)
|
||||||
|
.where(eq(registeredEvents.id, reg.registeredEventID))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!event || event.state !== 0) {
|
||||||
|
throw error(403, 'Can only delete registrations from events that have not started');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(registeredPlayers).where(eq(registeredPlayers.id, id));
|
||||||
|
|
||||||
|
globalEmitter.emit('eventUpdate');
|
||||||
|
return json({ success: true });
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { globalEmitter } from '$lib/server/globalEmitter';
|
|||||||
import { generateEndpoint } from '$lib/server/endpoint';
|
import { generateEndpoint } from '$lib/server/endpoint';
|
||||||
|
|
||||||
export async function POST({ request }: any) {
|
export async function POST({ request }: any) {
|
||||||
globalEmitter.emit('incrementScores');
|
globalEmitter.emit('scoreUpdate');
|
||||||
return new Response('ok');
|
return new Response('ok');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { scorers } from '$lib/server/db/schema';
|
import { scorers } from '$lib/server/db/schema';
|
||||||
|
import { getRegisteredEventsWithPlayers } from '$lib/server/databaseManager';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||||
if (!locals.user) return { user: null };
|
const eventId = parseInt(params.eventId);
|
||||||
|
const userInfo: { user: any } = { user: null };
|
||||||
|
|
||||||
|
if (locals.user) {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select({ role: scorers.role })
|
.select({ role: scorers.role })
|
||||||
.from(scorers)
|
.from(scorers)
|
||||||
.where(eq(scorers.id, locals.user.id));
|
.where(eq(scorers.id, locals.user.id));
|
||||||
return { user: { ...locals.user, role: row?.role ?? 'scorer' } };
|
userInfo.user = { ...locals.user, role: row?.role ?? 'scorer' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await getRegisteredEventsWithPlayers(eventId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...userInfo,
|
||||||
|
event: events[0] ?? null
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,22 +31,16 @@
|
|||||||
|
|
||||||
let eventEndpoint: EventSource;
|
let eventEndpoint: EventSource;
|
||||||
|
|
||||||
async function getEventData() {
|
let eventData = $state<any>(data.event);
|
||||||
let response = await fetch('/api/registeredEvents', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
eventId: eventId
|
|
||||||
}),
|
|
||||||
headers: {
|
|
||||||
'Content-type': 'application/json; charset=UTF-8'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
onMount(() => {
|
||||||
|
eventEndpoint = new EventSource('/api/registeredEvents');
|
||||||
// Sort players: placed first (ascending), unplaced last
|
eventEndpoint.onmessage = (e) => {
|
||||||
if (data && data[0] && data[0].registeredPlayers) {
|
const allEvents = JSON.parse(e.data);
|
||||||
data[0].registeredPlayers.forEach((bracket: any) => {
|
const updated = allEvents.find((ev: any) => ev.id == eventId);
|
||||||
|
if (updated) {
|
||||||
|
if (updated.registeredPlayers) {
|
||||||
|
updated.registeredPlayers.forEach((bracket: any) => {
|
||||||
bracket.items.sort((a: any, b: any) => {
|
bracket.items.sort((a: any, b: any) => {
|
||||||
if (a.placement === 0 && b.placement === 0) return 0;
|
if (a.placement === 0 && b.placement === 0) return 0;
|
||||||
if (a.placement === 0) return 1;
|
if (a.placement === 0) return 1;
|
||||||
@@ -55,43 +49,32 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
eventData = updated;
|
||||||
return data;
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
let eventDataPromise = getEventData();
|
onDestroy(() => {
|
||||||
|
eventEndpoint?.close();
|
||||||
onMount(() => {
|
|
||||||
eventEndpoint = new EventSource('/api/registeredEvents');
|
|
||||||
|
|
||||||
// eventEndpoint.onmessage = (e) => {
|
|
||||||
// const eventData = JSON.parse(e.data);
|
|
||||||
// console.log(eventData);
|
|
||||||
// };
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#await eventDataPromise}
|
{#if eventData}
|
||||||
<div>loading</div>
|
<div class="page-container">
|
||||||
{:then eventData}
|
<div class="event-card" class:ongoing-event={eventData.state == 1} class:completed-event={eventData.state == 2}
|
||||||
{@const event = eventData[0]}
|
style={eventData.state == 2 ? `--event-color: ${eventData.winner?.color ?? 'currentColor'}` : ''}>
|
||||||
{console.log(event)}
|
<div class="event-header">
|
||||||
<div class="flex justify-center">
|
<span class="event-name goldman">{eventData.name}</span>
|
||||||
<div class="w-full flex-col px-[5vw] text-center">
|
<span class="event-division">{eventData.division}</span>
|
||||||
<div
|
{#if eventData.state == 1}
|
||||||
style:background-color={event.state === 1
|
<span class="event-status goldman">ONGOING</span>
|
||||||
? 'color-mix(in srgb, #fe640b 18%, transparent)'
|
{:else if eventData.state == 2}
|
||||||
: event.state === 2
|
<span class="event-status goldman">FINISHED</span>
|
||||||
? '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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#each event.registeredPlayers as bracket, bi}
|
|
||||||
|
<div class="brackets px-[5vw]">
|
||||||
|
{#each eventData.registeredPlayers as bracket, bi}
|
||||||
{#if bi > 0}
|
{#if bi > 0}
|
||||||
<div class="bracket-sep" aria-hidden="true"></div>
|
<div class="bracket-sep" aria-hidden="true"></div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -116,17 +99,17 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="player-name-wrap" use:marquee>
|
<div class="player-name-wrap" use:marquee>
|
||||||
<span class=" text-xl">
|
<a href="/stats/player/{player.id}" class="marquee-inner">
|
||||||
{player.firstName}
|
{player.firstName}
|
||||||
{player.lastName}
|
{player.lastName}
|
||||||
</span>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{#if player.placement !== 0}
|
{#if player.placement !== 0}
|
||||||
<div class="player-placement goldman">{ordinal(player.placement)}</div>
|
<div class="player-placement goldman">{ordinal(player.placement)}</div>
|
||||||
<div class="resultContainer flex justify-center">
|
<div class="flex flex-col justify-center">
|
||||||
{#each player.playerScores as score}
|
{#each player.playerScores as score}
|
||||||
<div class="mx-5 my-1 rounded border-2" style="border-color:{player.teamColor}">
|
<div class="mx-1 my-0.5 rounded border px-1 text-xs" style="border-color:{player.teamColor}">
|
||||||
Run {score.resultIndex + 1}: {score.result}{event.resultPresets[0].unit}
|
Run {score.resultIndex + 1}: {score.result}{eventData.resultPresets[0].unit}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -139,6 +122,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if data.user}
|
{#if data.user}
|
||||||
<div class="mt-10 flex w-full justify-center">
|
<div class="mt-10 flex w-full justify-center">
|
||||||
<a
|
<a
|
||||||
@@ -147,15 +131,7 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/await}
|
</div>
|
||||||
|
{:else}
|
||||||
<style>
|
<div class="page-container"><div>loading</div></div>
|
||||||
.resultContainer {
|
{/if}
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
@media (max-width: 479px) {
|
|
||||||
.resultContainer {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -209,6 +209,9 @@
|
|||||||
eventId: eventId
|
eventId: eventId
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
event = { ...event, state: 1 };
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return new Error();
|
return new Error();
|
||||||
}
|
}
|
||||||
|
|||||||
187
src/routes/import-csv/+page.server.ts
Normal file
187
src/routes/import-csv/+page.server.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { players, teams, divisions, brackets, eventTypes, registeredEvents, registeredPlayers, scorers } from '$lib/server/db/schema';
|
||||||
|
import { parse } from 'csv-parse/sync';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
|
function findMatchingEvent(events: { name: string }[], csvName: string): { name: string } | undefined {
|
||||||
|
const trimmed = csvName.trim().toLowerCase();
|
||||||
|
let match = events.find((e) => e.name.toLowerCase() === trimmed);
|
||||||
|
if (match) return match;
|
||||||
|
match = events.find((e) => e.name.toLowerCase().startsWith(trimmed));
|
||||||
|
if (match) return match;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
import: async ({ locals, request }) => {
|
||||||
|
if (!locals.user) throw error(401, 'Unauthorized');
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('csv') as File;
|
||||||
|
if (!file || !file.size) {
|
||||||
|
return { success: false, errors: ['No CSV file provided.'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await file.text();
|
||||||
|
let records: Record<string, any>[];
|
||||||
|
try {
|
||||||
|
records = parse(text, { columns: true, skip_empty_lines: true, cast: true });
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, errors: [`Failed to parse CSV: ${(e as Error).message}`] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbTeams = await db.select().from(teams);
|
||||||
|
const dbDivisions = await db.select().from(divisions);
|
||||||
|
const dbBrackets = await db.select().from(brackets);
|
||||||
|
const dbEventTypes = await db.select().from(eventTypes);
|
||||||
|
const dbPlayers = await db.select().from(players);
|
||||||
|
const dbRegisteredEvents = await db.select().from(registeredEvents);
|
||||||
|
const dbRegisteredPlayers = await db.select().from(registeredPlayers);
|
||||||
|
|
||||||
|
const teamMap = new Map(dbTeams.map((t) => [t.name, t.id]));
|
||||||
|
const divisionExactMap = new Map(dbDivisions.map((d) => [d.name, d.id]));
|
||||||
|
const divisionLowerMap = new Map(dbDivisions.map((d) => [d.name.toLowerCase(), d.id]));
|
||||||
|
const bracketMap = new Map(dbBrackets.map((b) => [b.name, b.id]));
|
||||||
|
const eventTypeMap = new Map(dbEventTypes.map((e) => [e.name, e.id]));
|
||||||
|
|
||||||
|
const playersCreated: string[] = [];
|
||||||
|
const playersSkipped: string[] = [];
|
||||||
|
const registrationsCreated: string[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < records.length; i++) {
|
||||||
|
const record = records[i];
|
||||||
|
const rowNum = i + 2;
|
||||||
|
|
||||||
|
let firstName = String(record['First Name'] ?? '').trim();
|
||||||
|
let lastName = String(record['Last name'] ?? '').trim();
|
||||||
|
|
||||||
|
if (!firstName && !lastName) continue;
|
||||||
|
if (!firstName) { errors.push(`Row ${rowNum}: Missing first name`); continue; }
|
||||||
|
|
||||||
|
if (!lastName) {
|
||||||
|
lastName = ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastName === ' ' && firstName.includes(' ')) {
|
||||||
|
const parts = firstName.split(' ');
|
||||||
|
firstName = parts[0];
|
||||||
|
lastName = parts.slice(1).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBracket = String(record['Bracket'] ?? '').trim();
|
||||||
|
const rawEvent = String(record['Event'] ?? '').trim();
|
||||||
|
const rawClass = String(record['Class'] ?? '').trim();
|
||||||
|
|
||||||
|
if (!rawBracket) { errors.push(`Row ${rowNum}: Missing bracket`); continue; }
|
||||||
|
if (!rawEvent) { errors.push(`Row ${rowNum}: Missing event`); continue; }
|
||||||
|
if (!rawClass) { errors.push(`Row ${rowNum}: Missing class`); continue; }
|
||||||
|
|
||||||
|
const classParts = rawClass.split(/\s+/);
|
||||||
|
if (classParts.length !== 2) {
|
||||||
|
errors.push(`Row ${rowNum}: Class "${rawClass}" must contain exactly 2 words (division and team)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [divisionPart, teamPart] = classParts;
|
||||||
|
|
||||||
|
const teamId = teamMap.get(teamPart);
|
||||||
|
if (!teamId) {
|
||||||
|
errors.push(`Row ${rowNum}: Team "${teamPart}" not found`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let divisionId = divisionExactMap.get(divisionPart) ?? divisionLowerMap.get(divisionPart.toLowerCase());
|
||||||
|
if (!divisionId) {
|
||||||
|
const withPrefix = `Year ${divisionPart}`;
|
||||||
|
divisionId = divisionExactMap.get(withPrefix) ?? divisionLowerMap.get(withPrefix.toLowerCase());
|
||||||
|
}
|
||||||
|
if (!divisionId) {
|
||||||
|
errors.push(`Row ${rowNum}: Division "${divisionPart}" not found`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bracketId = bracketMap.get(rawBracket);
|
||||||
|
if (!bracketId) {
|
||||||
|
errors.push(`Row ${rowNum}: Bracket "${rawBracket}" not found`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedEvent = findMatchingEvent(dbEventTypes, rawEvent);
|
||||||
|
if (!matchedEvent) {
|
||||||
|
errors.push(`Row ${rowNum}: Event type "${rawEvent}" not found`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const eventTypeId = eventTypeMap.get(matchedEvent.name)!;
|
||||||
|
|
||||||
|
const regEvent = dbRegisteredEvents.find(
|
||||||
|
(re) => re.eventType === eventTypeId && re.division === divisionId
|
||||||
|
);
|
||||||
|
if (!regEvent) {
|
||||||
|
errors.push(`Row ${rowNum}: Registered event "${matchedEvent.name}" for division "${divisionPart}" not found`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let player = dbPlayers.find(
|
||||||
|
(p) => p.firstName === firstName && p.lastName === lastName && p.team === teamId && p.division === divisionId
|
||||||
|
);
|
||||||
|
if (!player) {
|
||||||
|
const [inserted] = await db
|
||||||
|
.insert(players)
|
||||||
|
.values({ firstName, lastName, team: teamId, division: divisionId })
|
||||||
|
.returning();
|
||||||
|
player = inserted;
|
||||||
|
dbPlayers.push(player);
|
||||||
|
playersCreated.push(`${firstName} ${lastName}`);
|
||||||
|
} else {
|
||||||
|
playersSkipped.push(`${firstName} ${lastName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyRegistered = dbRegisteredPlayers.some(
|
||||||
|
(rp) => rp.playerID === player.id && rp.registeredEventID === regEvent.id
|
||||||
|
);
|
||||||
|
if (!alreadyRegistered) {
|
||||||
|
await db.insert(registeredPlayers).values({
|
||||||
|
playerID: player.id,
|
||||||
|
registeredEventID: regEvent.id,
|
||||||
|
bracket: bracketId,
|
||||||
|
placement: 0
|
||||||
|
});
|
||||||
|
registrationsCreated.push(`${firstName} ${lastName} → ${matchedEvent.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: errors.length === 0,
|
||||||
|
summary: {
|
||||||
|
playersCreated: playersCreated.length,
|
||||||
|
playersSkipped: playersSkipped.length,
|
||||||
|
registrationsCreated: registrationsCreated.length
|
||||||
|
},
|
||||||
|
playersCreated,
|
||||||
|
playersSkipped,
|
||||||
|
registrationsCreated,
|
||||||
|
errors
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
93
src/routes/import-csv/+page.svelte
Normal file
93
src/routes/import-csv/+page.svelte
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
let result = $state<{
|
||||||
|
success: boolean;
|
||||||
|
summary?: { playersCreated: number; playersSkipped: number; registrationsCreated: number };
|
||||||
|
playersCreated?: string[];
|
||||||
|
playersSkipped?: string[];
|
||||||
|
registrationsCreated?: string[];
|
||||||
|
errors?: string[];
|
||||||
|
} | null>(null);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page-container">
|
||||||
|
<h2 class="section-label">Import CSV</h2>
|
||||||
|
<div class="card">
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/import"
|
||||||
|
enctype="multipart/form-data"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result: formResult }) => {
|
||||||
|
if (formResult.type === 'success') {
|
||||||
|
result = formResult.data as any;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap gap-4 items-end">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="csv-file">CSV File</label>
|
||||||
|
<input id="csv-file" type="file" name="csv" accept=".csv" required />
|
||||||
|
</div>
|
||||||
|
<button type="submit">Upload & Import</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if result}
|
||||||
|
<div class="card" style="margin-top: 1rem;">
|
||||||
|
<h3 class="section-label">Result</h3>
|
||||||
|
{#if result.success}
|
||||||
|
<p style="color: var(--ctp-mocha-green);">Import completed successfully.</p>
|
||||||
|
{:else}
|
||||||
|
<p style="color: var(--ctp-mocha-red);">Import completed with errors.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if result.summary}
|
||||||
|
<ul style="margin-top: 0.5rem;">
|
||||||
|
<li>Players created: {result.summary.playersCreated}</li>
|
||||||
|
<li>Players skipped (already exist): {result.summary.playersSkipped}</li>
|
||||||
|
<li>Registrations created: {result.summary.registrationsCreated}</li>
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if result.playersCreated && result.playersCreated.length > 0}
|
||||||
|
<details style="margin-top: 0.5rem;">
|
||||||
|
<summary>Created players ({result.playersCreated.length})</summary>
|
||||||
|
<ul style="margin-left: 1rem;">
|
||||||
|
{#each result.playersCreated as name}
|
||||||
|
<li>{name}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if result.registrationsCreated && result.registrationsCreated.length > 0}
|
||||||
|
<details style="margin-top: 0.5rem;">
|
||||||
|
<summary>Created registrations ({result.registrationsCreated.length})</summary>
|
||||||
|
<ul style="margin-left: 1rem;">
|
||||||
|
{#each result.registrationsCreated as reg}
|
||||||
|
<li>{reg}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if result.errors && result.errors.length > 0}
|
||||||
|
<details style="margin-top: 0.5rem;" open>
|
||||||
|
<summary>Errors ({result.errors.length})</summary>
|
||||||
|
<ul style="margin-left: 1rem; color: var(--ctp-mocha-red);">
|
||||||
|
{#each result.errors as err}
|
||||||
|
<li>{err}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -17,6 +17,13 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-container {
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -51,7 +58,7 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-rows: 1fr;
|
grid-template-rows: 1fr;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: filter 0.15s ease;
|
transition: aspect-ratio 0.3s ease, border-width 0.3s ease, min-height 0.3s ease, filter 0.15s ease;
|
||||||
}
|
}
|
||||||
.score-box:hover {
|
.score-box:hover {
|
||||||
filter: brightness(1.15);
|
filter: brightness(1.15);
|
||||||
@@ -65,10 +72,12 @@
|
|||||||
aspect-ratio: 2 / 1;
|
aspect-ratio: 2 / 1;
|
||||||
border-width: 3px;
|
border-width: 3px;
|
||||||
min-height: 80px;
|
min-height: 80px;
|
||||||
|
transition: aspect-ratio 0.3s ease, border-width 0.3s ease, min-height 0.3s ease;
|
||||||
}
|
}
|
||||||
.runner {
|
.runner {
|
||||||
aspect-ratio: 4 / 1;
|
aspect-ratio: 4 / 1;
|
||||||
min-height: 56px;
|
min-height: 56px;
|
||||||
|
transition: aspect-ratio 0.3s ease, min-height 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ghost SVG fills cell, text sits at bottom */
|
/* Ghost SVG fills cell, text sits at bottom */
|
||||||
@@ -90,62 +99,112 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
|
min-width: 0;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
.runner .score-fg {
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
.score-meta {
|
.score-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.score-rank {
|
.score-rank {
|
||||||
font-size: 10px;
|
font-size: clamp(14px, 2.5vw, 17px);
|
||||||
letter-spacing: 1.5px;
|
letter-spacing: 1.2px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
opacity: 0.5;
|
opacity: 0.85;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
margin-bottom: 3px;
|
margin-bottom: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-shadow: 0 0 6px rgb(0 0 0 / 50%);
|
||||||
}
|
}
|
||||||
.score-name {
|
.score-name {
|
||||||
font-size: clamp(11px, 3vw, 20px);
|
font-size: clamp(18px, 3.5vw, 22px);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
.score-pts {
|
.score-pts {
|
||||||
font-size: clamp(20px, 5.5vw, 40px);
|
font-size: clamp(20px, 5.5vw, 40px);
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
text-shadow: 0 0 8px rgb(0 0 0 / 80%), 0 0 4px rgb(0 0 0 / 60%);
|
||||||
|
}
|
||||||
|
.winner .score-meta {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.winner .score-rank {
|
||||||
|
font-size: clamp(16px, 3vw, 20px);
|
||||||
|
opacity: 0.85;
|
||||||
|
margin-bottom: 3px;
|
||||||
}
|
}
|
||||||
.winner .score-name {
|
.winner .score-name {
|
||||||
font-size: clamp(16px, 5vw, 32px);
|
font-size: clamp(34px, 9vw, 64px);
|
||||||
}
|
}
|
||||||
.winner .score-pts {
|
.winner .score-pts {
|
||||||
font-size: clamp(26px, 7.5vw, 52px);
|
font-size: clamp(34px, 9vw, 64px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive runners-up: 1 col <480px, 2 cols 480-699px, 4 cols ≥700px */
|
/* Smallest screens: one-line for runners, bigger text to fill height */
|
||||||
|
@media (max-width: 479px) {
|
||||||
|
.score-meta {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.score-rank {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.runner .score-rank {
|
||||||
|
font-size: clamp(16px, 3.5vw, 20px);
|
||||||
|
}
|
||||||
|
.runner .score-name {
|
||||||
|
font-size: clamp(20px, 5vw, 26px);
|
||||||
|
}
|
||||||
|
.score-pts {
|
||||||
|
font-size: clamp(22px, 6vw, 34px);
|
||||||
|
}
|
||||||
|
.winner .score-name {
|
||||||
|
font-size: clamp(26px, 7vw, 42px);
|
||||||
|
}
|
||||||
|
.winner .score-pts {
|
||||||
|
font-size: clamp(28px, 8vw, 48px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive runners-up: 1 col <480px, 2 cols 480-950px, 4 cols >950px */
|
||||||
.runners-grid {
|
.runners-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
transition: gap 0.3s ease;
|
||||||
}
|
}
|
||||||
@media (min-width: 480px) {
|
@media (min-width: 480px) and (max-width: 950px) {
|
||||||
.runners-grid {
|
.runners-grid {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.winner {
|
.winner {
|
||||||
aspect-ratio: 2 / 1;
|
aspect-ratio: 12 / 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.runner {
|
.runner {
|
||||||
aspect-ratio: 2 / 1;
|
aspect-ratio: 2 / 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.runner .score-rank {
|
||||||
|
font-size: clamp(18px, 3vw, 22px);
|
||||||
}
|
}
|
||||||
@media (min-width: 700px) {
|
|
||||||
|
.runner .score-name {
|
||||||
|
font-size: clamp(24px, 4vw, 30px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 951px) {
|
||||||
.runners-grid {
|
.runners-grid {
|
||||||
/* Use --runner-count so fewer teams fill the row evenly */
|
/* Use --runner-count so fewer teams fill the row evenly */
|
||||||
grid-template-columns: repeat(min(var(--runner-count), 4), 1fr);
|
grid-template-columns: repeat(min(var(--runner-count), 4), 1fr);
|
||||||
@@ -256,17 +315,20 @@
|
|||||||
.brackets {
|
.brackets {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
transition: gap 0.3s ease, align-items 0.3s ease;
|
||||||
}
|
}
|
||||||
.bracket-sep {
|
.bracket-sep {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: color-mix(in srgb, currentColor 10%, transparent);
|
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||||
margin: 0 10px;
|
margin: 0 10px;
|
||||||
|
transition: height 0.3s ease, width 0.3s ease, margin 0.3s ease;
|
||||||
}
|
}
|
||||||
.bracket-row {
|
.bracket-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 7px 9px;
|
padding: 7px 9px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
transition: gap 0.3s ease, padding 0.3s ease, min-width 0.3s ease, flex 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Player boxes */
|
/* Player boxes */
|
||||||
@@ -283,6 +345,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 52px;
|
min-height: 52px;
|
||||||
|
transition: max-width 0.3s ease, flex 0.3s ease, min-height 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile: single column layout */
|
/* Mobile: single column layout */
|
||||||
@@ -321,7 +384,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 700px) {
|
@media (min-width: 951px) {
|
||||||
.player-box {
|
.player-box {
|
||||||
max-width: calc(25% - 6px);
|
max-width: calc(25% - 6px);
|
||||||
}
|
}
|
||||||
@@ -384,3 +447,76 @@
|
|||||||
.player-placement-gap {
|
.player-placement-gap {
|
||||||
height: 50px;
|
height: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Shared card pattern for auth, settings, etc. */
|
||||||
|
.card {
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.card h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.card label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.card input,
|
||||||
|
.card select {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, currentColor 6%, transparent);
|
||||||
|
color: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.card input:focus,
|
||||||
|
.card select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: currentColor;
|
||||||
|
}
|
||||||
|
.card button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: filter 0.15s ease;
|
||||||
|
}
|
||||||
|
.card button:hover {
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
.card button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.card .error {
|
||||||
|
color: var(--ctp-mocha-red, #f38ba8);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.card .switch {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.card .switch a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.card .switch a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.card small {
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,12 +67,13 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-4">
|
<div class="page-container">
|
||||||
<h2 class="mb-4 text-lg font-bold">Manual Ledger Entry</h2>
|
<h2 class="section-label">Manual Ledger Entry</h2>
|
||||||
<div class="mb-4 flex flex-wrap gap-4 items-end">
|
<div class="card">
|
||||||
|
<div class="flex flex-wrap gap-4 items-end">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm mb-1">Event</label>
|
<label class="block text-sm mb-1">Event</label>
|
||||||
<select bind:value={selectedEventId} class="border rounded px-2 py-1">
|
<select bind:value={selectedEventId}>
|
||||||
<option value="">Select event...</option>
|
<option value="">Select event...</option>
|
||||||
{#each data.events as event (event.id)}
|
{#each data.events as event (event.id)}
|
||||||
<option value={event.id}>{event.name} — {event.division}</option>
|
<option value={event.id}>{event.name} — {event.division}</option>
|
||||||
@@ -86,18 +87,26 @@
|
|||||||
type="number"
|
type="number"
|
||||||
value={inputPoints[team] ?? '0'}
|
value={inputPoints[team] ?? '0'}
|
||||||
oninput={(e) => { inputPoints[team] = (e.target as HTMLInputElement).value; }}
|
oninput={(e) => { inputPoints[team] = (e.target as HTMLInputElement).value; }}
|
||||||
class="border rounded px-2 py-1 w-20"
|
style="width: 5rem;"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
<button
|
<button onclick={submitEntry} disabled={!selectedEventId || submitting}>
|
||||||
onclick={submitEntry}
|
|
||||||
disabled={!selectedEventId || submitting}
|
|
||||||
class="border rounded px-4 py-1 bg-green-700 text-white disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Submit
|
Submit
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-label" style="padding-bottom: 4px;">
|
||||||
|
Ledger Entries
|
||||||
|
<button
|
||||||
|
onclick={() => loadEntries(!showAll)}
|
||||||
|
style="margin-left: 0.5rem; padding: 0.15rem 0.5rem; font-size: 0.75rem; border: 1px solid color-mix(in srgb, currentColor 18%, transparent); border-radius: 4px; background: transparent; color: inherit; cursor: pointer;"
|
||||||
|
>
|
||||||
|
{showAll ? 'Show 100 most recent' : 'Load all entries'}
|
||||||
|
</button>
|
||||||
|
<span class="text-sm opacity-60" style="margin-left: 0.5rem;">{entries.length} entries</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Table data={entries} maxHeight="none">
|
<Table data={entries} maxHeight="none">
|
||||||
{#snippet header()}
|
{#snippet header()}
|
||||||
@@ -121,14 +130,4 @@
|
|||||||
<td class="px-2">{formatTime(entry.timestamp)}</td>
|
<td class="px-2">{formatTime(entry.timestamp)}</td>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
<div class="mt-4">
|
|
||||||
<button
|
|
||||||
onclick={() => loadEntries(!showAll)}
|
|
||||||
class="border rounded px-4 py-1"
|
|
||||||
>
|
|
||||||
{showAll ? 'Show 100 most recent' : 'Load all entries'}
|
|
||||||
</button>
|
|
||||||
<span class="ml-2 text-sm">{entries.length} entries shown</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
// src/routes/login/+page.server.ts
|
|
||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { scorers } from '$lib/server/db/schema';
|
import { scorers } from '$lib/server/db/schema';
|
||||||
import { generateSessionToken, createSession, setSessionTokenCookie } from '$lib/server/auth';
|
import { generateSessionToken, createSession, setSessionTokenCookie } from '$lib/server/auth';
|
||||||
import type { Actions } from '../login/$types';
|
import { verify } from '$lib/server/password';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import type { PageServerLoad } from '../login/$types';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
return { user: locals.user };
|
return { user: locals.user };
|
||||||
@@ -19,7 +17,7 @@ export const actions: Actions = {
|
|||||||
const password = data.get('password') as string;
|
const password = data.get('password') as string;
|
||||||
|
|
||||||
const [user] = await db.select().from(scorers).where(eq(scorers.username, username));
|
const [user] = await db.select().from(scorers).where(eq(scorers.username, username));
|
||||||
if (!user || !(await Bun.password.verify(password, user.passwordHash))) {
|
if (!user || !(await verify(password, user.passwordHash))) {
|
||||||
return fail(400, { message: 'Invalid credentials' });
|
return fail(400, { message: 'Invalid credentials' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ActionData, PageData } from './$types';
|
import type { ActionData, PageData } from './$types';
|
||||||
export let data: PageData;
|
let { data, form }: { data: PageData; form: any } = $props();
|
||||||
export let form: ActionData;
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="auth-card">
|
<div class="page-container">
|
||||||
|
<div class="card" style="max-width: 360px; margin: 3rem auto;">
|
||||||
{#if data.user}
|
{#if data.user}
|
||||||
<div class="align-center flex h-full w-full flex-col text-center">
|
<div class="align-center flex h-full w-full flex-col text-center">
|
||||||
<h1>Already logged in</h1>
|
<h1>Already logged in</h1>
|
||||||
@@ -20,18 +20,12 @@
|
|||||||
<form method="POST">
|
<form method="POST">
|
||||||
<label>
|
<label>
|
||||||
Username
|
Username
|
||||||
<input class="text-black" name="username" type="text" autocomplete="username" required />
|
<input name="username" type="text" autocomplete="username" required />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
Password
|
Password
|
||||||
<input
|
<input name="password" type="password" autocomplete="current-password" required />
|
||||||
class="text-black"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
autocomplete="current-password"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{#if form?.message}
|
{#if form?.message}
|
||||||
@@ -40,51 +34,8 @@
|
|||||||
|
|
||||||
<button type="submit">Log in</button>
|
<button type="submit">Log in</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<p class="switch">No account? <a href="/register">Sign up</a></p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- <p class="switch">No account? <a href="/signup">Sign up</a></p> -->
|
|
||||||
</div>
|
</div>
|
||||||
|
</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>
|
|
||||||
74
src/routes/manage-events/+page.server.ts
Normal file
74
src/routes/manage-events/+page.server.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import {
|
||||||
|
scorers,
|
||||||
|
eventTypes,
|
||||||
|
registeredEvents,
|
||||||
|
divisions,
|
||||||
|
scoringPresets,
|
||||||
|
resultPresets,
|
||||||
|
teams
|
||||||
|
} 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');
|
||||||
|
|
||||||
|
const eventTypeList = await db.select().from(eventTypes).orderBy(eventTypes.id);
|
||||||
|
const divisionList = await db.select().from(divisions).orderBy(divisions.id);
|
||||||
|
const resultPresetList = await db.select().from(resultPresets).orderBy(resultPresets.id);
|
||||||
|
|
||||||
|
const scoringPresetRows = await db
|
||||||
|
.select()
|
||||||
|
.from(scoringPresets)
|
||||||
|
.orderBy(scoringPresets.presetID, scoringPresets.placement);
|
||||||
|
|
||||||
|
const scoringPresetMap = new Map<number, { placement: number; points: number }[]>();
|
||||||
|
for (const row of scoringPresetRows) {
|
||||||
|
if (!scoringPresetMap.has(row.presetID)) {
|
||||||
|
scoringPresetMap.set(row.presetID, []);
|
||||||
|
}
|
||||||
|
scoringPresetMap.get(row.presetID)!.push({ placement: row.placement, points: row.points });
|
||||||
|
}
|
||||||
|
|
||||||
|
const scoringPresetsList = Array.from(scoringPresetMap.entries()).map(([id, entries]) => ({
|
||||||
|
presetID: id,
|
||||||
|
entries
|
||||||
|
}));
|
||||||
|
|
||||||
|
const registeredEventRows = await db
|
||||||
|
.select({
|
||||||
|
id: registeredEvents.id,
|
||||||
|
eventId: registeredEvents.id,
|
||||||
|
eventName: eventTypes.name,
|
||||||
|
division: divisions.name,
|
||||||
|
state: registeredEvents.state,
|
||||||
|
timeCompleted: registeredEvents.timeCompleted,
|
||||||
|
winner: registeredEvents.teamWinner,
|
||||||
|
winnerName: teams.name,
|
||||||
|
winnerColor: teams.color,
|
||||||
|
scorePreset: eventTypes.scoringPreset,
|
||||||
|
resultPreset: eventTypes.resultPreset
|
||||||
|
})
|
||||||
|
.from(registeredEvents)
|
||||||
|
.innerJoin(eventTypes, eq(registeredEvents.eventType, eventTypes.id))
|
||||||
|
.innerJoin(divisions, eq(registeredEvents.division, divisions.id))
|
||||||
|
.leftJoin(teams, eq(registeredEvents.teamWinner, teams.id))
|
||||||
|
.orderBy(registeredEvents.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventTypes: eventTypeList,
|
||||||
|
divisions: divisionList,
|
||||||
|
resultPresets: resultPresetList,
|
||||||
|
scoringPresets: scoringPresetsList,
|
||||||
|
registeredEvents: registeredEventRows
|
||||||
|
};
|
||||||
|
};
|
||||||
321
src/routes/manage-events/+page.svelte
Normal file
321
src/routes/manage-events/+page.svelte
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Table from '$lib/ui/Table.svelte';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
// --- Create Event Type form ---
|
||||||
|
let showCreateForm = $state(false);
|
||||||
|
let newEventName = $state('');
|
||||||
|
let newScoringPreset = $state<number>(0);
|
||||||
|
let newResultPreset = $state<number>(0);
|
||||||
|
|
||||||
|
// --- Register Event form ---
|
||||||
|
let showRegisterForm = $state(false);
|
||||||
|
let registerEventType = $state<number>(0);
|
||||||
|
let registerDivision = $state<number>(0);
|
||||||
|
|
||||||
|
function scoringPresetDescription(entries: { placement: number; points: number }[]): string {
|
||||||
|
return entries.map((e) => `${e.placement}→${e.points}`).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreateEventType() {
|
||||||
|
if (!newEventName || !newScoringPreset || !newResultPreset) return;
|
||||||
|
const res = await fetch('/api/eventTypes', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: newEventName,
|
||||||
|
scoringPreset: newScoringPreset,
|
||||||
|
resultPreset: newResultPreset
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
alert('Failed to create event type');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await res.json();
|
||||||
|
data.eventTypes.push({
|
||||||
|
id: result.id,
|
||||||
|
name: newEventName,
|
||||||
|
scoringPreset: newScoringPreset,
|
||||||
|
resultPreset: newResultPreset
|
||||||
|
});
|
||||||
|
newEventName = '';
|
||||||
|
newScoringPreset = 0;
|
||||||
|
newResultPreset = 0;
|
||||||
|
showCreateForm = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRegisterEvent() {
|
||||||
|
if (!registerEventType || !registerDivision) return;
|
||||||
|
const res = await fetch('/api/registerEvent', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
eventType: registerEventType,
|
||||||
|
division: registerDivision
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
alert('Failed to register event');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await res.json();
|
||||||
|
const et = data.eventTypes.find((e) => e.id === registerEventType);
|
||||||
|
const dv = data.divisions.find((d) => d.id === registerDivision);
|
||||||
|
if (et && dv) {
|
||||||
|
data.registeredEvents.push({
|
||||||
|
id: result.id,
|
||||||
|
eventId: result.id,
|
||||||
|
eventName: et.name,
|
||||||
|
division: dv.name,
|
||||||
|
state: 0,
|
||||||
|
timeCompleted: null,
|
||||||
|
winner: null,
|
||||||
|
winnerName: null,
|
||||||
|
winnerColor: null,
|
||||||
|
scorePreset: et.scoringPreset,
|
||||||
|
resultPreset: et.resultPreset
|
||||||
|
});
|
||||||
|
}
|
||||||
|
registerEventType = 0;
|
||||||
|
registerDivision = 0;
|
||||||
|
showRegisterForm = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let eventTypesWithPresets = $derived(
|
||||||
|
data.eventTypes.map((et) => {
|
||||||
|
const sp = data.scoringPresets.find((s) => s.presetID === et.scoringPreset);
|
||||||
|
const rp = data.resultPresets.find((r) => r.id === et.resultPreset);
|
||||||
|
return {
|
||||||
|
id: et.id,
|
||||||
|
name: et.name,
|
||||||
|
scoringPresetDescription: sp ? scoringPresetDescription(sp.entries) : 'Unknown',
|
||||||
|
resultPresetName: rp ? rp.presetName : 'Unknown'
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
function stateLabel(state: number): string {
|
||||||
|
if (state === 0) return 'Pending';
|
||||||
|
if (state === 1) return 'Ongoing';
|
||||||
|
return 'Finished';
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateColor(state: number): string {
|
||||||
|
if (state === 0) return 'var(--ctp-mocha-yellow)';
|
||||||
|
if (state === 1) return 'var(--ctp-mocha-green)';
|
||||||
|
return 'var(--ctp-mocha-blue)';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page-container">
|
||||||
|
<div class="section-label">Manage Events</div>
|
||||||
|
|
||||||
|
<!-- Create Event Type -->
|
||||||
|
<div
|
||||||
|
class="section-label"
|
||||||
|
style="padding-bottom: 4px; display: flex; justify-content: space-between; align-items: center;"
|
||||||
|
>
|
||||||
|
<span>Create Event Type</span>
|
||||||
|
<button class="action-btn new-btn" onclick={() => { showCreateForm = !showCreateForm; }}>
|
||||||
|
{showCreateForm ? 'Cancel' : 'New Event Type'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showCreateForm}
|
||||||
|
<div class="card new-form-card">
|
||||||
|
<div class="new-form-title">New Event Type</div>
|
||||||
|
<div class="flex flex-wrap gap-4 items-end">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="new-event-name">Event Name</label>
|
||||||
|
<input
|
||||||
|
id="new-event-name"
|
||||||
|
type="text"
|
||||||
|
bind:value={newEventName}
|
||||||
|
placeholder="e.g. 100m Sprint"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="new-scoring-preset">Scoring Preset</label>
|
||||||
|
<select id="new-scoring-preset" bind:value={newScoringPreset}>
|
||||||
|
<option value={0}>Select scoring...</option>
|
||||||
|
{#each data.scoringPresets as preset (preset.presetID)}
|
||||||
|
<option value={preset.presetID}>
|
||||||
|
Preset {preset.presetID} ({scoringPresetDescription(preset.entries)})
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="new-result-preset">Result Preset</label>
|
||||||
|
<select id="new-result-preset" bind:value={newResultPreset}>
|
||||||
|
<option value={0}>Select result...</option>
|
||||||
|
{#each data.resultPresets as rp (rp.id)}
|
||||||
|
<option value={rp.id}>{rp.presetName} ({rp.numberOfResults} result(s), {rp.unit})</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="action-btn submit-btn"
|
||||||
|
onclick={submitCreateEventType}
|
||||||
|
disabled={!newEventName || !newScoringPreset || !newResultPreset}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Event Types Table -->
|
||||||
|
<div
|
||||||
|
class="section-label"
|
||||||
|
style="padding-bottom: 4px; display: flex; justify-content: space-between; align-items: center;"
|
||||||
|
>
|
||||||
|
<span>Event Types</span>
|
||||||
|
<span class="text-sm opacity-60">{data.eventTypes.length} entries</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table data={eventTypesWithPresets} maxHeight="none">
|
||||||
|
{#snippet header()}
|
||||||
|
<th class="px-2">ID</th>
|
||||||
|
<th class="px-2">Name</th>
|
||||||
|
<th class="px-2">Scoring Preset</th>
|
||||||
|
<th class="px-2">Result Preset</th>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet row(et: (typeof eventTypesWithPresets)[number])}
|
||||||
|
<td class="px-2">{et.id}</td>
|
||||||
|
<td class="px-2">{et.name}</td>
|
||||||
|
<td class="px-2">{et.scoringPresetDescription}</td>
|
||||||
|
<td class="px-2">{et.resultPresetName}</td>
|
||||||
|
{/snippet}
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<!-- Register Event -->
|
||||||
|
<div
|
||||||
|
class="section-label"
|
||||||
|
style="padding-bottom: 4px; display: flex; justify-content: space-between; align-items: center;"
|
||||||
|
>
|
||||||
|
<span>Register Event</span>
|
||||||
|
<button class="action-btn new-btn" onclick={() => { showRegisterForm = !showRegisterForm; }}>
|
||||||
|
{showRegisterForm ? 'Cancel' : 'Register Event'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showRegisterForm}
|
||||||
|
<div class="card new-form-card">
|
||||||
|
<div class="new-form-title">Register an Event for a Division</div>
|
||||||
|
<div class="flex flex-wrap gap-4 items-end">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="register-event-type">Event Type</label>
|
||||||
|
<select id="register-event-type" bind:value={registerEventType}>
|
||||||
|
<option value={0}>Select event type...</option>
|
||||||
|
{#each data.eventTypes as et (et.id)}
|
||||||
|
<option value={et.id}>{et.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="register-division">Division</label>
|
||||||
|
<select id="register-division" bind:value={registerDivision}>
|
||||||
|
<option value={0}>Select division...</option>
|
||||||
|
{#each data.divisions as div (div.id)}
|
||||||
|
<option value={div.id}>{div.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="action-btn submit-btn"
|
||||||
|
onclick={submitRegisterEvent}
|
||||||
|
disabled={!registerEventType || !registerDivision}
|
||||||
|
>
|
||||||
|
Register
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Registered Events Table -->
|
||||||
|
<div
|
||||||
|
class="section-label"
|
||||||
|
style="padding-bottom: 4px; display: flex; justify-content: space-between; align-items: center;"
|
||||||
|
>
|
||||||
|
<span>Registered Events</span>
|
||||||
|
<span class="text-sm opacity-60">{data.registeredEvents.length} entries</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table data={data.registeredEvents} maxHeight="none">
|
||||||
|
{#snippet header()}
|
||||||
|
<th class="px-2">ID</th>
|
||||||
|
<th class="px-2">Event Name</th>
|
||||||
|
<th class="px-2">Division</th>
|
||||||
|
<th class="px-2">State</th>
|
||||||
|
<th class="px-2">Winner</th>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet row(event: (typeof data.registeredEvents)[number])}
|
||||||
|
<td class="px-2">{event.eventId}</td>
|
||||||
|
<td class="px-2">{event.eventName}</td>
|
||||||
|
<td class="px-2">{event.division}</td>
|
||||||
|
<td class="px-2" style="color: {stateColor(event.state)};">{stateLabel(event.state)}</td>
|
||||||
|
<td
|
||||||
|
class="px-2"
|
||||||
|
style={event.winnerName ? `color: var(--ctp-mocha-${event.winnerColor});` : ''}
|
||||||
|
>
|
||||||
|
{event.winnerName ?? '-'}
|
||||||
|
</td>
|
||||||
|
{/snippet}
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.action-btn {
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: color-mix(in srgb, currentColor 6%, transparent);
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.new-btn {
|
||||||
|
border-color: var(--ctp-mocha-green, #a6e3a1);
|
||||||
|
color: var(--ctp-mocha-green, #a6e3a1);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.submit-btn {
|
||||||
|
border-color: var(--ctp-mocha-blue, #89b4fa);
|
||||||
|
color: var(--ctp-mocha-blue, #89b4fa);
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.submit-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-form-card {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-form-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
136
src/routes/manage-players/+page.server.ts
Normal file
136
src/routes/manage-players/+page.server.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { error, redirect, fail } from '@sveltejs/kit';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { scorers, players, teams, divisions } from '$lib/server/db/schema';
|
||||||
|
import type { PageServerLoad, Actions } 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');
|
||||||
|
|
||||||
|
const playerList = await db
|
||||||
|
.select({
|
||||||
|
id: players.id,
|
||||||
|
firstName: players.firstName,
|
||||||
|
lastName: players.lastName,
|
||||||
|
teamId: players.team,
|
||||||
|
teamName: teams.name,
|
||||||
|
divisionId: players.division,
|
||||||
|
divisionName: divisions.name
|
||||||
|
})
|
||||||
|
.from(players)
|
||||||
|
.leftJoin(teams, eq(players.team, teams.id))
|
||||||
|
.leftJoin(divisions, eq(players.division, divisions.id))
|
||||||
|
.orderBy(players.firstName, players.lastName);
|
||||||
|
|
||||||
|
const teamList = await db.select().from(teams).orderBy(teams.name);
|
||||||
|
const divisionList = await db.select().from(divisions).orderBy(divisions.name);
|
||||||
|
|
||||||
|
return { players: playerList, teams: teamList, divisions: divisionList };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
createPlayer: async ({ locals, request }) => {
|
||||||
|
if (!locals.user) throw error(401, 'Unauthorized');
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
const data = await request.formData();
|
||||||
|
const firstName = (data.get('firstName') as string)?.trim();
|
||||||
|
const lastName = (data.get('lastName') as string)?.trim();
|
||||||
|
const teamId = data.get('teamId') as string;
|
||||||
|
const divisionId = data.get('divisionId') as string;
|
||||||
|
|
||||||
|
if (!firstName || !lastName || !teamId) {
|
||||||
|
return fail(400, { createMessage: 'First name, last name, and team are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(players)
|
||||||
|
.values({
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
team: parseInt(teamId),
|
||||||
|
division: divisionId ? parseInt(divisionId) : null
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
editPlayer: async ({ locals, request }) => {
|
||||||
|
if (!locals.user) throw error(401, 'Unauthorized');
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
const data = await request.formData();
|
||||||
|
const playerId = data.get('playerId') as string;
|
||||||
|
const firstName = (data.get('firstName') as string)?.trim();
|
||||||
|
const lastName = (data.get('lastName') as string)?.trim();
|
||||||
|
const teamId = data.get('teamId') as string;
|
||||||
|
const divisionId = data.get('divisionId') as string;
|
||||||
|
|
||||||
|
if (!playerId || !firstName || !lastName || !teamId) {
|
||||||
|
return fail(400, { editMessage: 'First name, last name, and team are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = await db.select().from(players).where(eq(players.id, parseInt(playerId)));
|
||||||
|
if (!existing) {
|
||||||
|
return fail(404, { editMessage: 'Player not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(players)
|
||||||
|
.set({
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
team: parseInt(teamId),
|
||||||
|
division: divisionId ? parseInt(divisionId) : null
|
||||||
|
})
|
||||||
|
.where(eq(players.id, parseInt(playerId)));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
deletePlayer: async ({ locals, request }) => {
|
||||||
|
if (!locals.user) throw error(401, 'Unauthorized');
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
const data = await request.formData();
|
||||||
|
const playerId = data.get('playerId') as string;
|
||||||
|
|
||||||
|
if (!playerId) {
|
||||||
|
return fail(400, { deleteMessage: 'Player ID required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = await db.select().from(players).where(eq(players.id, parseInt(playerId)));
|
||||||
|
if (!existing) {
|
||||||
|
return fail(404, { deleteMessage: 'Player not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(players).where(eq(players.id, parseInt(playerId)));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
383
src/routes/manage-players/+page.svelte
Normal file
383
src/routes/manage-players/+page.svelte
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
import Table from '$lib/ui/Table.svelte';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
let showCreateForm = $state(false);
|
||||||
|
let editingId = $state<number | null>(null);
|
||||||
|
|
||||||
|
let createFirstName = $state('');
|
||||||
|
let createLastName = $state('');
|
||||||
|
let createTeamId = $state('');
|
||||||
|
let createDivisionId = $state('');
|
||||||
|
|
||||||
|
let editFirstName = $state('');
|
||||||
|
let editLastName = $state('');
|
||||||
|
let editTeamId = $state('');
|
||||||
|
let editDivisionId = $state('');
|
||||||
|
|
||||||
|
let searchQuery = $state('');
|
||||||
|
|
||||||
|
let createMessage = $state('');
|
||||||
|
let editMessage = $state('');
|
||||||
|
let deleteMessage = $state('');
|
||||||
|
|
||||||
|
let filteredPlayers = $derived(
|
||||||
|
searchQuery
|
||||||
|
? data.players.filter((p) =>
|
||||||
|
p.firstName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
p.lastName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
p.teamName.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
)
|
||||||
|
: data.players
|
||||||
|
);
|
||||||
|
|
||||||
|
function resetCreateForm() {
|
||||||
|
createFirstName = '';
|
||||||
|
createLastName = '';
|
||||||
|
createTeamId = '';
|
||||||
|
createDivisionId = '';
|
||||||
|
createMessage = '';
|
||||||
|
showCreateForm = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(player: (typeof data.players)[number]) {
|
||||||
|
editingId = player.id;
|
||||||
|
editMessage = '';
|
||||||
|
editFirstName = player.firstName;
|
||||||
|
editLastName = player.lastName;
|
||||||
|
editTeamId = String(player.teamId);
|
||||||
|
editDivisionId = player.divisionId ? String(player.divisionId) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editingId = null;
|
||||||
|
editMessage = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page-container">
|
||||||
|
<div class="section-label">Manage Players</div>
|
||||||
|
|
||||||
|
<!-- Create Player -->
|
||||||
|
<div
|
||||||
|
class="section-label"
|
||||||
|
style="padding-bottom: 4px; display: flex; justify-content: space-between; align-items: center;"
|
||||||
|
>
|
||||||
|
<span>Add New Player</span>
|
||||||
|
<button class="action-btn new-btn" onclick={() => { showCreateForm = !showCreateForm; }}>
|
||||||
|
{showCreateForm ? 'Cancel' : 'New Player'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showCreateForm}
|
||||||
|
<div class="card new-form-card">
|
||||||
|
<div class="new-form-title">Create a new player</div>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/createPlayer"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result }) => {
|
||||||
|
if (result.type === 'success') {
|
||||||
|
resetCreateForm();
|
||||||
|
await invalidateAll();
|
||||||
|
} else if (result.type === 'failure') {
|
||||||
|
const d = result.data as { createMessage?: string } | null;
|
||||||
|
createMessage = d?.createMessage ?? 'Failed to create player';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap gap-4 items-end">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="create-first-name">First Name</label>
|
||||||
|
<input
|
||||||
|
id="create-first-name"
|
||||||
|
name="firstName"
|
||||||
|
type="text"
|
||||||
|
bind:value={createFirstName}
|
||||||
|
placeholder="First name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="create-last-name">Last Name</label>
|
||||||
|
<input
|
||||||
|
id="create-last-name"
|
||||||
|
name="lastName"
|
||||||
|
type="text"
|
||||||
|
bind:value={createLastName}
|
||||||
|
placeholder="Last name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="create-team">Team</label>
|
||||||
|
<select id="create-team" name="teamId" bind:value={createTeamId} required>
|
||||||
|
<option value="">Select team...</option>
|
||||||
|
{#each data.teams as team (team.id)}
|
||||||
|
<option value={team.id}>{team.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="create-division">Division</label>
|
||||||
|
<select id="create-division" name="divisionId" bind:value={createDivisionId}>
|
||||||
|
<option value="">None</option>
|
||||||
|
{#each data.divisions as div (div.id)}
|
||||||
|
<option value={div.id}>{div.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="action-btn submit-btn"
|
||||||
|
type="submit"
|
||||||
|
disabled={!createFirstName || !createLastName || !createTeamId}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if createMessage}
|
||||||
|
<p class="error" style="margin-top: 0.5rem;">{createMessage}</p>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Edit Player -->
|
||||||
|
{#if editingId !== null}
|
||||||
|
<div
|
||||||
|
class="section-label"
|
||||||
|
style="padding-bottom: 4px; display: flex; justify-content: space-between; align-items: center;"
|
||||||
|
>
|
||||||
|
<span>Edit Player</span>
|
||||||
|
<button class="action-btn new-btn" onclick={cancelEdit}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card new-form-card">
|
||||||
|
<div class="new-form-title">Update player details</div>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/editPlayer"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result }) => {
|
||||||
|
if (result.type === 'success') {
|
||||||
|
cancelEdit();
|
||||||
|
await invalidateAll();
|
||||||
|
} else if (result.type === 'failure') {
|
||||||
|
const d = result.data as { editMessage?: string } | null;
|
||||||
|
editMessage = d?.editMessage ?? 'Failed to update player';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="playerId" value={editingId} />
|
||||||
|
<div class="flex flex-wrap gap-4 items-end">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="edit-first-name">First Name</label>
|
||||||
|
<input
|
||||||
|
id="edit-first-name"
|
||||||
|
name="firstName"
|
||||||
|
type="text"
|
||||||
|
bind:value={editFirstName}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="edit-last-name">Last Name</label>
|
||||||
|
<input
|
||||||
|
id="edit-last-name"
|
||||||
|
name="lastName"
|
||||||
|
type="text"
|
||||||
|
bind:value={editLastName}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="edit-team">Team</label>
|
||||||
|
<select id="edit-team" name="teamId" bind:value={editTeamId} required>
|
||||||
|
{#each data.teams as team (team.id)}
|
||||||
|
<option value={String(team.id)}>{team.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="edit-division">Division</label>
|
||||||
|
<select id="edit-division" name="divisionId" bind:value={editDivisionId}>
|
||||||
|
<option value="">None</option>
|
||||||
|
{#each data.divisions as div (div.id)}
|
||||||
|
<option value={String(div.id)}>{div.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="action-btn submit-btn" type="submit">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if editMessage}
|
||||||
|
<p class="error" style="margin-top: 0.5rem;">{editMessage}</p>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Players Table -->
|
||||||
|
<div
|
||||||
|
class="section-label"
|
||||||
|
style="padding-bottom: 4px; display: flex; justify-content: space-between; align-items: center;"
|
||||||
|
>
|
||||||
|
<span>All Players</span>
|
||||||
|
<span class="text-sm opacity-60">{filteredPlayers.length} / {data.players.length} entries</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-bar">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by name or team..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-section">
|
||||||
|
<Table data={filteredPlayers} maxHeight="none">
|
||||||
|
{#snippet header()}
|
||||||
|
<th class="px-2">First Name</th>
|
||||||
|
<th class="px-2">Last Name</th>
|
||||||
|
<th class="px-2">Team</th>
|
||||||
|
<th class="px-2">Division</th>
|
||||||
|
<th class="px-2">Actions</th>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet row(player: (typeof data.players)[number])}
|
||||||
|
<td class="px-2">{player.firstName}</td>
|
||||||
|
<td class="px-2">{player.lastName}</td>
|
||||||
|
<td class="px-2">{player.teamName}</td>
|
||||||
|
<td class="px-2">{player.divisionName ?? '-'}</td>
|
||||||
|
<td class="px-2" style="white-space: nowrap;">
|
||||||
|
<button
|
||||||
|
class="action-btn edit-btn"
|
||||||
|
onclick={() => startEdit(player)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/deletePlayer"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result }) => {
|
||||||
|
if (result.type === 'success') {
|
||||||
|
await invalidateAll();
|
||||||
|
} else if (result.type === 'failure') {
|
||||||
|
const d = result.data as { deleteMessage?: string } | null;
|
||||||
|
deleteMessage = d?.deleteMessage ?? 'Failed to delete player';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
style="display: inline;"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
if (!confirm(`Delete ${player.firstName} ${player.lastName}?`)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="playerId" value={player.id} />
|
||||||
|
<button class="action-btn delete-btn" type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
{/snippet}
|
||||||
|
</Table>
|
||||||
|
{#if deleteMessage}
|
||||||
|
<p class="error" style="margin-top: 0.5rem;">{deleteMessage}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.action-btn {
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: color-mix(in srgb, currentColor 6%, transparent);
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.new-btn {
|
||||||
|
border-color: var(--ctp-mocha-green, #a6e3a1);
|
||||||
|
color: var(--ctp-mocha-green, #a6e3a1);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.submit-btn {
|
||||||
|
border-color: var(--ctp-mocha-blue, #89b4fa);
|
||||||
|
color: var(--ctp-mocha-blue, #89b4fa);
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.submit-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.edit-btn {
|
||||||
|
border-color: var(--ctp-mocha-yellow, #f9e2af);
|
||||||
|
color: var(--ctp-mocha-yellow, #f9e2af);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.delete-btn {
|
||||||
|
border-color: var(--ctp-mocha-red, #f38ba8);
|
||||||
|
color: var(--ctp-mocha-red, #f38ba8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-form-card {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-form-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--ctp-mocha-red, #f38ba8);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-section {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
padding: 0 16px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, currentColor 6%, transparent);
|
||||||
|
color: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: currentColor;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
90
src/routes/manage-users/+page.server.ts
Normal file
90
src/routes/manage-users/+page.server.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { error, redirect, fail } from '@sveltejs/kit';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { scorers } from '$lib/server/db/schema';
|
||||||
|
import { hash } from '$lib/server/password';
|
||||||
|
import type { PageServerLoad, Actions } 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await db
|
||||||
|
.select({ id: scorers.id, username: scorers.username, role: scorers.role })
|
||||||
|
.from(scorers)
|
||||||
|
.orderBy(scorers.username);
|
||||||
|
|
||||||
|
return { users };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
register: async ({ locals, request }) => {
|
||||||
|
if (!locals.user) throw error(401, 'Unauthorized');
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
const data = await 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, { registerMessage: '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, { registerMessage: 'Username already taken' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await hash(password);
|
||||||
|
const userId = crypto.randomUUID();
|
||||||
|
|
||||||
|
await db.insert(scorers).values({ id: userId, username, passwordHash });
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
changePassword: async ({ locals, request }) => {
|
||||||
|
if (!locals.user) throw error(401, 'Unauthorized');
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
const data = await request.formData();
|
||||||
|
const userId = data.get('userId') as string;
|
||||||
|
const newPassword = data.get('newPassword') as string;
|
||||||
|
|
||||||
|
if (!userId || !newPassword || newPassword.length < 8) {
|
||||||
|
return fail(400, { passwordMessage: 'User selection and an 8+ character password are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [user] = await db.select().from(scorers).where(eq(scorers.id, userId));
|
||||||
|
if (!user) {
|
||||||
|
return fail(404, { passwordMessage: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await hash(newPassword);
|
||||||
|
await db.update(scorers).set({ passwordHash }).where(eq(scorers.id, userId));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
246
src/routes/manage-users/+page.svelte
Normal file
246
src/routes/manage-users/+page.svelte
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
import Table from '$lib/ui/Table.svelte';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
let showRegisterForm = $state(false);
|
||||||
|
let showPasswordForm = $state(false);
|
||||||
|
|
||||||
|
let registerUsername = $state('');
|
||||||
|
let registerPassword = $state('');
|
||||||
|
|
||||||
|
let selectedUserId = $state('');
|
||||||
|
let newPassword = $state('');
|
||||||
|
|
||||||
|
let registerMessage = $state('');
|
||||||
|
let passwordMessage = $state('');
|
||||||
|
|
||||||
|
function resetRegisterForm() {
|
||||||
|
registerUsername = '';
|
||||||
|
registerPassword = '';
|
||||||
|
registerMessage = '';
|
||||||
|
showRegisterForm = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPasswordForm() {
|
||||||
|
selectedUserId = '';
|
||||||
|
newPassword = '';
|
||||||
|
passwordMessage = '';
|
||||||
|
showPasswordForm = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page-container">
|
||||||
|
<div class="section-label">Manage Users</div>
|
||||||
|
|
||||||
|
<!-- Register New Scorer -->
|
||||||
|
<div
|
||||||
|
class="section-label"
|
||||||
|
style="padding-bottom: 4px; display: flex; justify-content: space-between; align-items: center;"
|
||||||
|
>
|
||||||
|
<span>Register New Scorer</span>
|
||||||
|
<button class="action-btn new-btn" onclick={() => { showRegisterForm = !showRegisterForm; }}>
|
||||||
|
{showRegisterForm ? 'Cancel' : 'New Scorer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showRegisterForm}
|
||||||
|
<div class="card new-form-card">
|
||||||
|
<div class="new-form-title">Create a new scorer account</div>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/register"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result }) => {
|
||||||
|
if (result.type === 'success') {
|
||||||
|
resetRegisterForm();
|
||||||
|
await invalidateAll();
|
||||||
|
} else if (result.type === 'failure') {
|
||||||
|
const d = result.data as { registerMessage?: string } | null;
|
||||||
|
registerMessage = d?.registerMessage ?? 'Registration failed';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap gap-4 items-end">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="register-username">Username</label>
|
||||||
|
<input
|
||||||
|
id="register-username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
bind:value={registerUsername}
|
||||||
|
placeholder="scorer username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="register-password">Password</label>
|
||||||
|
<input
|
||||||
|
id="register-password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
bind:value={registerPassword}
|
||||||
|
placeholder="min 8 characters"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="action-btn submit-btn"
|
||||||
|
type="submit"
|
||||||
|
disabled={!registerUsername || registerPassword.length < 8}
|
||||||
|
>
|
||||||
|
Register
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if registerMessage}
|
||||||
|
<p class="error" style="margin-top: 0.5rem;">{registerMessage}</p>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Change Password -->
|
||||||
|
<div
|
||||||
|
class="section-label"
|
||||||
|
style="padding-bottom: 4px; display: flex; justify-content: space-between; align-items: center;"
|
||||||
|
>
|
||||||
|
<span>Change Password</span>
|
||||||
|
<button class="action-btn new-btn" onclick={() => { showPasswordForm = !showPasswordForm; }}>
|
||||||
|
{showPasswordForm ? 'Cancel' : 'Change Password'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showPasswordForm}
|
||||||
|
<div class="card new-form-card">
|
||||||
|
<div class="new-form-title">Set a new password for an existing scorer</div>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/changePassword"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result }) => {
|
||||||
|
if (result.type === 'success') {
|
||||||
|
resetPasswordForm();
|
||||||
|
} else if (result.type === 'failure') {
|
||||||
|
const d = result.data as { passwordMessage?: string } | null;
|
||||||
|
passwordMessage = d?.passwordMessage ?? 'Password change failed';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap gap-4 items-end">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="change-user">User</label>
|
||||||
|
<select id="change-user" name="userId" bind:value={selectedUserId} required>
|
||||||
|
<option value="">Select user...</option>
|
||||||
|
{#each data.users as user (user.id)}
|
||||||
|
<option value={user.id}>{user.username} ({user.role})</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="change-password">New Password</label>
|
||||||
|
<input
|
||||||
|
id="change-password"
|
||||||
|
name="newPassword"
|
||||||
|
type="password"
|
||||||
|
bind:value={newPassword}
|
||||||
|
placeholder="min 8 characters"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="action-btn submit-btn"
|
||||||
|
type="submit"
|
||||||
|
disabled={!selectedUserId || newPassword.length < 8}
|
||||||
|
>
|
||||||
|
Update Password
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if passwordMessage}
|
||||||
|
<p class="error" style="margin-top: 0.5rem;">{passwordMessage}</p>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Users Table -->
|
||||||
|
<div
|
||||||
|
class="section-label"
|
||||||
|
style="padding-bottom: 4px; display: flex; justify-content: space-between; align-items: center;"
|
||||||
|
>
|
||||||
|
<span>All Scorers</span>
|
||||||
|
<span class="text-sm opacity-60">{data.users.length} entries</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table data={data.users} maxHeight="none">
|
||||||
|
{#snippet header()}
|
||||||
|
<th class="px-2">Username</th>
|
||||||
|
<th class="px-2">Role</th>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet row(user: (typeof data.users)[number])}
|
||||||
|
<td class="px-2">{user.username}</td>
|
||||||
|
<td class="px-2">{user.role}</td>
|
||||||
|
{/snippet}
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.action-btn {
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: color-mix(in srgb, currentColor 6%, transparent);
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.new-btn {
|
||||||
|
border-color: var(--ctp-mocha-green, #a6e3a1);
|
||||||
|
color: var(--ctp-mocha-green, #a6e3a1);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.submit-btn {
|
||||||
|
border-color: var(--ctp-mocha-blue, #89b4fa);
|
||||||
|
color: var(--ctp-mocha-blue, #89b4fa);
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.submit-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-form-card {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-form-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--ctp-mocha-red, #f38ba8);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,13 +1,24 @@
|
|||||||
// src/routes/signup/+page.server.ts
|
|
||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { scorers } from '$lib/server/db/schema';
|
import { scorers } from '$lib/server/db/schema';
|
||||||
import { generateSessionToken, createSession, setSessionTokenCookie } from '$lib/server/auth';
|
import { generateSessionToken, createSession, setSessionTokenCookie } from '$lib/server/auth';
|
||||||
import type { Actions } from '../signup/$types';
|
import { hash } from '$lib/server/password';
|
||||||
|
import { getSetting } from '$lib/server/databaseManager';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
const disabled = (await getSetting('disableRegistration')) === 'true';
|
||||||
|
return { disabled };
|
||||||
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
default: async (event) => {
|
default: async (event) => {
|
||||||
|
const disabled = (await getSetting('disableRegistration')) === 'true';
|
||||||
|
if (disabled) {
|
||||||
|
return fail(403, { message: 'Registration is currently disabled by an administrator.' });
|
||||||
|
}
|
||||||
|
|
||||||
const data = await event.request.formData();
|
const data = await event.request.formData();
|
||||||
const username = data.get('username') as string;
|
const username = data.get('username') as string;
|
||||||
const password = data.get('password') as string;
|
const password = data.get('password') as string;
|
||||||
@@ -21,7 +32,7 @@ export const actions: Actions = {
|
|||||||
return fail(400, { message: 'Username already taken' });
|
return fail(400, { message: 'Username already taken' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await Bun.password.hash(password);
|
const passwordHash = await hash(password);
|
||||||
const userId = crypto.randomUUID();
|
const userId = crypto.randomUUID();
|
||||||
|
|
||||||
await db.insert(scorers).values({ id: userId, username, passwordHash });
|
await db.insert(scorers).values({ id: userId, username, passwordHash });
|
||||||
|
|||||||
@@ -1,27 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ActionData } from './$types';
|
import type { PageData } from './$types';
|
||||||
export let form: ActionData;
|
let { data, form }: { data: PageData; form: any } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="auth-card">
|
<div class="page-container">
|
||||||
|
<div class="card" style="max-width: 360px; margin: 3rem auto;">
|
||||||
<h1>Sign up</h1>
|
<h1>Sign up</h1>
|
||||||
|
|
||||||
|
{#if data.disabled}
|
||||||
|
<p class="error">Registration is currently disabled by an administrator.</p>
|
||||||
|
{:else}
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
<label>
|
<label>
|
||||||
Username
|
Username
|
||||||
<input name="username" class="text-black" type="text" autocomplete="username" required />
|
<input name="username" type="text" autocomplete="username" required />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
Password
|
Password
|
||||||
<input
|
<input name="password" type="password" autocomplete="new-password" minlength="8" required />
|
||||||
class="text-black"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
autocomplete="new-password"
|
|
||||||
minlength="8"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<small>At least 8 characters</small>
|
<small>At least 8 characters</small>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -33,51 +30,6 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="switch">Already have an account? <a href="/login">Log in</a></p>
|
<p class="switch">Already have an account? <a href="/login">Log in</a></p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</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>
|
|
||||||
|
|||||||
110
src/routes/registrations/+page.server.ts
Normal file
110
src/routes/registrations/+page.server.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
|
import { eq, sql } from 'drizzle-orm';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import {
|
||||||
|
scorers,
|
||||||
|
registeredPlayers,
|
||||||
|
registeredEvents,
|
||||||
|
brackets,
|
||||||
|
eventTypes,
|
||||||
|
players,
|
||||||
|
teams,
|
||||||
|
divisions,
|
||||||
|
scoringPresets
|
||||||
|
} 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');
|
||||||
|
|
||||||
|
const events = await db
|
||||||
|
.select({
|
||||||
|
id: registeredEvents.id,
|
||||||
|
name: eventTypes.name,
|
||||||
|
division: divisions.name,
|
||||||
|
state: registeredEvents.state
|
||||||
|
})
|
||||||
|
.from(registeredEvents)
|
||||||
|
.innerJoin(eventTypes, eq(registeredEvents.eventType, eventTypes.id))
|
||||||
|
.innerJoin(divisions, eq(registeredEvents.division, divisions.id))
|
||||||
|
.orderBy(registeredEvents.id);
|
||||||
|
|
||||||
|
const registrations = await db
|
||||||
|
.select({
|
||||||
|
registeredPlayerId: registeredPlayers.id,
|
||||||
|
playerId: players.id,
|
||||||
|
firstName: players.firstName,
|
||||||
|
lastName: players.lastName,
|
||||||
|
placement: registeredPlayers.placement,
|
||||||
|
bracket: brackets.name,
|
||||||
|
eventId: registeredEvents.id,
|
||||||
|
eventName: eventTypes.name,
|
||||||
|
teamId: teams.id,
|
||||||
|
teamName: teams.name,
|
||||||
|
teamColor: teams.color,
|
||||||
|
state: registeredEvents.state,
|
||||||
|
division: divisions.name,
|
||||||
|
points: sql<number>`COALESCE((
|
||||||
|
SELECT ${scoringPresets.points}
|
||||||
|
FROM ${scoringPresets}
|
||||||
|
WHERE ${scoringPresets.presetID} = ${eventTypes.scoringPreset}
|
||||||
|
AND ${scoringPresets.placement} = ${registeredPlayers.placement}
|
||||||
|
), 0)`
|
||||||
|
.mapWith(Number)
|
||||||
|
.as('points')
|
||||||
|
})
|
||||||
|
.from(registeredPlayers)
|
||||||
|
.innerJoin(registeredEvents, eq(registeredPlayers.registeredEventID, registeredEvents.id))
|
||||||
|
.innerJoin(brackets, eq(registeredPlayers.bracket, brackets.id))
|
||||||
|
.innerJoin(eventTypes, eq(registeredEvents.eventType, eventTypes.id))
|
||||||
|
.innerJoin(players, eq(registeredPlayers.playerID, players.id))
|
||||||
|
.innerJoin(teams, eq(players.team, teams.id))
|
||||||
|
.innerJoin(divisions, eq(registeredEvents.division, divisions.id))
|
||||||
|
.orderBy(teams.name, registeredEvents.id, brackets.name);
|
||||||
|
|
||||||
|
const allPlayers = await db
|
||||||
|
.select({
|
||||||
|
id: players.id,
|
||||||
|
firstName: players.firstName,
|
||||||
|
lastName: players.lastName,
|
||||||
|
teamId: players.team,
|
||||||
|
teamName: teams.name,
|
||||||
|
teamColor: teams.color
|
||||||
|
})
|
||||||
|
.from(players)
|
||||||
|
.innerJoin(teams, eq(players.team, teams.id))
|
||||||
|
.orderBy(players.firstName);
|
||||||
|
|
||||||
|
const bracketsList = await db.select().from(brackets).orderBy(brackets.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
events: events.map((e) => ({ id: e.id, name: e.name, division: e.division, state: e.state })),
|
||||||
|
registrations: registrations.map((r) => ({
|
||||||
|
id: r.registeredPlayerId,
|
||||||
|
registeredPlayerId: r.registeredPlayerId,
|
||||||
|
playerId: r.playerId,
|
||||||
|
firstName: r.firstName,
|
||||||
|
lastName: r.lastName,
|
||||||
|
placement: r.placement,
|
||||||
|
bracket: r.bracket,
|
||||||
|
eventId: r.eventId,
|
||||||
|
eventName: r.eventName,
|
||||||
|
teamId: r.teamId,
|
||||||
|
teamName: r.teamName,
|
||||||
|
teamColor: r.teamColor,
|
||||||
|
state: r.state,
|
||||||
|
division: r.division,
|
||||||
|
points: r.points,
|
||||||
|
stateLabel: r.state === 0 ? 'Not started' : r.state === 1 ? 'Ongoing' : 'Finished'
|
||||||
|
})),
|
||||||
|
allPlayers,
|
||||||
|
brackets: bracketsList
|
||||||
|
};
|
||||||
|
};
|
||||||
315
src/routes/registrations/+page.svelte
Normal file
315
src/routes/registrations/+page.svelte
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Table from '$lib/ui/Table.svelte';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
type Registration = (typeof data.registrations)[number];
|
||||||
|
type PlayerInfo = (typeof data.allPlayers)[number];
|
||||||
|
type Bracket = (typeof data.brackets)[number];
|
||||||
|
|
||||||
|
let selectedEventId = $state<number | ''>('');
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let editingId = $state<number | null>(null);
|
||||||
|
let editPlayerId = $state<number>(0);
|
||||||
|
|
||||||
|
let showNewForm = $state(false);
|
||||||
|
let newEventId = $state<number>(0);
|
||||||
|
let newBracketId = $state<number>(0);
|
||||||
|
let newPlayerId = $state<number>(0);
|
||||||
|
|
||||||
|
let pendingEvents = $derived(data.events.filter((e) => e.state === 0));
|
||||||
|
|
||||||
|
let filteredRegistrations = $derived(
|
||||||
|
data.registrations.filter((r) => {
|
||||||
|
if (selectedEventId !== '' && r.eventId !== selectedEventId) return false;
|
||||||
|
if (searchQuery) {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
const fullName = `${r.firstName} ${r.lastName}`.toLowerCase();
|
||||||
|
if (!fullName.includes(q)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
function playersByTeam(teamId: number): PlayerInfo[] {
|
||||||
|
return data.allPlayers.filter((p) => p.teamId === teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(reg: Registration) {
|
||||||
|
editingId = reg.registeredPlayerId;
|
||||||
|
editPlayerId = reg.playerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editingId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit(reg: Registration) {
|
||||||
|
if (editPlayerId === reg.playerId) {
|
||||||
|
editingId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fetch(`/api/registrations/${reg.registeredPlayerId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ playerId: editPlayerId })
|
||||||
|
});
|
||||||
|
reg.playerId = editPlayerId;
|
||||||
|
const newPlayer = data.allPlayers.find((p) => p.id === editPlayerId);
|
||||||
|
if (newPlayer) {
|
||||||
|
reg.firstName = newPlayer.firstName;
|
||||||
|
reg.lastName = newPlayer.lastName;
|
||||||
|
}
|
||||||
|
editingId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteReg(reg: Registration) {
|
||||||
|
if (!confirm(`Delete registration for ${reg.firstName} ${reg.lastName} from ${reg.eventName}?`)) return;
|
||||||
|
await fetch(`/api/registrations/${reg.registeredPlayerId}`, { method: 'DELETE' });
|
||||||
|
const idx = data.registrations.indexOf(reg);
|
||||||
|
if (idx !== -1) data.registrations.splice(idx, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetNewForm() {
|
||||||
|
showNewForm = false;
|
||||||
|
newEventId = 0;
|
||||||
|
newBracketId = 0;
|
||||||
|
newPlayerId = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitNew() {
|
||||||
|
if (!newEventId || !newBracketId || !newPlayerId) return;
|
||||||
|
const res = await fetch('/api/registeredPlayers', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ eventId: newEventId, playerId: newPlayerId, bracketId: newBracketId })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(err.message || 'Failed to create registration');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await res.json();
|
||||||
|
const selectedPlayer = data.allPlayers.find((p) => p.id === newPlayerId);
|
||||||
|
const selectedEvent = data.events.find((e) => e.id === newEventId);
|
||||||
|
const selectedBracket = data.brackets.find((b) => b.id === newBracketId);
|
||||||
|
if (!selectedPlayer || !selectedEvent || !selectedBracket) return;
|
||||||
|
const newReg: Registration = {
|
||||||
|
id: result.id,
|
||||||
|
registeredPlayerId: result.id,
|
||||||
|
playerId: newPlayerId,
|
||||||
|
firstName: selectedPlayer.firstName,
|
||||||
|
lastName: selectedPlayer.lastName,
|
||||||
|
placement: 0,
|
||||||
|
bracket: selectedBracket.name,
|
||||||
|
eventId: newEventId,
|
||||||
|
eventName: selectedEvent.name,
|
||||||
|
teamId: selectedPlayer.teamId,
|
||||||
|
teamName: selectedPlayer.teamName,
|
||||||
|
teamColor: selectedPlayer.teamColor,
|
||||||
|
state: 0,
|
||||||
|
division: selectedEvent.division,
|
||||||
|
points: 0,
|
||||||
|
stateLabel: 'Not started'
|
||||||
|
};
|
||||||
|
data.registrations.unshift(newReg);
|
||||||
|
resetNewForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateColor(state: number): string {
|
||||||
|
if (state === 0) return 'var(--ctp-mocha-yellow)';
|
||||||
|
if (state === 1) return 'var(--ctp-mocha-green)';
|
||||||
|
return 'var(--ctp-mocha-blue)';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page-container">
|
||||||
|
<div class="section-label">Event Registration Management</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-bottom: 1rem;">
|
||||||
|
<div class="flex flex-wrap gap-4 items-end">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="filter-event">Filter by Event</label>
|
||||||
|
<select id="filter-event" bind:value={selectedEventId}>
|
||||||
|
<option value="">All events</option>
|
||||||
|
{#each data.events as event (event.id)}
|
||||||
|
<option value={event.id}>{event.name} — {event.division}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="search-player">Search Player</label>
|
||||||
|
<input
|
||||||
|
id="search-player"
|
||||||
|
type="text"
|
||||||
|
placeholder="Type player name..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
style="min-width: 200px;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="section-label"
|
||||||
|
style="padding-bottom: 4px; display: flex; justify-content: space-between; align-items: center;"
|
||||||
|
>
|
||||||
|
<span>Registrations</span>
|
||||||
|
<span class="text-sm opacity-60">{filteredRegistrations.length} entries</span>
|
||||||
|
<button class="action-btn new-btn" onclick={() => { showNewForm = !showNewForm; }}>
|
||||||
|
{showNewForm ? 'Cancel' : 'New'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showNewForm}
|
||||||
|
<div class="card new-form-card">
|
||||||
|
<div class="new-form-title">New Registration</div>
|
||||||
|
<div class="flex flex-wrap gap-4 items-end">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="new-event">Event</label>
|
||||||
|
<select id="new-event" bind:value={newEventId}>
|
||||||
|
<option value={0}>Select event...</option>
|
||||||
|
{#each pendingEvents as event (event.id)}
|
||||||
|
<option value={event.id}>{event.name} — {event.division}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="new-bracket">Bracket</label>
|
||||||
|
<select id="new-bracket" bind:value={newBracketId}>
|
||||||
|
<option value={0}>Select bracket...</option>
|
||||||
|
{#each data.brackets as bracket (bracket.id)}
|
||||||
|
<option value={bracket.id}>{bracket.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="new-player">Player</label>
|
||||||
|
<select id="new-player" bind:value={newPlayerId}>
|
||||||
|
<option value={0}>Select player...</option>
|
||||||
|
{#each data.allPlayers as player (player.id)}
|
||||||
|
<option value={player.id}>{player.firstName} {player.lastName} ({player.teamName})</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="action-btn submit-btn" onclick={submitNew} disabled={!newEventId || !newBracketId || !newPlayerId}>
|
||||||
|
Insert
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Table data={filteredRegistrations} maxHeight="none">
|
||||||
|
{#snippet header()}
|
||||||
|
<th class="px-2">RP ID</th>
|
||||||
|
<th class="px-2">Event</th>
|
||||||
|
<th class="px-2">Team</th>
|
||||||
|
<th class="px-2">Division</th>
|
||||||
|
<th class="px-2">Bracket</th>
|
||||||
|
<th class="px-2">Player Name</th>
|
||||||
|
<th class="px-2">Event State</th>
|
||||||
|
<th class="px-2">Placement</th>
|
||||||
|
<th class="px-2">Points</th>
|
||||||
|
<th class="px-2">Actions</th>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet row(reg: Registration)}
|
||||||
|
<td class="px-2">{reg.registeredPlayerId}</td>
|
||||||
|
<td class="px-2">{reg.eventName}</td>
|
||||||
|
<td class="px-2" style="color: var(--ctp-mocha-{reg.teamColor});">{reg.teamName}</td>
|
||||||
|
<td class="px-2">{reg.division}</td>
|
||||||
|
<td class="px-2">{reg.bracket}</td>
|
||||||
|
<td class="px-2">
|
||||||
|
{#if editingId === reg.registeredPlayerId}
|
||||||
|
<select bind:value={editPlayerId}>
|
||||||
|
{#each playersByTeam(reg.teamId) as p (p.id)}
|
||||||
|
<option value={p.id}>{p.firstName} {p.lastName}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{:else}
|
||||||
|
{reg.firstName} {reg.lastName}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-2" style="color: {stateColor(reg.state)};">{reg.stateLabel}</td>
|
||||||
|
<td class="px-2">{reg.placement > 0 ? reg.placement : '-'}</td>
|
||||||
|
<td class="px-2">{reg.points > 0 ? reg.points : '-'}</td>
|
||||||
|
<td class="px-2">
|
||||||
|
{#if editingId === reg.registeredPlayerId}
|
||||||
|
<button class="action-btn" onclick={() => saveEdit(reg)}>Save</button>
|
||||||
|
<button class="action-btn" onclick={cancelEdit}>Cancel</button>
|
||||||
|
{:else}
|
||||||
|
<button class="action-btn" onclick={() => startEdit(reg)}>Edit</button>
|
||||||
|
{#if reg.state === 0}
|
||||||
|
<button class="action-btn delete" onclick={() => deleteReg(reg)}>Delete</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
{/snippet}
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.action-btn {
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: color-mix(in srgb, currentColor 6%, transparent);
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.delete {
|
||||||
|
border-color: var(--ctp-mocha-red, #f38ba8);
|
||||||
|
color: var(--ctp-mocha-red, #f38ba8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.new-btn {
|
||||||
|
border-color: var(--ctp-mocha-green, #a6e3a1);
|
||||||
|
color: var(--ctp-mocha-green, #a6e3a1);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.submit-btn {
|
||||||
|
border-color: var(--ctp-mocha-blue, #89b4fa);
|
||||||
|
color: var(--ctp-mocha-blue, #89b4fa);
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.submit-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-form-card {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-form-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
td select {
|
||||||
|
padding: 0.15rem 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: color-mix(in srgb, currentColor 6%, transparent);
|
||||||
|
color: inherit;
|
||||||
|
max-width: 140px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
58
src/routes/settings/+page.server.ts
Normal file
58
src/routes/settings/+page.server.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { error, redirect, fail } from '@sveltejs/kit';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { scorers } from '$lib/server/db/schema';
|
||||||
|
import { getSetting, setSetting } from '$lib/server/databaseManager';
|
||||||
|
import type { PageServerLoad, Actions } 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
const disableRegistration = (await getSetting('disableRegistration')) === 'true';
|
||||||
|
const requireLoginForLeaderboard = (await getSetting('requireLoginForLeaderboard')) === 'true';
|
||||||
|
|
||||||
|
return { disableRegistration, requireLoginForLeaderboard };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
toggleRegistration: async ({ locals }) => {
|
||||||
|
if (!locals.user) throw error(401, 'Unauthorized');
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
const current = (await getSetting('disableRegistration')) === 'true';
|
||||||
|
await setSetting('disableRegistration', current ? 'false' : 'true');
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleLeaderboardLock: async ({ locals }) => {
|
||||||
|
if (!locals.user) throw error(401, 'Unauthorized');
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
const current = (await getSetting('requireLoginForLeaderboard')) === 'true';
|
||||||
|
await setSetting('requireLoginForLeaderboard', current ? 'false' : 'true');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
46
src/routes/settings/+page.svelte
Normal file
46
src/routes/settings/+page.svelte
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page-container">
|
||||||
|
<h2 class="section-label">Settings</h2>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<form method="POST" action="?/toggleRegistration" use:enhance>
|
||||||
|
<div class="card" style="flex-direction: row; align-items: center;">
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-semibold">Disable Registration</p>
|
||||||
|
<p class="text-sm opacity-70">Prevents new users from signing up via /register</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
style={data.disableRegistration
|
||||||
|
? 'background: color-mix(in srgb, var(--ctp-mocha-red) 30%, transparent); border-color: var(--ctp-mocha-red);'
|
||||||
|
: 'background: color-mix(in srgb, var(--ctp-mocha-green) 30%, transparent); border-color: var(--ctp-mocha-green);'}
|
||||||
|
>
|
||||||
|
{data.disableRegistration ? 'Disabled' : 'Enabled'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="POST" action="?/toggleLeaderboardLock" use:enhance>
|
||||||
|
<div class="card" style="flex-direction: row; align-items: center;">
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-semibold">Require Login for Leaderboard</p>
|
||||||
|
<p class="text-sm opacity-70">Non-logged-in users will be redirected to /login</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
style={data.requireLoginForLeaderboard
|
||||||
|
? 'background: color-mix(in srgb, var(--ctp-mocha-red) 30%, transparent); border-color: var(--ctp-mocha-red);'
|
||||||
|
: 'background: color-mix(in srgb, var(--ctp-mocha-green) 30%, transparent); border-color: var(--ctp-mocha-green);'}
|
||||||
|
>
|
||||||
|
{data.requireLoginForLeaderboard ? 'Locked' : 'Public'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import { getPlayerInfo } from '$lib/server/databaseManager';
|
import { getPlayerInfo, getPlayerEvents } from '$lib/server/databaseManager';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
// export const load: PageServerLoad = async ({ params }) => {
|
|
||||||
// return {
|
|
||||||
// data: await getPlayerInfo(parseInt(params.playerId))
|
|
||||||
// };
|
|
||||||
// };
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params }) => {
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
|
const playerId = parseInt(params.playerId);
|
||||||
|
const [playerInfo, playerEvents] = await Promise.all([
|
||||||
|
getPlayerInfo(playerId),
|
||||||
|
getPlayerEvents(playerId)
|
||||||
|
]);
|
||||||
return {
|
return {
|
||||||
playerInfo: getPlayerInfo(parseInt(params.playerId))
|
playerInfo,
|
||||||
|
playerEvents
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,29 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
|
let { playerInfo, playerEvents: initialEvents } = data;
|
||||||
|
let playerEvents = $state(initialEvents);
|
||||||
|
|
||||||
|
let eventEndpoint: EventSource;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
eventEndpoint = new EventSource(`/api/playerEvents/${playerInfo.id}`);
|
||||||
|
eventEndpoint.onmessage = (e) => {
|
||||||
|
playerEvents = JSON.parse(e.data);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
eventEndpoint?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
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 marquee(node: HTMLElement) {
|
||||||
function measure() {
|
function measure() {
|
||||||
@@ -22,13 +44,9 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#await data.playerInfo}
|
<div class="page-container">
|
||||||
<div>Loading...</div>
|
|
||||||
{:then playerInfo}
|
|
||||||
{console.log(playerInfo)}
|
|
||||||
<div class="mt-5 flex justify-center">
|
|
||||||
<div
|
<div
|
||||||
class="player-single-box aspect-square w-full max-w-[95vw] justify-center md:aspect-2/1 lg:aspect-2/1"
|
class="player-box !max-w-full w-full aspect-2/1 md:aspect-auto"
|
||||||
style="--c:{playerInfo.teamInfo.color}"
|
style="--c:{playerInfo.teamInfo.color}"
|
||||||
>
|
>
|
||||||
<div class="player-ghost" aria-hidden="true">
|
<div class="player-ghost" aria-hidden="true">
|
||||||
@@ -44,32 +62,70 @@
|
|||||||
>
|
>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class=" goldman player-name-wrap text-5xl" use:marquee>
|
<div
|
||||||
{playerInfo.firstName}
|
class="relative z-1 flex flex-col justify-center h-full gap-0.5 px-5 py-3 md:flex-row md:items-center md:justify-between md:gap-0 md:px-6 md:py-4"
|
||||||
{playerInfo.lastName}
|
>
|
||||||
|
<div class="goldman player-name-wrap text-3xl md:text-4xl lg:text-5xl" use:marquee>
|
||||||
|
{playerInfo.firstName} {playerInfo.lastName}
|
||||||
|
</div>
|
||||||
|
<div class="goldman text-lg md:text-right md:text-xl lg:text-2xl" style="opacity: 0.85;">
|
||||||
|
{playerInfo.teamInfo.name}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/await}
|
|
||||||
|
|
||||||
<style>
|
<p class="section-label">Events</p>
|
||||||
.player-single-box {
|
|
||||||
flex: 1 1 0;
|
<section class="events-scroll" style="box-shadow: none; max-height: none; overflow-y: visible;">
|
||||||
position: relative;
|
<div class="events">
|
||||||
overflow: hidden;
|
{#each playerEvents as event (event.id)}
|
||||||
border-radius: 8px;
|
<div
|
||||||
border: 1.5px solid var(--c);
|
class="event-card"
|
||||||
color: var(--c);
|
class:ongoing-event={event.state == 1}
|
||||||
background: color-mix(in srgb, var(--c) 10%, transparent);
|
class:completed-event={event.state == 2}
|
||||||
padding: 5px 7px 5px;
|
style={event.state == 2 && event.winner ? `--event-color: ${event.winner.color}` : ''}
|
||||||
display: flex;
|
>
|
||||||
flex-direction: column;
|
<div class="event-header">
|
||||||
}
|
<a href="/event/{event.id}" class="event-name goldman">{event.name}</a>
|
||||||
.player-name-wrap {
|
<span class="event-division">{event.division}</span>
|
||||||
position: relative;
|
{#if event.state == 1}
|
||||||
z-index: 1;
|
<span class="event-status goldman">ONGOING</span>
|
||||||
text-decoration: none;
|
{:else if event.state == 2}
|
||||||
overflow: hidden;
|
<span
|
||||||
white-space: nowrap;
|
class="event-winner event-status goldman"
|
||||||
}
|
style={event.winner ? `--winner-color:${event.winner.color}` : ''}
|
||||||
</style>
|
>Won By {event.winner?.name ?? '?'}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1.5 px-3 py-2 text-sm">
|
||||||
|
<div class="flex items-baseline gap-2">
|
||||||
|
<span class="text-xs opacity-50 goldman">{event.bracket}</span>
|
||||||
|
{#if event.placement > 0}
|
||||||
|
<div class="flex items-baseline gap-1">
|
||||||
|
<span class="text-xs opacity-50">placed</span>
|
||||||
|
<span class="goldman text-base">{ordinal(event.placement)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ml-auto flex items-baseline gap-1">
|
||||||
|
<span class="goldman text-base">{event.points}</span>
|
||||||
|
<span class="text-xs opacity-50">pts</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<span class="text-xs opacity-40">Unranked</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if event.results.length > 0}
|
||||||
|
<div class="text-xs opacity-70">
|
||||||
|
{#each event.results as score, i}
|
||||||
|
<span
|
||||||
|
>{i > 0 ? ' · ' : ''}Run {score.index + 1}: {score.result}{event.unit}</span
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import adapter from '@sveltejs/adapter-auto';
|
import adapter from '@sveltejs/adapter-node';
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
compilerOptions: {
|
compilerOptions: {
|
||||||
// Force runes mode (except in node_modules). Remove when Svelte 6 makes it default.
|
|
||||||
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
|
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
|
||||||
},
|
},
|
||||||
kit: {
|
kit: {
|
||||||
@@ -18,11 +17,6 @@ const config = {
|
|||||||
experimental: {
|
experimental: {
|
||||||
remoteFunctions: true
|
remoteFunctions: true
|
||||||
}
|
}
|
||||||
},
|
|
||||||
compilerOptions: {
|
|
||||||
experimental: {
|
|
||||||
async: true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
31
temp.csv
Normal file
31
temp.csv
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
First Name,Last name,Bracket,Event,Class,,
|
||||||
|
,,A,100m,10 East,,Change the top row in the class column to change all rows (the red one)
|
||||||
|
,,B,100m,10 East,,"MUST be in the format ""NUMBER HOUSE"" with the house capitalised"
|
||||||
|
,,C,100m,10 East,,"eg ""10 East"" or ""7 West"""
|
||||||
|
,,A,200m,10 East,,"Please return the file with the same name (eg ""10 East.xlsx"") for my sanity"
|
||||||
|
,,B,200m,10 East,,"Any returned with generic file names may be filed incorrectly (no ""Book 3"" etc)"
|
||||||
|
,,C,200m,10 East,,
|
||||||
|
,,A,400m,10 East,,
|
||||||
|
,,B,400m,10 East,,
|
||||||
|
,,C,400m,10 East,,
|
||||||
|
,,A,800m,10 East,,
|
||||||
|
,,B,800m,10 East,,
|
||||||
|
,,C,800m,10 East,,
|
||||||
|
,,A,1500m,10 East,,
|
||||||
|
,,B,1500m,10 East,,
|
||||||
|
,,C,1500m,10 East,,
|
||||||
|
,,A,Javelin,10 East,,
|
||||||
|
,,B,Javelin,10 East,,
|
||||||
|
,,C,Javelin,10 East,,
|
||||||
|
,,A,Shotput,10 East,,
|
||||||
|
,,B,Shotput,10 East,,
|
||||||
|
,,C,Shotput,10 East,,
|
||||||
|
,,A,Long Jump,10 East,,
|
||||||
|
,,B,Long Jump,10 East,,
|
||||||
|
,,C,Long Jump,10 East,,
|
||||||
|
,,A,High Jump,10 East,,
|
||||||
|
,,B,High Jump,10 East,,
|
||||||
|
,,C,High Jump,10 East,,
|
||||||
|
,,A,Triple Jump,10 East,,
|
||||||
|
,,B,Triple Jump,10 East,,
|
||||||
|
,,C,Triple Jump,10 East,,
|
||||||
|
Reference in New Issue
Block a user