Compare commits
15 Commits
62af341a1e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b636243ff | |||
| 17acf0a497 | |||
| a3ead298af | |||
| 8394ad45c8 | |||
| feecae7792 | |||
| 7f9a03eed6 | |||
| 097ff77c8a | |||
| df8f5bc9a9 | |||
| 5e03a5798f | |||
| 4df2bc697d | |||
| 07c27ffaaa | |||
| ac4f02f597 | |||
| f93f407d66 | |||
| ec56e3794e | |||
| bfa49cfa91 |
12
TODO.md
12
TODO.md
@@ -2,17 +2,7 @@
|
||||
player view - all registered events and scores for those events
|
||||
team view - all registered players and all points gained from them - Do i need this?
|
||||
|
||||
# Login stuff - HIGH PRIORITY
|
||||
disableable register button on the login page for end users
|
||||
make a way to toggle on and off the register page
|
||||
protect the register endpoint?? (low prio)
|
||||
|
||||
# Ledger - HIGH PRIORITY
|
||||
ledger view
|
||||
inserting manually into ledger
|
||||
|
||||
# Database - HIGH PRIORITY
|
||||
make events score based off of highest score instead of avg
|
||||
load results from the excel sheet i made
|
||||
export whole contest results to csv
|
||||
|
||||
@@ -30,7 +20,5 @@ result amendments
|
||||
|
||||
## Easy shit
|
||||
animations for all leaderboards
|
||||
Ongoing since... or ongoing for... on the main page
|
||||
displaying averages of loaded scores when there's more than one (easy, low prio)
|
||||
fix the layout on event view to match the homepage view
|
||||
fix the marquee animation thing and make it global
|
||||
|
||||
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",
|
||||
"@libsql/client": "^0.17.2",
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
"@sveltejs/kit": "^2.57.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@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
|
||||
Seniors
|
||||
Year 7
|
||||
Year 8
|
||||
Year 9
|
||||
|
||||
|
@@ -1,9 +1,9 @@
|
||||
event_name,preset,resultPreset
|
||||
100m Sprint,1,race
|
||||
200m Sprint,1,race
|
||||
500m Relay,1,race
|
||||
750m Race,1,race
|
||||
1000m Long Distance,1,race
|
||||
400m Race,1,race
|
||||
800m Race,1,race
|
||||
1500m Long Distance,1,race
|
||||
Javelin, 1,three
|
||||
Shotput, 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,4 +1,4 @@
|
||||
presetName,numberOfResults,unit,lowerIsBetter,averageResults
|
||||
default,1,m,0,0
|
||||
three,3,m,0,1
|
||||
three,3,m,0,0
|
||||
race,1,s,1,0
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
team_name,color
|
||||
County,red
|
||||
East,pink
|
||||
West,yellow
|
||||
Laud,blue
|
||||
School,green
|
||||
County,red
|
||||
West,yellow
|
||||
|
||||
|
@@ -50,7 +50,8 @@ async function seed() {
|
||||
|
||||
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
|
||||
.insert(schema.scorers)
|
||||
.values({
|
||||
@@ -126,9 +127,24 @@ async function seed() {
|
||||
const divisionId = divisionMap.get(row.division);
|
||||
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({
|
||||
firstName: row.firstName,
|
||||
lastName: row.lastName,
|
||||
firstName,
|
||||
lastName,
|
||||
team: teamId,
|
||||
division: divisionId || null
|
||||
});
|
||||
@@ -155,34 +171,27 @@ async function seed() {
|
||||
const dbEventTypes = await db.select().from(schema.eventTypes);
|
||||
const eventTypeMap = new Map(dbEventTypes.map((et) => [et.name, et.id]));
|
||||
|
||||
// Seed registered events
|
||||
// Seed registered events (auto-generated from event types × divisions, ordered by division then event type)
|
||||
const eventNameMap = new Map<string, number>();
|
||||
const registeredEventsCSV = readCSV('registeredEvents.csv');
|
||||
|
||||
for (const row of registeredEventsCSV) {
|
||||
const eventTypeId = eventTypeMap.get(row.event_type);
|
||||
const teamId = teamMap.get(row.winner);
|
||||
const divisionId = divisionMap.get(row.division);
|
||||
|
||||
if (!eventTypeId) throw new Error(`Event Type "${row.event_type}" not found`);
|
||||
if (!divisionId) throw new Error(`Division "${row.division}" not found`);
|
||||
|
||||
for (const division of dbDivisions) {
|
||||
for (const eventType of dbEventTypes) {
|
||||
const [inserted] = await db
|
||||
.insert(schema.registeredEvents)
|
||||
.values({
|
||||
eventType: eventTypeId,
|
||||
division: divisionId,
|
||||
state: row.event_state || 0,
|
||||
timeCompleted: row.time_completed || null,
|
||||
teamWinner: teamId || null
|
||||
eventType: eventType.id,
|
||||
division: division.id,
|
||||
state: 0,
|
||||
timeCompleted: null,
|
||||
teamWinner: null
|
||||
})
|
||||
.returning();
|
||||
console.log(
|
||||
` → Registered Event [id:${inserted.id}]: ${row.event_type} | ${row.division}, winner: ${teamId}, ${row.winner}`
|
||||
` → Registered Event [id:${inserted.id}]: ${eventType.name} | ${division.name}`
|
||||
);
|
||||
|
||||
// Map event name|division to the generated event ID
|
||||
eventNameMap.set(`${row.event_type}|${row.division}`, inserted.id);
|
||||
eventNameMap.set(`${eventType.name}|${division.name}`, inserted.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Seed registered players (linking players to events)
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function validateSessionToken(token: string) {
|
||||
row.session.expiresAt = newExpiresAt;
|
||||
}
|
||||
|
||||
return { session: row.session, user: { id: row.user.id, username: row.user.username } };
|
||||
return { session: row.session, user: { id: row.user.id, username: row.user.username, role: row.user.role } };
|
||||
}
|
||||
|
||||
export async function invalidateSession(sessionId: string) {
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { sql, eq, and } from 'drizzle-orm';
|
||||
import { sql, eq, and, desc, inArray } from 'drizzle-orm';
|
||||
import * as schema from '$lib/server/db/schema';
|
||||
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
|
||||
export async function getAllInitialInfo() {
|
||||
return {
|
||||
teams: await getTeams(),
|
||||
events: await getRegisteredEvents()
|
||||
events: await getRegisteredEventsWithPlayers()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -214,3 +229,136 @@ export async function getRegisteredEventsWithPlayers(eventId?: number) {
|
||||
}
|
||||
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) {
|
||||
let query = db
|
||||
.select({
|
||||
id: schema.mainLedger.id,
|
||||
timestamp: schema.mainLedger.timestamp,
|
||||
updateType: schema.mainLedger.updateType,
|
||||
eventName: schema.eventTypes.name
|
||||
})
|
||||
.from(schema.mainLedger)
|
||||
.leftJoin(
|
||||
schema.registeredEvents,
|
||||
eq(schema.mainLedger.registeredEvent, schema.registeredEvents.id)
|
||||
)
|
||||
.leftJoin(
|
||||
schema.eventTypes,
|
||||
eq(schema.registeredEvents.eventType, schema.eventTypes.id)
|
||||
)
|
||||
.orderBy(desc(schema.mainLedger.timestamp)) as any;
|
||||
|
||||
if (limit !== 'all') {
|
||||
query = query.limit(limit);
|
||||
}
|
||||
|
||||
const entries = await query;
|
||||
|
||||
if (entries.length === 0) return [];
|
||||
|
||||
const ids = entries.map((e: any) => e.id);
|
||||
|
||||
const scores = await db
|
||||
.select({
|
||||
ledgerID: schema.scoreLedger.ledgerID,
|
||||
teamName: schema.teams.name,
|
||||
points: schema.scoreLedger.points
|
||||
})
|
||||
.from(schema.scoreLedger)
|
||||
.leftJoin(schema.teams, eq(schema.scoreLedger.teamID, schema.teams.id))
|
||||
.where(inArray(schema.scoreLedger.ledgerID, ids));
|
||||
|
||||
const scoresByLedger = new Map<number, Record<string, number>>();
|
||||
for (const s of scores) {
|
||||
if (s.ledgerID == null || s.teamName == null) continue;
|
||||
if (!scoresByLedger.has(s.ledgerID)) {
|
||||
scoresByLedger.set(s.ledgerID, {});
|
||||
}
|
||||
const map = scoresByLedger.get(s.ledgerID)!;
|
||||
map[s.teamName] = (map[s.teamName] || 0) + s.points;
|
||||
}
|
||||
|
||||
return entries.map((e: any) => ({
|
||||
id: e.id,
|
||||
type: e.updateType,
|
||||
eventName: e.eventName || '',
|
||||
timestamp: e.timestamp,
|
||||
teamPoints: scoresByLedger.get(e.id) || {}
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { drizzle } from 'drizzle-orm/libsql';
|
||||
import { createClient } from '@libsql/client';
|
||||
import { createClient } from './libsql-wrapper';
|
||||
import { construct } from 'drizzle-orm/libsql/driver-core';
|
||||
import * as schema from './schema';
|
||||
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 });
|
||||
|
||||
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));
|
||||
});
|
||||
|
||||
export const settings = sqliteTable('settings', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value').notNull()
|
||||
});
|
||||
|
||||
export const registeredEventPlayersView = sqliteView('registeredEventPlayersView').as((qb) => {
|
||||
return qb
|
||||
.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);
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
const rowOffsetTop = targetRow.offsetTop;
|
||||
const rowHeight = targetRow.offsetHeight;
|
||||
const containerHeight = containerRef.offsetHeight;
|
||||
|
||||
const centerScrollTarget = rowOffsetTop - containerHeight / 2 + rowHeight / 2;
|
||||
|
||||
@@ -32,7 +33,6 @@
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div bind:this={containerRef} class="table-container" style="max-height: {maxHeight};">
|
||||
|
||||
@@ -4,6 +4,32 @@
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
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>
|
||||
|
||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||
@@ -17,11 +43,27 @@
|
||||
<div class="w-full"></div>
|
||||
|
||||
{#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
|
||||
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 data.user}
|
||||
<a
|
||||
@@ -44,4 +86,34 @@
|
||||
.header {
|
||||
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>
|
||||
|
||||
@@ -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
|
||||
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();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, tick } from 'svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
|
||||
let {
|
||||
data
|
||||
@@ -8,8 +9,8 @@
|
||||
focusEventId?: number | null;
|
||||
} = $props();
|
||||
|
||||
let teams = $derived(data.teams.teams);
|
||||
let eventTable = $derived(data.events.events);
|
||||
let teams = $state(data.teams.teams);
|
||||
let eventTable = $state(data.events);
|
||||
|
||||
let scoreEndpoint: EventSource;
|
||||
let eventEndpoint: EventSource;
|
||||
@@ -63,7 +64,7 @@
|
||||
if (focusEventId == null) return;
|
||||
tick().then(() => {
|
||||
const el = eventRefs[focusEventId!];
|
||||
el.scrollIntoView({ alignToTop: true, behavior: 'instant', container: 'nearest' });
|
||||
el.scrollIntoView({ block: 'start', behavior: 'instant' });
|
||||
tick().then(() => {
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
@@ -132,6 +133,7 @@
|
||||
{#each leaderboard.slice(1) as team, i (team.name)}
|
||||
<a
|
||||
href="/"
|
||||
animate:flip={{ duration: 300 }}
|
||||
class="score-box runner"
|
||||
style="--c:{team.color}"
|
||||
aria-label="{team.name}, {ordinal(i + 2)} place, {team.points} points"
|
||||
@@ -169,6 +171,7 @@
|
||||
<div class="events">
|
||||
{#each eventTable as event (event.id)}
|
||||
<div
|
||||
animate:flip={{ duration: 300 }}
|
||||
class="event-card"
|
||||
class:ongoing-event={event.state == 1}
|
||||
class:completed-event={event.state == 2}
|
||||
|
||||
@@ -57,6 +57,7 @@ export async function POST({ request }: any) {
|
||||
|
||||
for (let result in currentPlayer.scores) {
|
||||
let currentResult = currentPlayer.scores[result];
|
||||
if (currentResult === null || currentResult === undefined) continue;
|
||||
let newScoreEntry = await db
|
||||
.insert(schema.registeredResults)
|
||||
.values({
|
||||
|
||||
@@ -11,7 +11,7 @@ export async function POST({ request }: any) {
|
||||
return new Error('send a response dummy');
|
||||
} else {
|
||||
console.log(responseBody);
|
||||
startEvent(responseBody.eventId);
|
||||
await startEvent(responseBody.eventId);
|
||||
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);
|
||||
}
|
||||
47
src/routes/api/ledger/+server.ts
Normal file
47
src/routes/api/ledger/+server.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { getLedgerEntries } from '$lib/server/databaseManager';
|
||||
import { globalEmitter } from '$lib/server/globalEmitter';
|
||||
import { db } from '$lib/server/db';
|
||||
import * as schema from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export async function GET({ url }: any) {
|
||||
const limit = url.searchParams.get('limit') || '100';
|
||||
const entries = await getLedgerEntries(limit === 'all' ? 'all' : parseInt(limit));
|
||||
return new Response(JSON.stringify(entries), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST({ request, locals }: any) {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { eventId, teamPoints: teamPointsByName } = body;
|
||||
|
||||
const teams = await db.select().from(schema.teams);
|
||||
const teamNameToId = new Map(teams.map((t) => [t.name, t.id]));
|
||||
|
||||
const newLedgerEntry = await db
|
||||
.insert(schema.mainLedger)
|
||||
.values({ registeredEvent: eventId, updateType: 'admin' })
|
||||
.returning();
|
||||
|
||||
const ledgerEntryId = newLedgerEntry[0].id;
|
||||
|
||||
const scoreEntries: { ledgerID: number; teamID: number; points: number }[] = [];
|
||||
for (const [name, points] of Object.entries(teamPointsByName as Record<string, number>)) {
|
||||
if (points === 0) continue;
|
||||
const teamID = teamNameToId.get(name);
|
||||
if (teamID === undefined) continue;
|
||||
scoreEntries.push({ ledgerID: ledgerEntryId, teamID, points });
|
||||
}
|
||||
|
||||
if (scoreEntries.length > 0) {
|
||||
await db.insert(schema.scoreLedger).values(scoreEntries);
|
||||
}
|
||||
|
||||
globalEmitter.emit('scoreUpdate');
|
||||
return new Response('ok');
|
||||
}
|
||||
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 { generateEndpoint } from '$lib/server/endpoint';
|
||||
|
||||
export async function POST({ request }: any) {
|
||||
return new Response('ok');
|
||||
export async function POST({ 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 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';
|
||||
|
||||
export async function POST({ request }: any) {
|
||||
globalEmitter.emit('incrementScores');
|
||||
globalEmitter.emit('scoreUpdate');
|
||||
return new Response('ok');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db';
|
||||
import { scorers } from '$lib/server/db/schema';
|
||||
import { getRegisteredEventsWithPlayers } from '$lib/server/databaseManager';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (!locals.user) return { user: null };
|
||||
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
const eventId = parseInt(params.eventId);
|
||||
const userInfo: { user: any } = { user: null };
|
||||
|
||||
if (locals.user) {
|
||||
const [row] = await db
|
||||
.select({ role: scorers.role })
|
||||
.from(scorers)
|
||||
.where(eq(scorers.id, locals.user.id));
|
||||
return { user: { ...locals.user, role: row?.role ?? 'scorer' } };
|
||||
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;
|
||||
|
||||
async function getEventData() {
|
||||
let response = await fetch('/api/registeredEvents', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
eventId: eventId
|
||||
}),
|
||||
headers: {
|
||||
'Content-type': 'application/json; charset=UTF-8'
|
||||
}
|
||||
});
|
||||
let eventData = $state<any>(data.event);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Sort players: placed first (ascending), unplaced last
|
||||
if (data && data[0] && data[0].registeredPlayers) {
|
||||
data[0].registeredPlayers.forEach((bracket: any) => {
|
||||
onMount(() => {
|
||||
eventEndpoint = new EventSource('/api/registeredEvents');
|
||||
eventEndpoint.onmessage = (e) => {
|
||||
const allEvents = JSON.parse(e.data);
|
||||
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) => {
|
||||
if (a.placement === 0 && b.placement === 0) return 0;
|
||||
if (a.placement === 0) return 1;
|
||||
@@ -55,43 +49,32 @@
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
eventData = updated;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
let eventDataPromise = getEventData();
|
||||
|
||||
onMount(() => {
|
||||
eventEndpoint = new EventSource('/api/registeredEvents');
|
||||
|
||||
// eventEndpoint.onmessage = (e) => {
|
||||
// const eventData = JSON.parse(e.data);
|
||||
// console.log(eventData);
|
||||
// };
|
||||
onDestroy(() => {
|
||||
eventEndpoint?.close();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#await eventDataPromise}
|
||||
<div>loading</div>
|
||||
{:then eventData}
|
||||
{@const event = eventData[0]}
|
||||
{console.log(event)}
|
||||
<div class="flex justify-center">
|
||||
<div class="w-full flex-col px-[5vw] text-center">
|
||||
<div
|
||||
style:background-color={event.state === 1
|
||||
? 'color-mix(in srgb, #fe640b 18%, transparent)'
|
||||
: event.state === 2
|
||||
? 'color-mix(in srgb, #a6e3a1 18%, transparent)'
|
||||
: ''}
|
||||
class="align-text-middle my-7 h-10 w-full rounded-2xl border-2 border-solid border-ctp-surface1"
|
||||
>
|
||||
{event.name} - {event.division}
|
||||
{#if event.state == 1}- ONGOING
|
||||
{:else if event.state == 2}- FINISHED
|
||||
{#if eventData}
|
||||
<div class="page-container">
|
||||
<div class="event-card" class:ongoing-event={eventData.state == 1} class:completed-event={eventData.state == 2}
|
||||
style={eventData.state == 2 ? `--event-color: ${eventData.winner?.color ?? 'currentColor'}` : ''}>
|
||||
<div class="event-header">
|
||||
<span class="event-name goldman">{eventData.name}</span>
|
||||
<span class="event-division">{eventData.division}</span>
|
||||
{#if eventData.state == 1}
|
||||
<span class="event-status goldman">ONGOING</span>
|
||||
{:else if eventData.state == 2}
|
||||
<span class="event-status goldman">FINISHED</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#each event.registeredPlayers as bracket, bi}
|
||||
|
||||
<div class="brackets px-[5vw]">
|
||||
{#each eventData.registeredPlayers as bracket, bi}
|
||||
{#if bi > 0}
|
||||
<div class="bracket-sep" aria-hidden="true"></div>
|
||||
{/if}
|
||||
@@ -116,17 +99,17 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="player-name-wrap" use:marquee>
|
||||
<span class=" text-xl">
|
||||
<a href="/stats/player/{player.id}" class="marquee-inner">
|
||||
{player.firstName}
|
||||
{player.lastName}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
{#if player.placement !== 0}
|
||||
<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}
|
||||
<div class="mx-5 my-1 rounded border-2" style="border-color:{player.teamColor}">
|
||||
Run {score.resultIndex + 1}: {score.result}{event.resultPresets[0].unit}
|
||||
<div class="mx-1 my-0.5 rounded border px-1 text-xs" style="border-color:{player.teamColor}">
|
||||
Run {score.resultIndex + 1}: {score.result}{eventData.resultPresets[0].unit}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -139,6 +122,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if data.user}
|
||||
<div class="mt-10 flex w-full justify-center">
|
||||
<a
|
||||
@@ -147,15 +131,7 @@
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{/await}
|
||||
|
||||
<style>
|
||||
.resultContainer {
|
||||
flex-direction: column;
|
||||
}
|
||||
@media (max-width: 479px) {
|
||||
.resultContainer {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="page-container"><div>loading</div></div>
|
||||
{/if}
|
||||
@@ -75,10 +75,10 @@
|
||||
return valid.reduce((a, b) => a + b, 0) / valid.length;
|
||||
}
|
||||
|
||||
function best(scores: (number | null)[], fallback = Infinity): number {
|
||||
function best(scores: (number | null)[], lowerIsBetter: boolean, fallback = Infinity): number {
|
||||
const valid = scores.filter((s): s is number => s !== null);
|
||||
if (valid.length === 0) return fallback;
|
||||
return Math.min(...valid);
|
||||
return lowerIsBetter ? Math.min(...valid) : Math.max(...valid);
|
||||
}
|
||||
|
||||
function getPoints(placement: number): number {
|
||||
@@ -106,10 +106,10 @@
|
||||
const fallback = lowerIsBetter ? Infinity : -Infinity;
|
||||
const sa = useAverage
|
||||
? average(committedScores[a.id] ?? [], fallback)
|
||||
: best(committedScores[a.id] ?? [], fallback);
|
||||
: best(committedScores[a.id] ?? [], lowerIsBetter, fallback);
|
||||
const sb = useAverage
|
||||
? average(committedScores[b.id] ?? [], fallback)
|
||||
: best(committedScores[b.id] ?? [], fallback);
|
||||
: best(committedScores[b.id] ?? [], lowerIsBetter, fallback);
|
||||
|
||||
return lowerIsBetter ? sa - sb : sb - sa;
|
||||
})
|
||||
@@ -209,6 +209,9 @@
|
||||
eventId: eventId
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
event = { ...event, state: 1 };
|
||||
}
|
||||
} catch {
|
||||
return new Error();
|
||||
}
|
||||
@@ -229,7 +232,9 @@
|
||||
position: i + 1,
|
||||
points: getPoints(i + 1),
|
||||
scores: committedScores[p.id] ?? [],
|
||||
average: average(committedScores[p.id] ?? [])
|
||||
summaryValue: useAverage
|
||||
? average(committedScores[p.id] ?? [])
|
||||
: best(committedScores[p.id] ?? [], lowerIsBetter)
|
||||
}))
|
||||
}))
|
||||
})
|
||||
@@ -309,6 +314,12 @@
|
||||
validScores.length > 0
|
||||
? validScores.reduce((a, b) => a + b, 0) / validScores.length
|
||||
: 0}
|
||||
{@const bestScore =
|
||||
validScores.length > 0
|
||||
? lowerIsBetter
|
||||
? Math.min(...validScores)
|
||||
: Math.max(...validScores)
|
||||
: 0}
|
||||
<div
|
||||
animate:flip={{ duration: 300 }}
|
||||
class="scoring-player-card"
|
||||
@@ -362,6 +373,10 @@
|
||||
<div class="text-sm opacity-60">
|
||||
avg: {avgScore.toFixed(2)}
|
||||
</div>
|
||||
{:else if numResults > 1}
|
||||
<div class="text-sm opacity-60">
|
||||
best: {bestScore.toFixed(2)}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if player.playerScores.length > 0}
|
||||
{#each player.playerScores as score}
|
||||
@@ -378,6 +393,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if event.state == 1}
|
||||
<button
|
||||
class="mt-4 rounded px-6 py-2 text-white"
|
||||
class:bg-green-600={submitStatus === 'idle'}
|
||||
@@ -392,6 +408,7 @@
|
||||
{:else if submitStatus === 'done'}Submitted ✓
|
||||
{:else}Error — Retry{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -51,7 +58,7 @@
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
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 {
|
||||
filter: brightness(1.15);
|
||||
@@ -65,10 +72,12 @@
|
||||
aspect-ratio: 2 / 1;
|
||||
border-width: 3px;
|
||||
min-height: 80px;
|
||||
transition: aspect-ratio 0.3s ease, border-width 0.3s ease, min-height 0.3s ease;
|
||||
}
|
||||
.runner {
|
||||
aspect-ratio: 4 / 1;
|
||||
min-height: 56px;
|
||||
transition: aspect-ratio 0.3s ease, min-height 0.3s ease;
|
||||
}
|
||||
|
||||
/* Ghost SVG fills cell, text sits at bottom */
|
||||
@@ -90,62 +99,112 @@
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 14px;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
.runner .score-fg {
|
||||
padding: 0 8px;
|
||||
}
|
||||
.score-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.score-rank {
|
||||
font-size: 10px;
|
||||
letter-spacing: 1.5px;
|
||||
font-size: clamp(14px, 2.5vw, 17px);
|
||||
letter-spacing: 1.2px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.5;
|
||||
opacity: 0.85;
|
||||
line-height: 1;
|
||||
margin-bottom: 3px;
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
text-shadow: 0 0 6px rgb(0 0 0 / 50%);
|
||||
}
|
||||
.score-name {
|
||||
font-size: clamp(11px, 3vw, 20px);
|
||||
font-size: clamp(18px, 3.5vw, 22px);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.score-pts {
|
||||
font-size: clamp(20px, 5.5vw, 40px);
|
||||
letter-spacing: 2px;
|
||||
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 {
|
||||
font-size: clamp(16px, 5vw, 32px);
|
||||
font-size: clamp(34px, 9vw, 64px);
|
||||
}
|
||||
.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 {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: 1fr;
|
||||
transition: gap 0.3s ease;
|
||||
}
|
||||
@media (min-width: 480px) {
|
||||
@media (min-width: 480px) and (max-width: 950px) {
|
||||
.runners-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.winner {
|
||||
aspect-ratio: 2 / 1;
|
||||
aspect-ratio: 12 / 5;
|
||||
}
|
||||
|
||||
.runner {
|
||||
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 {
|
||||
/* Use --runner-count so fewer teams fill the row evenly */
|
||||
grid-template-columns: repeat(min(var(--runner-count), 4), 1fr);
|
||||
@@ -256,17 +315,20 @@
|
||||
.brackets {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: gap 0.3s ease, align-items 0.3s ease;
|
||||
}
|
||||
.bracket-sep {
|
||||
height: 1px;
|
||||
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||
margin: 0 10px;
|
||||
transition: height 0.3s ease, width 0.3s ease, margin 0.3s ease;
|
||||
}
|
||||
.bracket-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 7px 9px;
|
||||
flex-wrap: wrap;
|
||||
transition: gap 0.3s ease, padding 0.3s ease, min-width 0.3s ease, flex 0.3s ease;
|
||||
}
|
||||
|
||||
/* Player boxes */
|
||||
@@ -283,6 +345,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 52px;
|
||||
transition: max-width 0.3s ease, flex 0.3s ease, min-height 0.3s ease;
|
||||
}
|
||||
|
||||
/* Mobile: single column layout */
|
||||
@@ -321,7 +384,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 700px) {
|
||||
@media (min-width: 951px) {
|
||||
.player-box {
|
||||
max-width: calc(25% - 6px);
|
||||
}
|
||||
@@ -384,3 +447,76 @@
|
||||
.player-placement-gap {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// src/routes/admin/+page.server.ts
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db';
|
||||
import { scorers } from '$lib/server/db/schema';
|
||||
import { scorers, teams } from '$lib/server/db/schema';
|
||||
import { getRegisteredEvents } from '$lib/server/databaseManager';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
@@ -19,5 +19,8 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
return { user: locals.user };
|
||||
const allTeams = await db.select({ id: teams.id, name: teams.name }).from(teams).orderBy(teams.name);
|
||||
const events = await getRegisteredEvents();
|
||||
|
||||
return { teams: allTeams, events: events.events.map((e) => ({ id: e.id, name: e.name, division: e.division })) };
|
||||
};
|
||||
|
||||
@@ -1 +1,133 @@
|
||||
<div>some</div>
|
||||
<script lang="ts">
|
||||
import Table from '$lib/ui/Table.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
type LedgerEntry = {
|
||||
id: number;
|
||||
type: string;
|
||||
eventName: string;
|
||||
timestamp: string;
|
||||
teamPoints: Record<string, number>;
|
||||
};
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let entries = $state<LedgerEntry[]>([]);
|
||||
let showAll = $state(false);
|
||||
let selectedEventId = $state<number | ''>('');
|
||||
let inputPoints = $state<Record<string, string>>({});
|
||||
let submitting = $state(false);
|
||||
|
||||
let teamNames = $derived(data.teams.map((t) => t.name).sort());
|
||||
|
||||
function formatTime(ts: string) {
|
||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
}
|
||||
|
||||
function formatPoints(n: number) {
|
||||
return n >= 0 ? `+${n}` : `${n}`;
|
||||
}
|
||||
|
||||
async function loadEntries(all: boolean = false) {
|
||||
const res = await fetch(`/api/ledger?limit=${all ? 'all' : '100'}`);
|
||||
entries = await res.json();
|
||||
showAll = all;
|
||||
}
|
||||
|
||||
async function submitEntry() {
|
||||
const event = data.events.find((e) => e.id === selectedEventId);
|
||||
if (!event) return;
|
||||
|
||||
submitting = true;
|
||||
try {
|
||||
const teamPoints: Record<string, number> = {};
|
||||
for (const [team, val] of Object.entries(inputPoints)) {
|
||||
const num = parseInt(val);
|
||||
if (!isNaN(num) && num !== 0) {
|
||||
teamPoints[team] = num;
|
||||
}
|
||||
}
|
||||
|
||||
await fetch('/api/ledger', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ eventId: event.id, teamPoints })
|
||||
});
|
||||
|
||||
selectedEventId = '';
|
||||
inputPoints = {};
|
||||
await loadEntries(showAll);
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadEntries();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="page-container">
|
||||
<h2 class="section-label">Manual Ledger Entry</h2>
|
||||
<div class="card">
|
||||
<div class="flex flex-wrap gap-4 items-end">
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Event</label>
|
||||
<select bind:value={selectedEventId}>
|
||||
<option value="">Select event...</option>
|
||||
{#each data.events as event (event.id)}
|
||||
<option value={event.id}>{event.name} — {event.division}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{#each teamNames as team}
|
||||
<div>
|
||||
<label class="block text-sm mb-1">{team}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={inputPoints[team] ?? '0'}
|
||||
oninput={(e) => { inputPoints[team] = (e.target as HTMLInputElement).value; }}
|
||||
style="width: 5rem;"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
<button onclick={submitEntry} disabled={!selectedEventId || submitting}>
|
||||
Submit
|
||||
</button>
|
||||
</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">
|
||||
{#snippet header()}
|
||||
<th class="px-2">ID</th>
|
||||
<th class="px-2">Type</th>
|
||||
<th class="px-2">Event</th>
|
||||
{#each teamNames as team}
|
||||
<th class="px-2">{team}</th>
|
||||
{/each}
|
||||
<th class="px-2">Time</th>
|
||||
{/snippet}
|
||||
{#snippet row(entry: LedgerEntry)}
|
||||
<td class="px-2">{entry.id}</td>
|
||||
<td class="px-2">{entry.type}</td>
|
||||
<td class="px-2">{entry.eventName}</td>
|
||||
{#each teamNames as team}
|
||||
<td class="px-2">
|
||||
{entry.teamPoints[team] !== undefined ? formatPoints(entry.teamPoints[team]) : ''}
|
||||
</td>
|
||||
{/each}
|
||||
<td class="px-2">{formatTime(entry.timestamp)}</td>
|
||||
{/snippet}
|
||||
</Table>
|
||||
</div>
|
||||
@@ -1,12 +1,10 @@
|
||||
// src/routes/login/+page.server.ts
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db';
|
||||
import { scorers } from '$lib/server/db/schema';
|
||||
import { generateSessionToken, createSession, setSessionTokenCookie } from '$lib/server/auth';
|
||||
import type { Actions } from '../login/$types';
|
||||
|
||||
import type { PageServerLoad } from '../login/$types';
|
||||
import { verify } from '$lib/server/password';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
return { user: locals.user };
|
||||
@@ -19,7 +17,7 @@ export const actions: Actions = {
|
||||
const password = data.get('password') as string;
|
||||
|
||||
const [user] = await db.select().from(scorers).where(eq(scorers.username, username));
|
||||
if (!user || !(await Bun.password.verify(password, user.passwordHash))) {
|
||||
if (!user || !(await verify(password, user.passwordHash))) {
|
||||
return fail(400, { message: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { ActionData, PageData } from './$types';
|
||||
export let data: PageData;
|
||||
export let form: ActionData;
|
||||
let { data, form }: { data: PageData; form: any } = $props();
|
||||
</script>
|
||||
|
||||
<div class="auth-card">
|
||||
<div class="page-container">
|
||||
<div class="card" style="max-width: 360px; margin: 3rem auto;">
|
||||
{#if data.user}
|
||||
<div class="align-center flex h-full w-full flex-col text-center">
|
||||
<h1>Already logged in</h1>
|
||||
@@ -20,18 +20,12 @@
|
||||
<form method="POST">
|
||||
<label>
|
||||
Username
|
||||
<input class="text-black" name="username" type="text" autocomplete="username" required />
|
||||
<input name="username" type="text" autocomplete="username" required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
class="text-black"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
<input name="password" type="password" autocomplete="current-password" required />
|
||||
</label>
|
||||
|
||||
{#if form?.message}
|
||||
@@ -40,51 +34,8 @@
|
||||
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
|
||||
<p class="switch">No account? <a href="/register">Sign up</a></p>
|
||||
{/if}
|
||||
|
||||
<!-- <p class="switch">No account? <a href="/signup">Sign up</a></p> -->
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.auth-card {
|
||||
max-width: 320px;
|
||||
margin: 4rem auto;
|
||||
padding: 2rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
}
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
button {
|
||||
padding: 0.6rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: #333;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.error {
|
||||
color: #c0392b;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
.switch {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
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 { eq } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db';
|
||||
import { scorers } from '$lib/server/db/schema';
|
||||
import { generateSessionToken, createSession, setSessionTokenCookie } from '$lib/server/auth';
|
||||
import type { Actions } from '../signup/$types';
|
||||
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 = {
|
||||
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 username = data.get('username') as string;
|
||||
const password = data.get('password') as string;
|
||||
@@ -21,7 +32,7 @@ export const actions: Actions = {
|
||||
return fail(400, { message: 'Username already taken' });
|
||||
}
|
||||
|
||||
const passwordHash = await Bun.password.hash(password);
|
||||
const passwordHash = await hash(password);
|
||||
const userId = crypto.randomUUID();
|
||||
|
||||
await db.insert(scorers).values({ id: userId, username, passwordHash });
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
<script lang="ts">
|
||||
import type { ActionData } from './$types';
|
||||
export let form: ActionData;
|
||||
import type { PageData } from './$types';
|
||||
let { data, form }: { data: PageData; form: any } = $props();
|
||||
</script>
|
||||
|
||||
<div class="auth-card">
|
||||
<div class="page-container">
|
||||
<div class="card" style="max-width: 360px; margin: 3rem auto;">
|
||||
<h1>Sign up</h1>
|
||||
|
||||
{#if data.disabled}
|
||||
<p class="error">Registration is currently disabled by an administrator.</p>
|
||||
{:else}
|
||||
<form method="POST">
|
||||
<label>
|
||||
Username
|
||||
<input name="username" class="text-black" type="text" autocomplete="username" required />
|
||||
<input name="username" type="text" autocomplete="username" required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
class="text-black"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
minlength="8"
|
||||
required
|
||||
/>
|
||||
<input name="password" type="password" autocomplete="new-password" minlength="8" required />
|
||||
<small>At least 8 characters</small>
|
||||
</label>
|
||||
|
||||
@@ -33,51 +30,6 @@
|
||||
</form>
|
||||
|
||||
<p class="switch">Already have an account? <a href="/login">Log in</a></p>
|
||||
{/if}
|
||||
</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';
|
||||
|
||||
// export const load: PageServerLoad = async ({ params }) => {
|
||||
// return {
|
||||
// data: await getPlayerInfo(parseInt(params.playerId))
|
||||
// };
|
||||
// };
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const playerId = parseInt(params.playerId);
|
||||
const [playerInfo, playerEvents] = await Promise.all([
|
||||
getPlayerInfo(playerId),
|
||||
getPlayerEvents(playerId)
|
||||
]);
|
||||
return {
|
||||
playerInfo: getPlayerInfo(parseInt(params.playerId))
|
||||
playerInfo,
|
||||
playerEvents
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
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 measure() {
|
||||
@@ -22,13 +44,9 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await data.playerInfo}
|
||||
<div>Loading...</div>
|
||||
{:then playerInfo}
|
||||
{console.log(playerInfo)}
|
||||
<div class="mt-5 flex justify-center">
|
||||
<div class="page-container">
|
||||
<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}"
|
||||
>
|
||||
<div class="player-ghost" aria-hidden="true">
|
||||
@@ -44,32 +62,70 @@
|
||||
>
|
||||
</svg>
|
||||
</div>
|
||||
<div class=" goldman player-name-wrap text-5xl" use:marquee>
|
||||
{playerInfo.firstName}
|
||||
{playerInfo.lastName}
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
{/await}
|
||||
|
||||
<style>
|
||||
.player-single-box {
|
||||
flex: 1 1 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
border: 1.5px solid var(--c);
|
||||
color: var(--c);
|
||||
background: color-mix(in srgb, var(--c) 10%, transparent);
|
||||
padding: 5px 7px 5px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.player-name-wrap {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
<p class="section-label">Events</p>
|
||||
|
||||
<section class="events-scroll" style="box-shadow: none; max-height: none; overflow-y: visible;">
|
||||
<div class="events">
|
||||
{#each playerEvents as event (event.id)}
|
||||
<div
|
||||
class="event-card"
|
||||
class:ongoing-event={event.state == 1}
|
||||
class:completed-event={event.state == 2}
|
||||
style={event.state == 2 && event.winner ? `--event-color: ${event.winner.color}` : ''}
|
||||
>
|
||||
<div class="event-header">
|
||||
<a href="/event/{event.id}" class="event-name goldman">{event.name}</a>
|
||||
<span class="event-division">{event.division}</span>
|
||||
{#if event.state == 1}
|
||||
<span class="event-status goldman">ONGOING</span>
|
||||
{:else if event.state == 2}
|
||||
<span
|
||||
class="event-winner event-status goldman"
|
||||
style={event.winner ? `--winner-color:${event.winner.color}` : ''}
|
||||
>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} */
|
||||
const config = {
|
||||
compilerOptions: {
|
||||
// Force runes mode (except in node_modules). Remove when Svelte 6 makes it default.
|
||||
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
|
||||
},
|
||||
kit: {
|
||||
@@ -18,11 +17,6 @@ const config = {
|
||||
experimental: {
|
||||
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