Compare commits

..

246 Commits

Author SHA1 Message Date
7434165ab0 Pocketbase: Update workflow to v0.33.0
Some checks failed
Build Formula11 Docker Image / pocketbase-docker (push) Has been cancelled
Build Pocketbase Docker Image / pocketbase-docker (push) Successful in 10s
2025-11-23 12:57:26 +01:00
cae1457081 Pocketbase: Update schema (fix incorrect DNF points calculation with multiple DNFs)
Some checks failed
Build Formula11 Docker Image / pocketbase-docker (push) Has been cancelled
Build Pocketbase Docker Image / pocketbase-docker (push) Successful in 3m28s
2025-11-23 12:48:47 +01:00
0afc4c2e80 Pocketbase: Update image to v0.33.0 2025-11-23 12:48:30 +01:00
b58ed8e0f0 Env: Update browser-list-db 2025-11-23 12:48:13 +01:00
9ff333f90e Env: Update flake 2025-11-23 12:47:41 +01:00
d31c6b6735 Lib: Fix f1.com scraping after the keks changed their site
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 55s
2025-06-21 22:35:06 +02:00
cb48ee68e5 Data/Official: Add warning toast to official data fetch button 2025-06-21 22:34:42 +02:00
f3c75fc921 Lib: Update toast.ts to allow action toasts 2025-06-21 22:33:47 +02:00
eb0de24754 Statistics: Fix nullpointer issue 2025-06-07 22:46:56 +02:00
5adc05e1bb Statistics: Implement statistics page with team/driver standings + driver cumsum chart
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 31s
2025-06-07 22:44:41 +02:00
f049805124 Leaderboard: Use chart options generator function 2025-06-07 22:44:24 +02:00
e4be7c4830 Data/Official: Update scraped data "depends" name 2025-06-07 22:44:01 +02:00
f0950d3241 Lib: Add chart options generator function 2025-06-07 22:43:30 +02:00
454b77e778 Lib: Update fetcher + schema after database update 2025-06-07 22:43:21 +02:00
35c0003159 Data/Official: Disable scrape button for official data clientside
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 30s
2025-06-07 20:40:59 +02:00
5a6156adb7 Skeleton: Subscribe to scraped_startinggrids collection 2025-06-07 20:40:19 +02:00
c14958e5bf Data/Official: Display starting grid data
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 30s
2025-06-07 20:34:28 +02:00
9d6e4e1b0b API/Scrape: Scrape starting grid data 2025-06-07 20:34:17 +02:00
f5d8f56330 Lib: Implement starting grid scraper 2025-06-07 20:34:02 +02:00
3ca967591e Leaderboard: Implement simple points cumsum chart
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 1m3s
2025-06-07 19:59:39 +02:00
6e6ce020a3 Env: Add carbon-charts dep 2025-06-07 19:59:22 +02:00
d54ee01227 Lib: Update fetchers and schema after database changes 2025-06-07 19:58:34 +02:00
3339ffaa5f Seasonpicks: Invert table background colors 2025-06-06 16:26:53 +02:00
78ee291795 Racepicks: Invert table background colors 2025-06-06 16:26:46 +02:00
77895d9057 Seasonpicks: Decrease table margins
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 49s
2025-06-06 16:08:58 +02:00
0cd87c6222 Racepicks: Decrease table margins 2025-06-06 16:08:58 +02:00
59ece62947 Lib: Fix driver id lookup by selected chips in RaceResultCard
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 52s
2025-04-16 03:03:37 +02:00
9cabe902a0 Pocketbase: Update schema
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 30s
2025-03-27 14:40:57 +01:00
c8016509ea Season/Teams: Remove border from team color badge
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 29s
2025-03-27 13:28:26 +01:00
82941d4a2a Season/Drivers: Remove border from team color badge 2025-03-27 13:28:19 +01:00
a9ac49f6fa Season/Drivers: Display started_active in table
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 33s
2025-03-27 13:17:30 +01:00
0cd4129a6e Seasonpicks: Filter drivers (podiums/teamwinners) by started_active 2025-03-27 13:17:20 +01:00
eeae9fea6e Lib: Set started_active field for new driver in TeamSwitchCard 2025-03-27 13:17:05 +01:00
35af66c222 Lib: Add started_active field to Driver schema 2025-03-27 13:16:17 +01:00
707ba0f156 Season/Drivers: Add "Switch Team" button
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 48s
2025-03-27 13:00:12 +01:00
a1a9166c73 Skeleton: Register TeamSwitchCard component 2025-03-27 13:00:01 +01:00
310d2c1bd3 Lib: Implement TeamSwitchCard 2025-03-27 12:59:52 +01:00
7d49da3492 Lib: Prevent DriverCard from updating driver teams (use team switch) 2025-03-27 12:59:37 +01:00
edbcf5e5ab Lib: Update SubstitutionCard promises 2025-03-27 12:59:01 +01:00
f66f6783d2 Skeleton: Link to roadmap in navbar
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 29s
2025-03-25 20:33:36 +01:00
415c9e3ae1 Lib: Add newtab option to Button component 2025-03-25 20:33:28 +01:00
cb4a2140f7 Skeleton: Move data drawer "Official" button
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 34s
2025-03-25 20:12:04 +01:00
7ab818dd48 Api/Scrape: Implement serverside api route to fetch official f1.com data
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 37s
2025-03-25 20:09:54 +01:00
1d7dff1b53 Seasonpicks: Update depended data 2025-03-25 19:51:37 +01:00
18efab45d3 Racepicks: Update depended data 2025-03-25 19:51:31 +01:00
f75c880c25 Data/Raceresults: Update depended data 2025-03-25 19:51:23 +01:00
20dfc45f89 Data/Official: Implement driverstandings page 2025-03-25 19:45:37 +01:00
3b710bd846 Data/Official: Implement raceresults page 2025-03-25 19:45:30 +01:00
5485425213 Data/Official: Implement teamstandings page 2025-03-25 19:45:23 +01:00
a9252e92f7 Skeleton: Subscribe to official/scraped pocketbase tables 2025-03-25 19:45:15 +01:00
baaa5f1c61 Data/Official: Add official data page skeleton 2025-03-25 19:32:00 +01:00
6239cdef5d Skeleton: Add data drawer button for official data 2025-03-25 19:31:39 +01:00
a05c0e6882 Data/Users: Update table height 2025-03-25 19:31:25 +01:00
756004476d Season/Teams: Update table height 2025-03-25 19:31:20 +01:00
0e0203c4f7 Season/Substitutions: Update table height 2025-03-25 19:31:13 +01:00
7e24a43312 Season/Races: Update table height 2025-03-25 19:31:06 +01:00
4bfa15f4aa Season/Drivers: Update table height 2025-03-25 19:31:01 +01:00
f3eb710403 Data/Raceresults: Update table height 2025-03-25 19:30:43 +01:00
bc158b6060 Lib: Add scraper library functions for results/team standings/driver standings 2025-03-25 19:30:10 +01:00
2fa2d7006b Lib: Add ScrapedDriverStanding + ScrapedTeamStanding to schema 2025-03-25 19:29:48 +01:00
36b4aea1c2 Lib: Add pocketbase fetchers for scraped data 2025-03-25 19:29:32 +01:00
4a497aefb4 Lib: Add optional height classes to component + make thead sticky 2025-03-25 19:29:16 +01:00
22a6da55fa Lib: Add ScrapedRaceResult to schema 2025-03-25 18:00:58 +01:00
b0859ff147 Env: Add cheerio node dep 2025-03-25 18:00:46 +01:00
cdf11fc2ba Raceresults: Check for 0 pxxs in results table
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 54s
2025-03-25 18:00:26 +01:00
8f27828384 Lib: Don't force 7 pxxs in RaceResultCard 2025-03-25 18:00:04 +01:00
e40c284726 Lib: Disable race select filtering in RaceResultCard when editing an existing result
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 28s
2025-03-23 19:16:18 +01:00
80fa0216fb Lib: Only show races without result present in RaceResultCard race dropdown
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 27s
2025-03-23 19:10:37 +01:00
ec56925dca Lib: Add placeholder to RaceCard, hint to end name with emoji
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 27s
2025-03-23 19:03:17 +01:00
243a020e00 Racepicks: Rework racename display on mobile (fixes emoji bug)
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 40s
2025-03-23 19:00:48 +01:00
45d4cc1557 Env: Add runes2 npm dep for emoji support 2025-03-23 19:00:34 +01:00
23d01672de Lib: Check for empty string in format_date
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 28s
2025-03-23 18:17:46 +01:00
ab32cfc49a Racepicks: Fix race result popup clipping with avatar table header
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 29s
2025-03-23 18:12:41 +01:00
2faa57489d Pocketbase: Update schema
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 29s
2025-03-23 17:22:08 +01:00
c732ff2014 Leaderboard: Implement first primitive leaderboard page (only total points)
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 29s
2025-03-23 17:17:07 +01:00
7fb38874d9 Leaderboard: Fetch points data 2025-03-23 17:16:51 +01:00
ce2c7af35c Env: Add sqlite/sqlitebrowser to flake 2025-03-23 17:16:30 +01:00
9ec0bf0bd5 Lib: Add fetchers for RacePickPoints and RacePickPointsAcc 2025-03-23 17:16:10 +01:00
f249205cd8 Lib: Add RacePickPoints and RacePickPointsAcc to schema 2025-03-23 17:15:59 +01:00
25e50fd64b Racepicks: Fix incorrect column min-width
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 19s
2025-03-23 08:47:46 +01:00
67164cff09 Racepicks: Make table scroll on both axes, always show race+avatars
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 28s
2025-03-18 18:54:19 +01:00
cb87f336a3 Seasonpicks: Remove obsolete styling 2025-03-18 18:53:43 +01:00
d490eb339e Seasonpicks: Fix accordion shadow (other shadows still missing)
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 30s
2025-03-18 18:08:34 +01:00
132b538775 Lib: Sort driver dropdown options by lastname 2025-03-18 17:22:33 +01:00
843824ffa9 Seasonpicks: Make table scroll on both axes, always show category+avatars 2025-03-18 17:12:50 +01:00
db75fd2ace Seasonpicks: Do not hide usernames on mobile (columns stay large enough) 2025-03-18 13:46:16 +01:00
6bfc87fdc3 Racepicks: Align table column min width with seasonpicks table 2025-03-18 13:31:52 +01:00
d3827cf914 Skeleton: Update drawer width breakpoints 2025-03-18 13:29:44 +01:00
6f329322bc Remove obsolete TODOs 2025-03-18 13:08:01 +01:00
1fe6c49f2b Racepicks: Add tooltips to small images (e.g. avatars...)
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 48s
2025-03-18 12:40:41 +01:00
cf4d98f480 Seasonpicks: Add tooltips to small images (e.g. avatars, podiums, ...) 2025-03-18 12:40:31 +01:00
8051b86d51 Seasonpicks: Remove unused awaited promise 2025-03-18 12:30:03 +01:00
901bcc1c05 Lib: Add tooltip capability to LazyImage 2025-03-18 12:29:49 +01:00
19170ebef9 Env: Add uuid npm dep 2025-03-18 12:29:28 +01:00
6a5be20e80 Seasonpicks: Don't slice teamwinners in accordion 2025-03-18 12:23:46 +01:00
8250378da4 Skeleton: Fix error in pbUser store access 2025-03-18 12:14:45 +01:00
8a79da8d8b Racepicks: Hide accordion if signed out 2025-03-18 12:14:00 +01:00
a9b37a8884 Seasonpicks: Hide accordion if signed out 2025-03-18 12:13:54 +01:00
1857755cfb Racepicks: Update accordion picked/outstanding overflows 2025-03-18 12:05:08 +01:00
3f31a00a09 Seasonpicks: Update accordion podiums/teamwinners/picked/outstanding overflows 2025-03-18 12:05:00 +01:00
0870e6510f Env: Update flake lock 2025-03-16 15:32:20 +01:00
5da7cfa089 Lib: Migrate to page from $app/state
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 27s
2025-03-15 12:08:18 +01:00
6b3c0716b0 Skeleton: Migrate to page from $app/state 2025-03-15 12:08:12 +01:00
db99892df1 Seasonpicks: Fix inverted teamwinners/podiums opacity + sort podiums in accordion
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 42s
2025-03-15 10:56:52 +01:00
94c55212dd Seasonpicks: Decrease non-picked teamwinners/podiums opacity
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 38s
2025-03-15 02:08:05 +01:00
84ae6b25d7 Env: Update node deps 2025-03-15 02:07:53 +01:00
5a992faf48 Seasonpicks: Improve teamwinners/podiums display (2-cols, team colors)
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 27s
2025-03-15 01:50:26 +01:00
4df7cae2d6 Seasonpicks: Display teamwinners/podiums as lists
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 28s
2025-03-15 01:29:18 +01:00
eec143d63b Skeleton: Add "Forgot Password" button to login drawer
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 28s
2025-03-15 00:54:56 +01:00
0642a6c0e2 Lib: Refactor Input component borders 2025-03-15 00:28:06 +01:00
3eab329b42 Skeleton: Display if a user email is verified in profile drawer 2025-03-15 00:20:12 +01:00
4f9799dcfb Lib: Set verified field for pbUser 2025-03-15 00:19:43 +01:00
e46bdc60bd Lib: Add verified field to User schema 2025-03-15 00:19:35 +01:00
62f4d211ac Hooks: Try to refresh the authStore on page reload 2025-03-15 00:19:14 +01:00
e35d56c81c Lib: Allow setting timeout in toast helpers 2025-03-15 00:19:00 +01:00
3a9b4d6834 Lib: Add optional tail element to Input component 2025-03-15 00:18:39 +01:00
43e8a00aeb Skeleton: Use writable store for pbUser object 2025-03-14 23:56:52 +01:00
614e2becc4 Skeleton: Remove user/admin from fetched data
This data was fetched before it was available, so the user object was
undefined
2025-03-14 22:35:03 +01:00
03fe027f8c Lib: Update pocketbase auth handling 2025-03-14 22:20:36 +01:00
c597fff15a Skeleton: Initial support for user emails 2025-03-14 22:19:40 +01:00
12803a7b8f Lib: Add info/warning toast helpers 2025-03-14 22:17:54 +01:00
f39c1a9090 Lib: Update user schema to include email 2025-03-14 22:17:46 +01:00
6a6c93d960 Lib: Add EMailIcon svg component 2025-03-14 22:17:34 +01:00
7abe668169 Data/Users: Don't show username (as it's used for login)
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 46s
2025-03-14 19:46:08 +01:00
b6fd307725 Seasonpicks: Display firstname in table 2025-03-14 19:45:06 +01:00
b160d15683 Racepicks: Display firstname in table 2025-03-14 19:44:58 +01:00
082bb72ec1 Seasonpicks: Disable countdown until seasonpicks will be locked
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 28s
2025-03-14 01:45:57 +01:00
b9413fcfcd Lib: Allow customizing finished text in Countdown 2025-03-14 01:45:40 +01:00
a0d75d6411 Lib: Rename fetch_seasonpicks to fetch_visibleseasonpicks 2025-03-14 01:24:24 +01:00
8ee212bbf3 Seasonpicks: Fix slightly scrollable teamwinner/podiums in accordion 2025-03-14 01:20:25 +01:00
e7d78883b3 Seasonpicks: Fix accordion grid layout when signed out
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 26s
2025-03-14 00:44:48 +01:00
d72b623105 Racepicks: Fix accordion grid layout when signed out 2025-03-14 00:44:39 +01:00
1ddd2282e2 Skeleton: Invalidate seasonpick-/racepick-data when logging in 2025-03-14 00:44:26 +01:00
2536126ead Skeleton: Trim login data + fix undefined firstname when updating profile
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 26s
2025-03-14 00:39:16 +01:00
8c18cef7aa Racepicks: Use grid for accordion
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 27s
2025-03-14 00:27:40 +01:00
e63c905f0e Seasonpicks: Use grid for accordion + adjust breakpoints 2025-03-14 00:27:33 +01:00
b36ec6c3cd Seasonpicks: Increase hottake row height in table 2025-03-14 00:13:23 +01:00
17a1ea8524 Racepicks: Use new date format helpers
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 50s
2025-03-14 00:08:41 +01:00
bbdb684a8a Season/Races: Fix datetimes being shown in UTC + use new date format helpers 2025-03-14 00:08:29 +01:00
f88c8dc7ed Lib: Use new date formatting helpers in RaceCard 2025-03-14 00:08:01 +01:00
54d154e445 Lib: Fix removing sprints in RaceCard 2025-03-14 00:07:48 +01:00
b9aea2e501 Lib: Fix missing label separator in Input 2025-03-14 00:06:08 +01:00
b4552f7d50 Lib: Remove DateTimeInput 2025-03-14 00:05:52 +01:00
f4fcb2e745 Lib: Add DateTimeInput 2025-03-14 00:00:06 +01:00
965504ed4e Lib: Update date formatting helpers 2025-03-13 23:59:46 +01:00
780706ce4a Layout: Don't check for username/firstname in profile update handler
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 29s
2025-02-27 01:11:50 +01:00
d4d44a79f6 Seasonpicks: Fix accordion lag
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 27s
2025-02-27 00:55:58 +01:00
63ba16bd4d Racepicks: Fix accordion lag 2025-02-27 00:55:52 +01:00
750c89823d Seasonpicks: Sort teamwinners and podiums
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 27s
2025-02-27 00:52:00 +01:00
55d0525b37 Racepicks: Fix "Your Race Pick" accordion opening lag 2025-02-27 00:51:49 +01:00
a80f2e6fcd Lib: Comments 2025-02-27 00:51:26 +01:00
1933ad8ca8 Pocketbase: Update schema
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 32s
2025-02-27 00:13:20 +01:00
4d7b96b5cc Seasonpick: Fix modal component typo
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 32s
2025-02-27 00:10:35 +01:00
e5fd61aa49 Skeleton: Register SeasonPickCard modal component 2025-02-27 00:10:11 +01:00
4a2b5e8781 Lib: Implement SeasonPickCard 2025-02-27 00:09:24 +01:00
53396fce19 Lib: Display full driver names in dropdown labels + sort alphabetically 2025-02-26 23:37:22 +01:00
dcd62cdbfa Lib: Sort driver options in RaceResultCard 2025-02-26 23:37:06 +01:00
08774fc40b Raceresults: Fetch substitutions 2025-02-26 23:21:43 +01:00
bc8a86c025 Lib: Respect substituted drivers in RaceResultCard 2025-02-26 23:21:29 +01:00
3178840b02 Lib: DISABLE PXX DISPLAY IN RACERESULTCARD CHIPS 2025-02-26 22:56:28 +01:00
161929f1c2 Seasonpicks: Hide Doohan text if no pick 2025-02-26 22:30:29 +01:00
6475c49af0 Seasonpicks: Implement "Your Season Pick" Accordion
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 26s
2025-02-26 22:28:16 +01:00
b6f02c683f Racepicks: Show all picked/outstanding users with scroll 2025-02-26 22:28:03 +01:00
5b02b37e38 Lib: Assign imgclass also to image container 2025-02-26 22:27:43 +01:00
c119567a90 Seasonpicks: Make first column width match racepicks page
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 27s
2025-02-26 21:24:16 +01:00
99de8eec72 Seasonpicks: Only display hottakes before deadline
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 30s
2025-02-26 21:20:45 +01:00
f7bb4c7978 Lib: Disable draggable on Button links 2025-02-26 21:20:22 +01:00
f63d143ba5 Skeleton: Fix user-select: none for firefox 2025-02-26 21:10:31 +01:00
cb8eb26d22 Seasonpicks: Refactor current seasonpick acquisition + user new fetchers
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 43s
2025-02-26 21:04:21 +01:00
1ca384700a Racepicks: Refactor current racepick acquisition + use new fetchers 2025-02-26 21:04:08 +01:00
2952cac6ef Lib: Add fetcher for hottakes + use "visible..." collections for picks 2025-02-26 21:03:47 +01:00
0a26c82200 Lib: Update PickedUser schemas + add Hottake schema 2025-02-26 21:03:14 +01:00
f8365adc37 Pocketbase: Add schema 2025-02-26 20:39:07 +01:00
6c8738366a Seasonpicks: Implement first (rough) seasonpick display page
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 57s
2025-02-25 20:47:43 +01:00
cd31d50cb2 Lib: Fix double semicolon in LazyImage 2025-02-25 20:11:30 +01:00
d31576a4d5 Racepicks: Make vertical race name font smaller on small screens 2025-02-25 18:59:39 +01:00
98e4f92485 Seasonpicks: Fetch users that already made their seasonpick 2025-02-18 01:21:15 +01:00
fcf389bf29 Lib: Add seasonpickedusers fetcher 2025-02-18 01:21:03 +01:00
f06fdb3c2e Lib: Add SeasonPickedUser to schema 2025-02-18 01:20:57 +01:00
9b1de1d4b7 Data/Users: Don't await graphics 2025-02-18 01:20:45 +01:00
03c19480a0 Racepicks: Don't await graphics 2025-02-18 01:20:37 +01:00
34c8ef57bf Lib: Don't await graphics 2025-02-18 01:20:32 +01:00
2fb7a7d597 Seasonpicks: Fetch teams/drivers/seasonpicks 2025-02-18 00:43:05 +01:00
036733b287 Racepicks: Use pointer cursor for race column (to highlight result popups) 2025-02-18 00:42:49 +01:00
55765d2d42 Lib: Add SeasonPick schema 2025-02-18 00:42:22 +01:00
c8a0a3d5f2 Lib: Add fetcher for seasonpicks 2025-02-18 00:42:15 +01:00
c67ee16641 Skeleton: Remove dependencies/subscriptions on view collections
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 26s
2025-02-18 00:02:01 +01:00
c74b31907f Lib: Remove manual data invalidation in favor of pocketbase subscriptions
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 31s
2025-02-17 23:51:12 +01:00
98b11f7b9b Skeleton: Subscribe to pocketbase realtime events for all collections
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 54s
2025-02-17 23:33:35 +01:00
aaf919af0a Lib: Add pocketbase realtime subscribe/unsubscribe helpers 2025-02-17 23:33:23 +01:00
8c2754cebc Racepicks: Also fetch missing substitutions 2025-02-17 23:32:30 +01:00
ba5dac81fd Racepicks: Fix picked users grid padding inconsistency 2025-02-17 23:32:18 +01:00
5f73f0f952 Lib: Remove SkeletonData schema and use correct PageData types instead 2025-02-17 23:31:57 +01:00
4a1fcc6447 Skeleton: Some login logic fixes 2025-02-17 22:56:03 +01:00
04b69611a1 Skeleton: Define dependencies when fetching data 2025-02-17 21:15:00 +01:00
25a55ec94e Skeleton: Delegate data fetching to specific routes and only load what's needed 2025-02-17 21:12:11 +01:00
2b90f977d5 Lib: Fix invalid field in schema 2025-02-17 20:59:56 +01:00
8c8be5273b Lib: Move data fetching functions to library 2025-02-17 20:59:50 +01:00
37f4234c87 Lib: Implement driver headshot compression 2025-02-17 18:15:32 +01:00
29d992affe Lib: Implement race pictogram compression 2025-02-17 18:15:23 +01:00
24d557958a Lib: Implement team banner compression 2025-02-17 18:15:15 +01:00
524ccc13d2 Skeleton: Implement user avatar compression 2025-02-17 18:15:06 +01:00
8142c72c93 Api/Compress: Add image compression server endpoint 2025-02-17 18:14:50 +01:00
8ca1e3d511 Env: Remove obsolete node deps + re-add sharp 2025-02-17 18:14:34 +01:00
6093553ce5 Lib: Update server-side image compression 2025-02-17 18:14:18 +01:00
bd7e962f83 Lib: Fix deleting from wrong collection in RaceCard 2025-02-17 18:07:09 +01:00
9e39547936 Skeleton: Fetch RaceResults asynchronously in root layout 2025-02-17 15:58:07 +01:00
e58b94022a Env: Remove obsolete flake contents 2025-02-17 15:51:20 +01:00
c211aa21bb Env: Remove obsolete node deps + add image-conversion 2025-02-17 15:51:11 +01:00
65e3c0651c Lib: Fix public pocketbase url
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 25s
2025-02-08 16:42:53 +01:00
f868d779e7 DISABLE SSR AND TRANSITION TO SPA
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 43s
2025-02-08 16:37:58 +01:00
91fc3ae7a2 Lib: Add "Select Random" button to RacePickCard
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 26s
2025-02-07 00:04:40 +01:00
88fb24a20c Lib: Refactor nullhandling in RaceResultCard 2025-02-06 23:59:26 +01:00
6f4b5e45cc Lib: Update RacePickCard labelwidth 2025-02-06 23:13:35 +01:00
705b66f638 Lib: Fix promise handling and reactive placeholder/chips in RaceResultCard
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 49s
2025-02-06 23:10:23 +01:00
bea45b8339 Lib: Update promise handling in RacePickCard 2025-02-06 23:10:05 +01:00
76a5388e18 Lib: Reorder card scripts 2025-02-06 23:09:40 +01:00
bbd700f755 Lib: Move dateformat function to lib 2025-02-06 23:06:21 +01:00
0b64425284 Lib: Replace the <Dropdown> popup with a native select element + bind the value 2025-02-06 19:48:25 +01:00
c8ce8118cb Lib: Allow binding the <Input> value 2025-02-06 19:47:44 +01:00
5d6d33fe9f Lib: Remove unused Search component 2025-02-06 16:45:04 +01:00
d76bbe8979 Racepicks: Fetch currentrace at the end in the load function
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 27s
2025-02-06 00:32:20 +01:00
3d51ea3e10 RaceResultCard: WIP - Make PXXs reactive based on selected race 2025-02-06 00:01:54 +01:00
685a30b9a9 Skeleton: Add page titles
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 26s
2025-02-05 22:33:07 +01:00
5f43c9ac52 Lib: Use Promise.all for nested {#await} blocks
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 31s
2025-02-05 22:20:37 +01:00
894ad9aaa6 Racepicks: Use Promise.all for nested {#await} blocks 2025-02-05 22:14:57 +01:00
178adef327 Lib: Simplify RaceResultCard
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 49s
2025-02-05 21:56:41 +01:00
6947eeae3f Racepicks: Fix width of guess badges 2025-02-05 21:56:32 +01:00
735c73e435 Racepicks: Stream loaded data and resolve promises in markup 2025-02-05 20:38:28 +01:00
907e4fefb1 Lib: Type data passed to cards from the page context 2025-02-05 20:14:05 +01:00
8cb665cae8 Lib: Fix nullpointer in RacePickCard/SubstitutionCard preview handler
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 26s
2025-02-05 03:01:29 +01:00
da47668c29 Lib: Simplify RacePickCard 2025-02-05 02:56:31 +01:00
f90f5734e8 Lib: Simplify SubstitutionCard 2025-02-05 02:33:46 +01:00
042cb42e65 Lib: Simplify RaceCard 2025-02-05 02:11:02 +01:00
2a51c76e2f Lib: Remove caching from dropdown options generators 2025-02-05 01:49:40 +01:00
206d897fca Lib: Simplify DriverCard 2025-02-05 01:49:30 +01:00
603c7d0e40 Lib: Simplify TeamCard 2025-02-05 01:48:37 +01:00
347b5e1470 Racepicks: Fix progressive form enhancement (derive variables correctly) 2025-02-04 23:38:39 +01:00
ecee4ac5ab Skeleton: Use progressive form enhancement for register/login
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 27s
2025-02-04 23:02:05 +01:00
022054c1f1 Racepicks: Use progressive form enhancement for racepicks 2025-02-04 23:01:54 +01:00
887a9ea2ae Lib: Use progressive form enhancement for all cards 2025-02-04 23:01:35 +01:00
09519c18fa Racepicks: Only allow picking active drivers and apply substitutions
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 25s
2025-02-04 21:56:30 +01:00
b586a9ee23 Lib: Only display active/inactive drivers in SubstitutionCard dropdowns 2025-02-04 21:56:08 +01:00
d6ea629d32 Lib: Don't cache driver dropdown options 2025-02-04 21:55:46 +01:00
31749f6e0f Skeleton: Limit username input to 10 characters
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 26s
2025-02-04 21:22:26 +01:00
479d8aaec5 Lib: Display PXX in RaceResultCard selection chips 2025-02-04 21:22:17 +01:00
84 changed files with 10010 additions and 2844 deletions

2
.envrc
View File

@ -1,2 +0,0 @@
use flake
layout node

View File

@ -20,6 +20,6 @@ jobs:
username: ${{ secrets.CONTAINER_REGISTRY_USER }}
password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
- name: Build Pocketbase Docker Image
run: docker build --build-arg PB_VERSION=0.25.0 --file pocketbase.dockerfile --tag gitea.vps.chriphost.de/christoph/pocketbase:0.25.0 .
run: docker build --build-arg PB_VERSION=0.33.0 --file pocketbase.dockerfile --tag gitea.vps.chriphost.de/christoph/pocketbase:0.33.0 .
- name: Push Pocketbase Docker Image
run: docker push gitea.vps.chriphost.de/christoph/pocketbase:0.25.0
run: docker push gitea.vps.chriphost.de/christoph/pocketbase:0.33.0

128
flake.lock generated
View File

@ -1,15 +1,40 @@
{
"nodes": {
"devshell": {
"clj-nix": {
"inputs": {
"nixpkgs": "nixpkgs"
"devshell": "devshell",
"nix-fetcher-data": "nix-fetcher-data",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1735644329,
"narHash": "sha256-tO3HrHriyLvipc4xr+Ewtdlo7wM1OjXNjlWRgmM7peY=",
"lastModified": 1763549559,
"narHash": "sha256-w7qhicMuDyfm9/dJKs5+47XqhZmGXRfkZjyn8XjO+c0=",
"owner": "jlesquembre",
"repo": "clj-nix",
"rev": "a55b9fbce3da4aa35c94221f76d40c79e6de4d81",
"type": "github"
},
"original": {
"owner": "jlesquembre",
"repo": "clj-nix",
"type": "github"
}
},
"devshell": {
"inputs": {
"nixpkgs": [
"clj-nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1741473158,
"narHash": "sha256-kWNaq6wQUbUMlPgw8Y+9/9wP0F8SHkjy24/mN3UAppg=",
"owner": "numtide",
"repo": "devshell",
"rev": "f7795ede5b02664b57035b3b757876703e2c3eac",
"rev": "7c9e793ebe66bcba8292989a68c0419b737a22a0",
"type": "github"
},
"original": {
@ -18,6 +43,24 @@
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1719745305,
"narHash": "sha256-xwgjVUpqSviudEkpQnioeez1Uo2wzrsMaJKJClh+Bls=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "c3c5ecc05edc7dafba779c6c1a61cd08ac6583e9",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
@ -36,43 +79,80 @@
"type": "github"
}
},
"nixpkgs": {
"nix-fetcher-data": {
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": [
"clj-nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1722073938,
"narHash": "sha256-OpX0StkL8vpXyWOGUD6G+MA26wAXK6SpT94kLJXo6B4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e36e9f57337d0ff0cf77aceb58af4c805472bfae",
"lastModified": 1728229178,
"narHash": "sha256-p5Fx880uBYstIsbaDYN7sECJT11oHxZQKtHgMAVblWA=",
"owner": "jlesquembre",
"repo": "nix-fetcher-data",
"rev": "f3a73c34d28db49ef90fd7872a142bfe93120e55",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"owner": "jlesquembre",
"repo": "nix-fetcher-data",
"type": "github"
}
},
"nixpkgs_2": {
"nixpkgs": {
"locked": {
"lastModified": 1737717945,
"narHash": "sha256-ET91TMkab3PmOZnqiJQYOtSGvSTvGeHoegAv4zcTefM=",
"lastModified": 1763618868,
"narHash": "sha256-v5afmLjn/uyD9EQuPBn7nZuaZVV9r+JerayK/4wvdWA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "ecd26a469ac56357fd333946a99086e992452b6a",
"rev": "a8d610af3f1a5fb71e23e08434d8d61a466fc942",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
"id": "nixpkgs",
"type": "indirect"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1717284937,
"narHash": "sha256-lIbdfCsf8LMFloheeE6N31+BMIeixqyQWbSr2vk79EQ=",
"type": "tarball",
"url": "https://github.com/NixOS/nixpkgs/archive/eb9ceca17df2ea50a250b6b27f7bf6ab0186f198.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://github.com/NixOS/nixpkgs/archive/eb9ceca17df2ea50a250b6b27f7bf6ab0186f198.tar.gz"
}
},
"root": {
"inputs": {
"devshell": "devshell",
"clj-nix": "clj-nix",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs_2"
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1763865987,
"narHash": "sha256-DJpzM8Jz3B0azJcAoF+YFHr8rEbxYLJ0wy1kWZ29HOw=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "042d905c01a6eec3bcae8530dacb19cda9758a63",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {

419
flake.nix
View File

@ -1,150 +1,329 @@
{
description = "Svelte F1 Guessgame";
rec {
description = "Formula11";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
inputs.flake-utils.url = "github:numtide/flake-utils";
inputs.devshell.url = "github:numtide/devshell";
inputs = {
nixpkgs.url = "nixpkgs"; # Use nixpkgs from system registry
flake-utils.url = "github:numtide/flake-utils";
rust-overlay.url = "github:oxalica/rust-overlay";
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
clj-nix.url = "github:jlesquembre/clj-nix";
clj-nix.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = {
self,
nixpkgs,
flake-utils,
devshell,
rust-overlay,
clj-nix,
}:
# Create a shell (and possibly package) for each possible system, not only x86_64-linux
flake-utils.lib.eachDefaultSystem (system: let
pkgs = import nixpkgs {
inherit system;
config.allowUnfree = true;
overlays = [devshell.overlays.default];
};
timple = pkgs.python312Packages.buildPythonPackage rec {
pname = "timple";
version = "0.1.8";
src = pkgs.python312Packages.fetchPypi {
inherit pname version;
hash = "sha256-u8EgMA8BA6OpPlSg0ASRxLcIcv5psRIEcBpIicagXw8=";
};
doCheck = false;
pyproject = true;
# Build time deps
nativeBuildInputs = with pkgs.python312Packages; [
setuptools
];
# Run time deps
dependencies = with pkgs.python312Packages; [
matplotlib
numpy
overlays = [
rust-overlay.overlays.default
];
};
inherit (pkgs) lib stdenv;
fastf1 = pkgs.python312Packages.buildPythonPackage rec {
pname = "fastf1";
version = "3.4.4";
# ===========================================================================================
# Define custom dependencies
# ===========================================================================================
src = pkgs.python312Packages.fetchPypi {
inherit pname version;
hash = "sha256-nELQtvzlLsUYyVaPe1KqvMmzHy5l5W7u1I6m8r8md/4=";
};
# Python package example
# typed-ffmpeg = pkgs.python313Packages.buildPythonPackage rec {
# pname = "typed_ffmpeg";
# version = "3.6";
#
# src = pkgs.python313Packages.fetchPypi {
# inherit pname version;
# hash = "sha256-YPspq/lqI/jx/9FCQntmQPw4lrPIsdxtHTUg0F0QbrM=";
# };
#
# pyproject = true;
# build-system = [
# pkgs.python313Packages.setuptools
# pkgs.python313Packages.setuptools-scm
# ];
# };
doCheck = false;
pyproject = true;
# python = pkgs.python313.withPackages (p:
# with p; [
# # numpy
# # matplotlib
# # typed-ffmpeg
# # pyside6
# ]);
# Build time deps
nativeBuildInputs = with pkgs.python312Packages; [
hatchling
hatch-vcs
];
# rust = pkgs.rust-bin.stable.latest.default.override {
# extensions = ["rust-src"]; # Include the Rust stdlib source (for IntelliJ)
# };
# Run time deps
dependencies = with pkgs.python312Packages; [
matplotlib
numpy
pandas
python-dateutil
requests
requests-cache
scipy
rapidfuzz
websockets
timple
];
};
# 64 bit C/C++ compilers that don't collide (use the same libc)
# bintools = pkgs.wrapBintoolsWith {
# bintools = pkgs.bintools.bintools; # Unwrapped bintools
# libc = pkgs.glibc;
# };
# gcc = pkgs.hiPrio (pkgs.wrapCCWith {
# cc = pkgs.gcc.cc; # Unwrapped gcc
# libc = pkgs.glibc;
# bintools = bintools;
# });
# clang = pkgs.wrapCCWith {
# cc = pkgs.clang.cc; # Unwrapped clang
# libc = pkgs.glibc;
# bintools = bintools;
# };
f1python = pkgs.python312.withPackages (p:
with p; [
# Basic
rich
# Multilib C/C++ compilers that don't collide (use the same libc)
# bintools_multilib = pkgs.wrapBintoolsWith {
# bintools = pkgs.bintools.bintools; # Unwrapped bintools
# libc = pkgs.glibc_multi;
# };
# gcc_multilib = pkgs.hiPrio (pkgs.wrapCCWith {
# cc = pkgs.gcc.cc; # Unwrapped gcc
# libc = pkgs.glibc_multi;
# bintools = bintools_multilib;
# });
# clang_multilib = pkgs.wrapCCWith {
# cc = pkgs.clang.cc; # Unwrapped clang
# libc = pkgs.glibc_multi;
# bintools = bintools_multilib;
# };
# Web
flask
flask-sqlalchemy
flask-caching
sqlalchemy
# ===========================================================================================
# Specify dependencies
# https://nixos.org/manual/nixpkgs/stable/#ssec-stdenv-dependencies-overview
# Just for a "nix develop" shell, buildInputs can be used for everything.
# ===========================================================================================
# Test
pytest
# Add dependencies to nativeBuildInputs if they are executed during the build:
# - Those which are needed on $PATH during the build, for example cmake and pkg-config
# - Setup hooks, for example makeWrapper
# - Interpreters needed by patchShebangs for build scripts (with the --build flag), which can be the case for e.g. perl
nativeBuildInputs = with pkgs; [
nodejs_24
pocketbase
# TODO: For some reason, listing those under fastf1.dependencies doesn't work???
matplotlib
numpy
pandas
python-dateutil
requests
requests-cache
scipy
rapidfuzz
websockets
timple
sqlite # For sqlite console
sqlitebrowser # To check low-level pocketbase data
fastf1
]);
in {
devShell = pkgs.devshell.mkShell {
name = "Formula11";
# Languages:
# python
# rust
# bintools
# gcc
# clang
# bintools_multilib
# gcc_multilib
# clang_multilib
# clojure
# jdk
packages = with pkgs; [
nodejs_23
pocketbase
# C/C++:
# gdb
# valgrind
# gnumake
# cmake
# pkg-config
# nodePackages.autoprefixer
# nodePackages.postcss
# nodePackages.postcss-cli
# nodePackages.sass
# nodePackages.svelte-check
# nodePackages.tailwindcss
# Clojure:
# leiningen
# clj-nix.packages.${system}.deps-lock
# f1python
# sqlitebrowser
];
# Java:
# gradle
# Use $1 for positional args
commands = [
{
name = "pb";
help = "Serve PocketBase";
command = "pocketbase serve --http 192.168.86.50:8090";
}
{
name = "dev";
help = "Serve Formula 11 (Dev)";
command = "npm run dev -- --host --port 5173";
}
{
name = "prod";
help = "Serve Formula 11 (Prod)";
command = "npm run build && npm run preview -- --host --port 5173";
}
{
name = "check";
help = "Continuously monitor for SvelteKit issues";
command = "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch";
}
];
# Python:
# hatch
# py-spy
# Qt:
# qt6.wrapQtAppsHook # For the shellHook
];
# Add dependencies to buildInputs if they will end up copied or linked into the final output or otherwise used at runtime:
# - Libraries used by compilers, for example zlib
# - Interpreters needed by patchShebangs for scripts which are installed, which can be the case for e.g. perl
buildInputs = with pkgs; [
# C/C++:
# boost
# sfml
# Qt:
# qt6.qtbase
# qt6.full
];
# ===========================================================================================
# Define buildable + installable packages
# ===========================================================================================
# package = stdenv.mkDerivation {
# inherit nativeBuildInputs buildInputs;
# pname = "";
# version = "1.0.0";
# src = ./.;
#
# installPhase = ''
# mkdir -p $out/bin
# mv ./BINARY $out/bin
# '';
# };
# package = clj-nix.lib.mkCljApp {
# inherit pkgs;
# modules = [
# # Option list: https://jlesquembre.github.io/clj-nix/options/
# {
# name = "";
# version = "1.0.0";
# main-ns = "";
# projectSrc = ./.;
# withLeiningen = true;
# buildCommand = "lein uberjar"; # Requires "withLeiningen = true;"
# jdk = pkgs.jdk; # Default is pkgs.jdk_headless
# # customJdk.enable = true;
# # nativeImage.enable = true;
# }
# ];
# };
in rec {
# Provide package for "nix build"
# defaultPackage = package;
# defaultApp = flake-utils.lib.mkApp {
# drv = defaultPackage;
# };
# Provide environment for "nix develop"
devShell = pkgs.mkShell {
inherit nativeBuildInputs buildInputs;
name = description;
# =========================================================================================
# Define environment variables
# =========================================================================================
# Rust stdlib source:
# RUST_SRC_PATH = "${rust}/lib/rustlib/src/rust/library";
# Custom dynamic libraries:
# LD_LIBRARY_PATH = builtins.concatStringsSep ":" [
# # Rust Bevy GUI app:
# # "${pkgs.xorg.libX11}/lib"
# # "${pkgs.xorg.libXcursor}/lib"
# # "${pkgs.xorg.libXrandr}/lib"
# # "${pkgs.xorg.libXi}/lib"
# # "${pkgs.libGL}/lib"
#
# # JavaFX app:
# # "${pkgs.libGL}/lib"
# # "${pkgs.gtk3}/lib"
# # "${pkgs.glib.out}/lib"
# # "${pkgs.xorg.libXtst}/lib"
# ];
# Dynamic libraries from buildinputs:
# LD_LIBRARY_PATH = nixpkgs.lib.makeLibraryPath buildInputs;
# QT imports to use with "qmlls -E"
# QML_IMPORT_PATH = "${pkgs.qt6.full}/lib/qt-6/qml";
# Set PYTHONPATH
# PYTHONPATH = ".";
# Set matplotlib backend
# MPLBACKEND = "TkAgg";
# =========================================================================================
# Define shell environment
# =========================================================================================
# Setup the shell when entering the "nix develop" environment (bash script).
shellHook = let
mkCmakeScript = type: let
typeLower = lib.toLower type;
in
pkgs.writers.writeFish "cmake-${typeLower}.fish" ''
cd $FLAKE_PROJECT_ROOT
echo "Removing build directory ./cmake-build-${typeLower}/"
rm -rf ./cmake-build-${typeLower}
echo "Creating build directory"
mkdir cmake-build-${typeLower}
cd cmake-build-${typeLower}
echo "Running cmake"
cmake -G "Unix Makefiles" -DCMAKE_BUILD_TYPE="${type}" -DCMAKE_EXPORT_COMPILE_COMMANDS="On" ..
echo "Linking compile_commands.json"
cd ..
ln -sf ./cmake-build-${typeLower}/compile_commands.json ./compile_commands.json
'';
cmakeDebug = mkCmakeScript "Debug";
cmakeRelease = mkCmakeScript "Release";
mkBuildScript = type: let
typeLower = lib.toLower type;
in
pkgs.writers.writeFish "cmake-build.fish" ''
cd $FLAKE_PROJECT_ROOT/cmake-build-${typeLower}
echo "Running cmake"
cmake --build .
'';
buildDebug = mkBuildScript "Debug";
buildRelease = mkBuildScript "Release";
# Use this to specify commands that should be ran after entering fish shell
initProjectShell = pkgs.writers.writeFish "init-shell.fish" ''
echo "Entering \"${description}\" environment..."
# Determine the project root, used e.g. in cmake scripts
set -g -x FLAKE_PROJECT_ROOT (git rev-parse --show-toplevel)
# Rust Bevy:
# abbr -a build-release-windows "CARGO_FEATURE_PURE=1 cargo xwin build --release --target x86_64-pc-windows-msvc"
# C/C++:
# abbr -a cmake-debug "${cmakeDebug}"
# abbr -a cmake-release "${cmakeRelease}"
# abbr -a build-debug "${buildDebug}"
# abbr -a build-release "${buildRelease}"
# Clojure:
# abbr -a clojure-deps "deps-lock --lein"
# Python:
# abbr -a run "python ./app/main.py"
# abbr -a profile "py-spy record -o profile.svg -- python ./app/main.py && firefox profile.svg"
# abbr -a ptop "py-spy top -- python ./app/main.py"
# Formula11:
abbr -a pb "pocketbase serve --http 192.168.86.50:8090 --dev"
abbr -a dev "npm run dev -- --host --port 5173"
abbr -a prod "npm run build && npm run preview -- --host --port 5173"
abbr -a check "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
'';
in
builtins.concatStringsSep "\n" [
# Launch into pure fish shell
''
exec "$(type -p fish)" -C "source ${initProjectShell} && abbr -a menu '${pkgs.bat}/bin/bat "${initProjectShell}"'"
''
# Qt: Launch into wrapped fish shell
# https://nixos.org/manual/nixpkgs/stable/#sec-language-qt
# ''
# fishdir=$(mktemp -d)
# makeWrapper "$(type -p fish)" "$fishdir/fish" "''${qtWrapperArgs[@]}"
# exec "$fishdir/fish" -C "source ${initProjectShell} && abbr -a menu '${pkgs.bat}/bin/bat "${initProjectShell}"'"
# ''
];
};
});
}

1930
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,30 +10,31 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@carbon/charts-svelte": "^1.22.18",
"@floating-ui/dom": "^1.6.13",
"@fsouza/prettierd": "^0.25.4",
"@fsouza/prettierd": "^0.26.1",
"@skeletonlabs/skeleton": "^2.10.4",
"@skeletonlabs/tw-plugin": "^0.4.0",
"@sveltejs/adapter-auto": "^3.3.1",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.16.1",
"@sveltejs/kit": "^2.19.1",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/forms": "^0.5.10",
"@types/node": "^22.10.10",
"@types/uuid": "^10.0.0",
"autoprefixer": "^10.4.20",
"@types/node": "^22.13.10",
"autoprefixer": "^10.4.21",
"cheerio": "^1.0.0",
"date-fns": "^4.1.0",
"pocketbase": "^0.25.1",
"postcss": "^8.5.1",
"prettier": "^3.4.2",
"pocketbase": "^0.25.2",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"svelte": "^5.19.3",
"svelte-check": "^4.1.4",
"runes2": "^1.1.4",
"svelte": "^5.23.0",
"svelte-check": "^4.1.5",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"uuid": "^11.0.5",
"vite": "^6.0.11"
"typescript": "^5.8.2",
"uuid": "^11.1.0",
"vite": "^6.2.2"
},
"dependencies": {
"sharp": "^0.33.5"

2948
pb_schema.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
# because fucking Docker thinks "toomanyrequests". Fuckers
FROM docker.io/library/alpine:3.21.2
ARG PB_VERSION=0.25.0
ARG PB_VERSION=0.33.0
RUN apk add --no-cache \
unzip \

7
src/app.d.ts vendored
View File

@ -5,12 +5,7 @@ import type { PocketBase, RecordModel } from "pocketbase";
// for information about these interfaces
declare global {
namespace App {
interface Locals {
pb: PocketBase;
user: User | undefined;
admin: boolean;
}
// interface Locals {}
// interface Error {}
// interface PageData {}
// interface PageState {}

View File

@ -18,6 +18,9 @@
<!-- Prefetch data specified in "load" functions on link hover -->
<body data-theme="formula11Theme" data-sveltekit-preload-data="hover">
<!-- SvelteKit inserts the body contents here -->
<div style="display: contents; user-select: none">%sveltekit.body%</div>
<!-- TODO: The fucking user-select: none doesn't work in firefox -->
<div style="display: contents; user-select: none; -moz-user-select: -moz-none">
%sveltekit.body%
</div>
</body>
</html>

13
src/hooks.client.ts Normal file
View File

@ -0,0 +1,13 @@
import { refresh_auth } from "$lib/pocketbase";
import type { ClientInit } from "@sveltejs/kit";
export const init: ClientInit = async () => {
// Try to refresh the authStore. This is required when e.g.
// changing e-mail address. The new e-mail will show up after
// being verified, so the authStore has to be reloaded.
try {
await refresh_auth();
} catch (error) {
console.log("hooks.client.ts:", error);
}
};

View File

@ -1,76 +0,0 @@
import type { Graphic, User } from "$lib/schema";
import type { Handle } from "@sveltejs/kit";
import { env } from "$env/dynamic/private";
import PocketBase from "pocketbase";
// This function will run serverside on each request.
// The event.locals will be passed onto serverside load functions and handlers.
// We create a new PocketBase client for each request, so it always carries the
// most recent authentication data.
// The authenticated PocketBase client will be available in all *.server.ts files.
export const handle: Handle = async ({ event, resolve }) => {
const requestStartTime: number = Date.now();
// If env variables are defined (e.g. in the prod environment), use those.
// Otherwise use the default local development IP:Port.
// Because we imported "$env/dynamic/private",
// the variables will only be available to the server (e.g. .server.ts files).
let pb_url: string = "http://192.168.86.50:8090";
if (env.PB_PROTOCOL && env.PB_HOST && env.PB_PORT) {
pb_url = `${env.PB_PROTOCOL}://${env.PB_HOST}:${env.PB_PORT}`;
}
if (env.PB_PROTOCOL && env.PB_URL) {
pb_url = `${env.PB_PROTOCOL}://${env.PB_URL}`;
}
event.locals.pb = new PocketBase(pb_url);
// Load the most recent authentication data from a cookie (is updated below)
event.locals.pb.authStore.loadFromCookie(event.request.headers.get("cookie") || "");
if (event.locals.pb.authStore.isValid) {
// If the authentication data is valid, we make a "user" object easily available.
event.locals.user = structuredClone(event.locals.pb.authStore.model) as User;
if (event.locals.user) {
if (event.locals.pb.authStore.model.avatar) {
// Fill in the avatar URL
event.locals.user.avatar_url = event.locals.pb.files.getURL(
event.locals.pb.authStore.model,
event.locals.pb.authStore.model.avatar,
);
} else {
// Fill in the driver_headshot_template URL if no avatar chosen
const driver_headshot_template: Graphic = await event.locals.pb
.collection("graphics")
.getFirstListItem('name="driver_headshot_template"');
event.locals.user.avatar_url = event.locals.pb.files.getURL(
driver_headshot_template,
driver_headshot_template.file,
);
}
// Set admin status for easier access
event.locals.admin = event.locals.user.admin;
}
} else {
event.locals.user = undefined;
}
// Resolve the request. This is what happens by default.
const response = await resolve(event);
console.log(
"=====\n",
`Request Date: ${new Date(requestStartTime).toISOString()}\n`,
`Method: ${event.request.method}\n`,
`Path: ${event.url.pathname}\n`,
`Duration: ${Date.now() - requestStartTime}ms\n`,
`Status: ${response.status}`,
);
// Store the current authentication data to a cookie, so it can be loaded above.
response.headers.set("set-cookie", event.locals.pb.authStore.exportToCookie({ secure: false }));
return response;
};

View File

@ -1,10 +0,0 @@
import type { Reroute } from "@sveltejs/kit";
const rerouted: Record<string, string> = {};
// NOTE: This does not change the browser's address bar (the route path)!
export const reroute: Reroute = ({ url }) => {
if (url.pathname in rerouted) {
return rerouted[url.pathname];
}
};

60
src/lib/chart.ts Normal file
View File

@ -0,0 +1,60 @@
import { type LineChartOptions, ScaleTypes } from "@carbon/charts-svelte";
export const make_chart_options = (
title: string,
bottom: string,
left: string,
group: string = "group",
width: string = "100%",
height: string = "400px",
): LineChartOptions => {
return {
title: title,
axes: {
bottom: {
mapsTo: bottom,
scaleType: ScaleTypes.LABELS,
},
left: {
mapsTo: left,
scaleType: ScaleTypes.LINEAR,
},
},
data: {
groupMapsTo: group,
},
curve: "curveMonotoneX",
// toolbar: {
// enabled: false,
// },
animations: true,
// canvasZoom: {
// enabled: false,
// },
grid: {
x: {
enabled: true,
alignWithAxisTicks: true,
},
y: {
enabled: true,
alignWithAxisTicks: true,
},
},
legend: {
enabled: true,
clickable: true,
position: "top",
},
points: {
enabled: true,
radius: 5,
},
tooltip: {
showTotal: false,
},
resizable: true,
width: width,
height: height,
};
};

View File

@ -1,10 +1,11 @@
<script lang="ts">
interface CountdownProps {
date: string;
gotext?: string;
extraclass?: string;
}
let { date, extraclass = "" }: CountdownProps = $props();
let { date, gotext = "Go Go Go", extraclass = "" }: CountdownProps = $props();
// Set the date we're counting down to
const countDownDate = new Date(date).getTime();
@ -35,6 +36,6 @@
{#if distance > 0}
{days + "d " + hours + "h " + minutes + "m "}
{:else}
GO GO GO GO
{gotext}
{/if}
</span>

View File

@ -2,6 +2,8 @@
import type { HTMLImgAttributes } from "svelte/elements";
import { lazyload } from "$lib/lazyload";
import { fetch_image_base64 } from "$lib/image";
import { popup, type PopupSettings } from "@skeletonlabs/skeleton";
import { v4 as uuidv4 } from "uuid";
interface LazyImageProps extends HTMLImgAttributes {
/** The URL to the image resource to lazyload */
@ -19,11 +21,14 @@
/** Optional extra style for the lazy <div> container */
containerstyle?: string;
/** Additional classes to insert */
/** Additional classes to insert for container + image */
imgclass?: string;
/** Slightly zoom the image on mouse-hover */
hoverzoom?: boolean;
/** Optional tooltip text */
tooltip?: string;
}
let {
@ -34,6 +39,7 @@
containerstyle = undefined,
imgclass = "",
hoverzoom = false,
tooltip = undefined,
...restProps
}: LazyImageProps = $props();
@ -49,15 +55,23 @@
const img_opacity_handler = (node: HTMLElement) => {
setTimeout(() => (node.style.opacity = "1"), 20);
};
// Tooltip handling
const tooltipId: string = uuidv4();
const popupTooltip: PopupSettings = {
event: "hover",
target: tooltipId,
placement: "top",
};
</script>
<!-- Show a correctly sized div so the layout doesn't jump. -->
<div
use:lazyload
onLazyVisible={lazy_visible_handler}
class="overflow-hidden"
style="aspect-ratio: {imgwidth} / {imgheight}; {containerstyle ??
''}; max-width: {imgwidth}px; max-height: {imgheight}px"
class="overflow-hidden {imgclass}"
style="aspect-ratio: {imgwidth} / {imgheight}; max-width: {imgwidth}px; max-height: {imgheight}px; {containerstyle ??
''}"
>
{#if load}
{#await fetch_image_base64(src) then data}
@ -67,8 +81,16 @@
class="bg-surface-100 transition-all {imgclass} {hoverzoom ? 'hover:scale-105' : ''}"
style="opacity: 0; transition-duration: 300ms; {imgstyle ?? ''}"
draggable="false"
use:popup={popupTooltip}
{...restProps}
/>
{/await}
{/if}
</div>
{#if tooltip}
<div class="card variant-filled-surface p-2 shadow" data-popup={tooltipId}>
<p>{tooltip}</p>
<div class="variant-filled-surface arrow"></div>
</div>
{/if}

View File

@ -8,17 +8,20 @@
/** The columns the table should have. */
columns: TableColumn[];
/** Optional height classes */
height?: string;
/** An optional function handling clicking on a table row */
handler?: (event: Event, id: string) => Promise<void>;
}
let { data, columns, handler = undefined }: TableProps = $props();
let { data, columns, height = "", handler = undefined }: TableProps = $props();
</script>
{#if data.length > 0}
<div class="table-container bg-white shadow">
<table class="table table-compact bg-white">
<thead>
<div class="table-container bg-white shadow {height}">
<table class="table table-compact !overflow-scroll bg-white">
<thead class="sticky top-0">
<tr class="bg-surface-500">
{#each columns as col}
<th class="!px-3">{col.label}</th>

View File

@ -3,185 +3,231 @@
import {
FileDropzone,
getModalStore,
getToastStore,
SlideToggle,
type ModalStore,
type ToastStore,
} from "@skeletonlabs/skeleton";
import { Button, Input, Card, Dropdown } from "$lib/components";
import type { Driver, Team } from "$lib/schema";
import type { Driver } from "$lib/schema";
import { DRIVER_HEADSHOT_HEIGHT, DRIVER_HEADSHOT_WIDTH } from "$lib/config";
import { team_dropdown_options } from "$lib/dropdown";
import { get_driver_headshot_template } from "$lib/database";
import { get_error_toast } from "$lib/toast";
import { pb, pbUser } from "$lib/pocketbase";
import { error } from "@sveltejs/kit";
import type { PageData } from "../../../routes/data/season/drivers/$types";
interface DriverCardProps {
/** Data passed from the page context */
data: PageData;
/** The [Driver] object used to prefill values. */
driver?: Driver | undefined;
/** The teams (for the dropdown options) */
teams: Team[];
/** Disable all inputs if [true] */
disable_inputs?: boolean;
/** Require all inputs if [true] */
require_inputs?: boolean;
/** The [src] of the driver headshot template preview */
headshot_template?: string;
/** The value this component's team select dropdown will bind to */
// TODO: Move this into this component? Why am I passing it from the outside?
// This also applies to the other card components...
team_select_value: string;
/** The value this component's active switch will bind to */
active_value: boolean;
driver?: Driver;
}
let {
driver = undefined,
teams,
disable_inputs = false,
require_inputs = false,
headshot_template = undefined,
team_select_value,
active_value,
}: DriverCardProps = $props();
let { data, driver = undefined }: DriverCardProps = $props();
const modalStore: ModalStore = getModalStore();
if ($modalStore[0].meta) {
const meta = $modalStore[0].meta;
// Stuff thats required for the "update" card
data = meta.data;
driver = meta.driver;
teams = meta.teams;
team_select_value = meta.team_select_value;
active_value = meta.active_value;
disable_inputs = meta.disable_inputs;
// Stuff thats additionally required for the "create" card
require_inputs = meta.require_inputs;
headshot_template = meta.headshot_template;
}
const toastStore: ToastStore = getToastStore();
// Constants
const labelwidth: string = "120px";
// Reactive state
let required: boolean = $derived(!driver);
let disabled: boolean = $derived(!$pbUser?.admin);
let firstname_input_value: string = $state(driver?.firstname ?? "");
let lastname_input_value: string = $state(driver?.lastname ?? "");
let code_input_value: string = $state(driver?.code ?? "");
let team_select_value: string = $state(driver?.team ?? "");
let headshot_file_value: FileList | undefined = $state();
let active_value: boolean = $state(driver?.active ?? true);
// Database actions
const update_driver = (create?: boolean): (() => Promise<void>) => {
const handler = async (): Promise<void> => {
if (!firstname_input_value || firstname_input_value === "") {
toastStore.trigger(get_error_toast("Please enter a first name!"));
return;
}
if (!lastname_input_value || lastname_input_value === "") {
toastStore.trigger(get_error_toast("Please enter a last name!"));
return;
}
if (!code_input_value || code_input_value === "") {
toastStore.trigger(get_error_toast("Please enter a driver code!"));
return;
}
if (!team_select_value || team_select_value === "") {
toastStore.trigger(get_error_toast("Please select a team!"));
return;
}
// Headshot handling
let headshot_avif: Blob | undefined = undefined;
const headshot_file: File | undefined =
headshot_file_value && headshot_file_value.length === 1
? headshot_file_value[0]
: undefined;
if (headshot_file) {
const headshot_formdata: FormData = new FormData();
headshot_formdata.append("image", headshot_file);
headshot_formdata.append("width", DRIVER_HEADSHOT_WIDTH.toString());
headshot_formdata.append("height", DRIVER_HEADSHOT_HEIGHT.toString());
try {
const response = await fetch("/api/compress", {
method: "POST",
body: headshot_formdata,
});
if (!response.ok) {
error(500, "Compression failed.");
}
headshot_avif = await response.blob();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
}
const driver_data = {
firstname: firstname_input_value,
lastname: lastname_input_value,
code: code_input_value,
team: team_select_value,
active: active_value,
headshot: headshot_avif,
};
try {
if (create) {
if (!headshot_avif) {
toastStore.trigger(get_error_toast("Please upload a single driver headshot!"));
return;
}
await pb.collection("drivers").create(driver_data);
} else {
if (!driver?.id) {
toastStore.trigger(get_error_toast("Invalid driver id!"));
return;
}
// TODO: Not sure if we want to switch teams without creating a new driver
if (team_select_value !== driver.team) {
toastStore.trigger(
get_error_toast("Please use the team switch button to change teams!"),
);
return;
}
await pb.collection("drivers").update(driver.id, driver_data);
}
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
return handler;
};
const delete_driver = async (): Promise<void> => {
if (!driver?.id) {
toastStore.trigger(get_error_toast("Invalid driver id!"));
return;
}
try {
await pb.collection("drivers").delete(driver.id);
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
</script>
<Card
imgsrc={driver?.headshot_url ?? headshot_template}
imgid="update_driver_headshot_preview_{driver?.id ?? 'create'}"
imgsrc={driver?.headshot_url ?? get_driver_headshot_template(data.graphics)}
imgid="headshot_preview"
width="w-full sm:w-auto"
imgwidth={DRIVER_HEADSHOT_WIDTH}
imgheight={DRIVER_HEADSHOT_HEIGHT}
imgonclick={(event: Event) => modalStore.close()}
>
<form method="POST" enctype="multipart/form-data">
<!-- This is also disabled, because the ID should only be -->
<!-- "leaked" to users that are allowed to use the inputs -->
{#if driver && !disable_inputs}
<input name="id" type="hidden" value={driver.id} />
{/if}
<div class="flex flex-col gap-2">
<!-- Driver name input -->
<Input bind:value={firstname_input_value} autocomplete="off" {labelwidth} {disabled} {required}>
First Name
</Input>
<Input bind:value={lastname_input_value} autocomplete="off" {labelwidth} {disabled} {required}>
Last Name
</Input>
<Input
bind:value={code_input_value}
autocomplete="off"
minlength={3}
maxlength={3}
{labelwidth}
{disabled}
{required}
>
Driver Code
</Input>
<div class="flex flex-col gap-2">
<!-- Driver name input -->
<Input
id="driver_first_name_{driver?.id ?? 'create'}"
name="firstname"
value={driver?.firstname ?? ""}
autocomplete="off"
labelwidth="120px"
disabled={disable_inputs}
required={require_inputs}
>First Name
</Input>
<Input
id="driver_last_name_{driver?.id ?? 'create'}"
name="lastname"
value={driver?.lastname ?? ""}
autocomplete="off"
labelwidth="120px"
disabled={disable_inputs}
required={require_inputs}
>Last Name
</Input>
<Input
id="driver_code_{driver?.id ?? 'create'}"
name="code"
value={driver?.code ?? ""}
autocomplete="off"
minlength={3}
maxlength={3}
labelwidth="120px"
disabled={disable_inputs}
required={require_inputs}
>Driver Code
</Input>
<!-- Driver team input -->
<!-- Driver team input -->
{#await data.teams then teams}
<Dropdown
name="team"
input_variable={team_select_value}
bind:value={team_select_value}
options={team_dropdown_options(teams)}
labelwidth="120px"
disabled={disable_inputs}
required={require_inputs}
{labelwidth}
{disabled}
{required}
>
Team
</Dropdown>
{/await}
<!-- Headshot upload -->
<FileDropzone
name="headshot"
id="driver_headshot_{driver?.id ?? 'create'}"
onchange={get_image_preview_event_handler(
`update_driver_headshot_preview_${driver?.id ?? "create"}`,
)}
disabled={disable_inputs}
required={require_inputs}
>
<svelte:fragment slot="message"
><span class="font-bold">Upload Headshot</span></svelte:fragment
>
</FileDropzone>
<!-- Headshot upload -->
<FileDropzone
name="headshot"
bind:files={headshot_file_value}
onchange={get_image_preview_event_handler("headshot_preview")}
{disabled}
{required}
>
<svelte:fragment slot="message">
<span class="font-bold">Upload Headshot</span>
</svelte:fragment>
</FileDropzone>
<!-- Save/Delete buttons -->
<div class="flex items-center justify-end gap-2">
<div class="mr-auto">
<SlideToggle
name="active"
background="bg-primary-500"
active="bg-tertiary-500"
bind:checked={active_value}
disabled={disable_inputs}
/>
</div>
{#if driver}
<Button
formaction="?/update_driver"
color="secondary"
disabled={disable_inputs}
submit
width="w-1/2"
>
Save
</Button>
<Button
color="primary"
submit
disabled={disable_inputs}
formaction="?/delete_driver"
width="w-1/2"
>
Delete
</Button>
{:else}
<Button
formaction="?/create_driver"
color="tertiary"
submit
width="w-full"
disabled={disable_inputs}
>
Create Driver
</Button>
{/if}
<!-- Save/Delete buttons -->
<div class="flex items-center justify-end gap-2">
<div class="mr-auto">
<SlideToggle
name="active"
background="bg-primary-500"
active="bg-tertiary-500"
bind:checked={active_value}
{disabled}
/>
</div>
{#if driver}
<Button onclick={update_driver()} color="secondary" {disabled} width="w-1/2">Save</Button>
<Button onclick={delete_driver} color="primary" {disabled} width="w-1/2">Delete</Button>
{:else}
<Button onclick={update_driver(true)} color="tertiary" {disabled} width="w-full">
Create Driver
</Button>
{/if}
</div>
</form>
</div>
</Card>

View File

@ -1,215 +1,300 @@
<script lang="ts">
import { get_image_preview_event_handler } from "$lib/image";
import { FileDropzone, getModalStore, type ModalStore } from "@skeletonlabs/skeleton";
import {
FileDropzone,
getModalStore,
getToastStore,
type ModalStore,
type ToastStore,
} from "@skeletonlabs/skeleton";
import { Button, Card, Input } from "$lib/components";
import type { Race } from "$lib/schema";
import { format } from "date-fns";
import { RACE_PICTOGRAM_HEIGHT, RACE_PICTOGRAM_WIDTH } from "$lib/config";
import { get_race_pictogram_template } from "$lib/database";
import { format_date, isodatetimeformat } from "$lib/date";
import { get_error_toast } from "$lib/toast";
import { pb, pbUser } from "$lib/pocketbase";
import { error } from "@sveltejs/kit";
import type { PageData } from "../../../routes/data/season/races/$types";
interface RaceCardProps {
/** Data passed from the page context */
data: PageData;
/** The [Race] object used to prefill values. */
race?: Race | undefined;
/** Disable all inputs if [true] */
disable_inputs?: boolean;
/** Require all inputs if [true] */
require_inputs?: boolean;
/** The [src] of the race pictogram template preview */
pictogram_template?: string;
race?: Race;
}
let {
race = undefined,
disable_inputs = false,
require_inputs = false,
pictogram_template = "",
}: RaceCardProps = $props();
let { data, race = undefined }: RaceCardProps = $props();
const modalStore: ModalStore = getModalStore();
if ($modalStore[0].meta) {
const meta = $modalStore[0].meta;
// Stuff thats required for the "update" card
data = meta.data;
race = meta.race;
disable_inputs = meta.disable_inputs;
// Stuff thats additionally required for the "create" card
require_inputs = meta.require_inputs;
pictogram_template = meta.pictogram_template;
}
// Dates have to be formatted to datetime-local format
const dateformat: string = "yyyy-MM-dd'T'HH:mm";
const sprintqualidate: string | undefined =
race && race.sprintqualidate ? format(new Date(race.sprintqualidate), dateformat) : undefined;
const sprintdate: string | undefined =
race && race.sprintdate ? format(new Date(race.sprintdate), dateformat) : undefined;
const qualidate: string | undefined = race
? format(new Date(race.qualidate), dateformat)
: undefined;
const racedate: string | undefined = race
? format(new Date(race.racedate), dateformat)
: undefined;
const toastStore: ToastStore = getToastStore();
// Constants
const labelwidth = "85px";
const clear_sprint = () => {
const sprintquali: HTMLInputElement = document.getElementById(
`race_sprintqualidate_${race?.id ?? "create"}`,
) as HTMLInputElement;
const sprint: HTMLInputElement = document.getElementById(
`race_sprintdate_${race?.id ?? "create"}`,
) as HTMLInputElement;
(document.getElementById("sqdate") as HTMLInputElement).value = "";
(document.getElementById("sdate") as HTMLInputElement).value = "";
sprintquali.value = "";
sprint.value = "";
sprintqualidate_value = "";
sprintdate_value = "";
};
const labelwidth = "80px";
// Reactive state
let required: boolean = $derived(!race);
let disabled: boolean = $derived(!$pbUser?.admin);
let name_value: string = $state(race?.name ?? "");
let step_value: string = $state(race?.step.toString() ?? "");
let pxx_value: string = $state(race?.pxx.toString() ?? "");
let sprintqualidate_value: string = $state("");
let sprintdate_value: string = $state("");
let qualidate_value: string = $state("");
let racedate_value: string = $state("");
let pictogram_value: FileList | undefined = $state();
// Set the initial values if we've clicked on an existing race
// PocketBase stores in UTC, so resulting values will be offset by 1h here
if (race) {
if (race.sprintqualidate && race.sprintdate) {
sprintqualidate_value = format_date(race.sprintqualidate, isodatetimeformat);
sprintdate_value = format_date(race.sprintdate, isodatetimeformat);
}
qualidate_value = format_date(race.qualidate, isodatetimeformat);
racedate_value = format_date(race.racedate, isodatetimeformat);
}
// Database actions
const update_race = (create?: boolean): (() => Promise<void>) => {
const handler = async (): Promise<void> => {
if (!name_value || name_value === "") {
toastStore.trigger(get_error_toast("Please enter a name!"));
return;
}
if (!step_value || step_value === "") {
toastStore.trigger(get_error_toast("Please enter a step!"));
return;
}
if (!pxx_value || pxx_value === "") {
toastStore.trigger(get_error_toast("Please enter a place to guess (PXX)!"));
return;
}
if (!qualidate_value || qualidate_value === "") {
toastStore.trigger(get_error_toast("Please enter a qualifying date!"));
return;
}
if (!racedate_value || racedate_value === "") {
toastStore.trigger(get_error_toast("Please enter a race date!"));
return;
}
// Pictogram handling
let pictogram_avif: Blob | undefined = undefined;
const pictogram_file: File | undefined =
pictogram_value && pictogram_value.length === 1 ? pictogram_value[0] : undefined;
if (pictogram_file) {
const pictogram_formdata: FormData = new FormData();
pictogram_formdata.append("image", pictogram_file);
pictogram_formdata.append("width", RACE_PICTOGRAM_WIDTH.toString());
pictogram_formdata.append("height", RACE_PICTOGRAM_HEIGHT.toString());
try {
const response = await fetch("/api/compress", {
method: "POST",
body: pictogram_formdata,
});
if (!response.ok) {
error(500, "Compression failed.");
}
pictogram_avif = await response.blob();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
}
// Use toISOString here, as we want to convert from localtime to UTC, which PocketBase uses
const race_data = {
name: name_value,
step: step_value,
pxx: pxx_value,
sprintqualidate:
sprintqualidate_value && sprintqualidate_value !== ""
? new Date(sprintqualidate_value).toISOString()
: null,
sprintdate:
sprintdate_value && sprintdate_value != ""
? new Date(sprintdate_value).toISOString()
: null,
qualidate: new Date(qualidate_value).toISOString(),
racedate: new Date(racedate_value).toISOString(),
pictogram: pictogram_avif,
};
try {
if (create) {
if (!pictogram_avif) {
toastStore.trigger(get_error_toast("Please upload a single pictogram!"));
return;
}
await pb.collection("races").create(race_data);
} else {
if (!race?.id) {
toastStore.trigger(get_error_toast("Invalid race id!"));
return;
}
await pb.collection("races").update(race.id, race_data);
}
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
return handler;
};
const delete_race = async (): Promise<void> => {
if (!race?.id) {
toastStore.trigger(get_error_toast("Invalid race id!"));
return;
}
try {
await pb.collection("races").delete(race.id);
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
</script>
<Card
imgsrc={race?.pictogram_url ?? pictogram_template}
imgid="update_race_pictogram_preview_{race?.id ?? 'create'}"
imgsrc={race?.pictogram_url ?? get_race_pictogram_template(data.graphics)}
imgid="pictogram_preview"
width="w-full sm:w-auto"
imgwidth={RACE_PICTOGRAM_WIDTH}
imgheight={RACE_PICTOGRAM_HEIGHT}
imgonclick={(event: Event) => modalStore.close()}
>
<form method="POST" enctype="multipart/form-data">
<!-- This is also disabled, because the ID should only be -->
<!-- "leaked" to users that are allowed to use the inputs -->
{#if race && !disable_inputs}
<input name="id" type="hidden" value={race.id} />
{/if}
<div class="flex flex-col gap-2">
<!-- Race name input -->
<Input
bind:value={name_value}
autocomplete="off"
placeholder="Has to end with an emoji"
{labelwidth}
{disabled}
{required}
>
Name
</Input>
<Input
bind:value={step_value}
autocomplete="off"
placeholder="The step in the race calendar"
type="number"
min={1}
max={24}
{labelwidth}
{disabled}
{required}
>
Step
</Input>
<Input
bind:value={pxx_value}
autocomplete="off"
placeholder="The place to guess"
type="number"
min={1}
max={20}
{labelwidth}
{disabled}
{required}
>
PXX
</Input>
<div class="flex flex-col gap-2">
<!-- Driver name input -->
<Input
id="race_name_{race?.id ?? 'create'}"
name="name"
value={race?.name ?? ""}
autocomplete="off"
{labelwidth}
disabled={disable_inputs}
required={require_inputs}>Name</Input
>
<Input
id="race_step_{race?.id ?? 'create'}"
name="step"
value={race?.step ?? ""}
autocomplete="off"
{labelwidth}
type="number"
min={1}
max={24}
disabled={disable_inputs}
required={require_inputs}>Step</Input
>
<Input
id="race_pxx_{race?.id ?? 'create'}"
name="pxx"
value={race?.pxx ?? ""}
autocomplete="off"
{labelwidth}
type="number"
min={1}
max={20}
disabled={disable_inputs}
required={require_inputs}>PXX</Input
>
<!-- NOTE: Input datetime-local accepts YYYY-mm-ddTHH:MM format -->
<Input
id="sqdate"
type="datetime-local"
bind:value={sprintqualidate_value}
autocomplete="off"
{labelwidth}
{disabled}
>
SQuali
</Input>
<Input
id="sdate"
type="datetime-local"
bind:value={sprintdate_value}
autocomplete="off"
{labelwidth}
{disabled}
>
SRace
</Input>
<Input
type="datetime-local"
bind:value={qualidate_value}
autocomplete="off"
{labelwidth}
{disabled}
{required}
>
Quali
</Input>
<Input
type="datetime-local"
bind:value={racedate_value}
autocomplete="off"
{labelwidth}
{disabled}
{required}
>
Race
</Input>
<!-- NOTE: Input datetime-local accepts YYYY-mm-ddTHH:MM format -->
<Input
id="race_sprintqualidate_{race?.id ?? 'create'}"
name="sprintqualidate"
value={sprintqualidate ?? ""}
autocomplete="off"
{labelwidth}
type="datetime-local"
disabled={disable_inputs}>SQuali</Input
>
<Input
id="race_sprintdate_{race?.id ?? 'create'}"
name="sprintdate"
value={sprintdate ?? ""}
autocomplete="off"
{labelwidth}
type="datetime-local"
disabled={disable_inputs}>SRace</Input
>
<Input
id="race_qualidate_{race?.id ?? 'create'}"
name="qualidate"
value={qualidate ?? ""}
autocomplete="off"
{labelwidth}
type="datetime-local"
disabled={disable_inputs}
required={require_inputs}>Quali</Input
>
<Input
id="race_racedate_{race?.id ?? 'create'}"
name="racedate"
value={racedate ?? ""}
autocomplete="off"
{labelwidth}
type="datetime-local"
disabled={disable_inputs}
required={require_inputs}>Race</Input
>
<!-- Headshot upload -->
<FileDropzone
name="pictogram"
onchange={get_image_preview_event_handler("pictogram_preview")}
bind:files={pictogram_value}
{disabled}
{required}
>
<svelte:fragment slot="message">
<span class="font-bold">Upload Pictogram</span>
</svelte:fragment>
</FileDropzone>
<!-- Headshot upload -->
<FileDropzone
name="pictogram"
id="race_pictogram_{race?.id ?? 'create'}"
onchange={get_image_preview_event_handler(
`update_race_pictogram_preview_${race?.id ?? "create"}`,
)}
disabled={disable_inputs}
required={require_inputs}
>
<svelte:fragment slot="message"
><span class="font-bold">Upload Pictogram</span></svelte:fragment
>
</FileDropzone>
<!-- Save/Delete buttons -->
<div class="flex justify-end gap-2">
<Button onclick={clear_sprint} color="secondary" disabled={disable_inputs} width="w-1/3">
Remove Sprint
<!-- Save/Delete buttons -->
<div class="flex justify-end gap-2">
<Button onclick={clear_sprint} color="secondary" {disabled} width={race ? "w-1/3" : "w-1/2"}>
Remove Sprint
</Button>
{#if race}
<Button onclick={update_race()} color="secondary" {disabled} width="w-1/3">
Save Changes
</Button>
{#if race}
<Button
formaction="?/update_race"
color="secondary"
disabled={disable_inputs}
submit
width="w-1/3"
>
Save Changes
</Button>
<Button
color="primary"
submit
disabled={disable_inputs}
formaction="?/delete_race"
width="w-1/3"
>
Delete
</Button>
{:else}
<Button
formaction="?/create_race"
color="tertiary"
submit
width="w-1/2"
disabled={disable_inputs}
>
Create Race
</Button>
{/if}
</div>
<Button onclick={delete_race} color="primary" {disabled} width="w-1/3">Delete</Button>
{:else}
<Button onclick={update_race(true)} color="tertiary" {disabled} width="w-1/2">
Create Race
</Button>
{/if}
</div>
</form>
</div>
</Card>

View File

@ -1,157 +1,209 @@
<script lang="ts">
import { Card, Button, Dropdown } from "$lib/components";
import type { Driver, Race, RacePick, User } from "$lib/schema";
import { get_by_value } from "$lib/database";
import type { Action } from "svelte/action";
import { getModalStore, type ModalStore } from "@skeletonlabs/skeleton";
import type { Driver, RacePick, Substitution } from "$lib/schema";
import { get_by_value, get_driver_headshot_template } from "$lib/database";
import {
getModalStore,
getToastStore,
type ModalStore,
type ToastStore,
} from "@skeletonlabs/skeleton";
import { DRIVER_HEADSHOT_HEIGHT, DRIVER_HEADSHOT_WIDTH } from "$lib/config";
import { driver_dropdown_options } from "$lib/dropdown";
import { get_error_toast } from "$lib/toast";
import { pb, pbUser } from "$lib/pocketbase";
import type { PageData } from "../../../routes/racepicks/$types";
interface RacePickCardProps {
/** Data passed from the page context */
data: PageData;
/** The [RacePick] object used to prefill values. */
racepick?: RacePick | undefined;
/** The [Race] object containing the place to guess */
currentrace: Race | null;
/** The [User] currently logged in */
user?: User;
/** The drivers (to display the headshot) */
drivers: Driver[];
/** Disable all inputs if [true] */
disable_inputs?: boolean;
/** The [src] of the driver headshot template preview */
headshot_template?: string;
/** The value this component's pxx select dropdown will bind to */
pxx_select_value: string;
/** The value this component's dnf select dropdown will bind to */
dnf_select_value: string;
racepick?: RacePick;
}
let {
racepick = undefined,
currentrace = null,
user = undefined,
drivers,
disable_inputs = false,
headshot_template = "",
pxx_select_value,
dnf_select_value,
}: RacePickCardProps = $props();
let { data, racepick = undefined }: RacePickCardProps = $props();
const modalStore: ModalStore = getModalStore();
if ($modalStore[0].meta) {
const meta = $modalStore[0].meta;
// Stuff thats required for the "update" card
data = meta.data;
racepick = meta.racepick;
currentrace = meta.currentrace;
user = meta.user;
drivers = meta.drivers;
disable_inputs = meta.disable_inputs;
headshot_template = meta.headshot_template;
pxx_select_value = meta.pxx_select_value;
dnf_select_value = meta.dnf_select_value;
}
// This action is used on the <Dropdown> element.
// It will trigger once the Dropdown's <input> elements is mounted.
// This way we'll receive a reference to the object so we can register our event handler.
const register_pxx_preview_handler: Action = (node: HTMLElement) => {
node.addEventListener("DropdownChange", update_pxx_preview);
const toastStore: ToastStore = getToastStore();
// Await promises
let drivers: Driver[] | undefined = $state(undefined);
data.drivers.then((d: Driver[]) => (drivers = d));
let substitutions: Substitution[] | undefined = $state(undefined);
data.substitutions.then((s: Substitution[]) => (substitutions = s));
// Constants
const labelwidth: string = "70px";
// Reactive state
let required: boolean = $derived(!racepick);
let disabled: boolean = false; // TODO: Datelock
let pxx_select_value: string = $state(racepick?.pxx ?? "");
let dnf_select_value: string = $state(racepick?.dnf ?? "");
let active_drivers_and_substitutes: Driver[] = $derived.by(() => {
if (!data.currentrace) return [];
let active_and_substitutes: Driver[] = (drivers ?? []).filter(
(driver: Driver) => driver.active,
);
(substitutions ?? [])
.filter((substitution: Substitution) => substitution.race === data.currentrace?.id)
.forEach((substitution: Substitution) => {
const for_index = active_and_substitutes.findIndex(
(driver: Driver) => driver.id === substitution.for,
);
const sub_index = (drivers ?? []).findIndex(
(driver: Driver) => driver.id === substitution.substitute,
);
active_and_substitutes[for_index] = (drivers ?? [])[sub_index];
});
return active_and_substitutes.sort((a: Driver, b: Driver) => a.code.localeCompare(b.code));
});
// Update preview
$effect(() => {
if (!drivers) return;
const src: string = get_by_value(drivers, "id", pxx_select_value)?.headshot_url ?? "";
const img: HTMLImageElement = document.getElementById("headshot_preview") as HTMLImageElement;
if (img) img.src = src;
});
const random_select_handler = (event: Event): void => {
pxx_select_value =
active_drivers_and_substitutes[
Math.floor(Math.random() * active_drivers_and_substitutes.length)
].id;
dnf_select_value =
active_drivers_and_substitutes[
Math.floor(Math.random() * active_drivers_and_substitutes.length)
].id;
};
// This event handler is registered to the Dropdown's <input> element through the action above.
const update_pxx_preview = (event: Event) => {
const target: HTMLInputElement = event.target as HTMLInputElement;
// Database actions
const update_racepick = (create?: boolean): (() => Promise<void>) => {
const handler = async (): Promise<void> => {
if (!$pbUser?.id || $pbUser.id === "") {
toastStore.trigger(get_error_toast("Invalid user id!"));
return;
}
if (!data.currentrace?.id || data.currentrace.id === "") {
toastStore.trigger(get_error_toast("Invalid race id!"));
return;
}
if (!pxx_select_value || pxx_select_value === "") {
toastStore.trigger(get_error_toast("Please enter a PXX guess!"));
return;
}
if (!dnf_select_value || dnf_select_value === "") {
toastStore.trigger(get_error_toast("Please enter a DNF guess!"));
return;
}
// The option "label" gets put into the Dropdown's input value,
// so we need to lookup the driver by "code".
const src: string = get_by_value(drivers, "code", target.value)?.headshot_url || "";
if (src) {
const preview: HTMLImageElement = document.getElementById(
`update_substitution_headshot_preview_${racepick?.id ?? "create"}`,
) as HTMLImageElement;
const racepick_data = {
user: $pbUser.id,
race: data.currentrace.id,
pxx: pxx_select_value,
dnf: dnf_select_value,
};
if (preview) preview.src = src;
try {
if (create) {
await pb.collection("racepicks").create(racepick_data);
} else {
if (!racepick?.id) {
toastStore.trigger(get_error_toast("Invalid racepick id!"));
return;
}
await pb.collection("racepicks").update(racepick.id, racepick_data);
}
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
return handler;
};
const delete_racepick = async (): Promise<void> => {
if (!racepick?.id) {
toastStore.trigger(get_error_toast("Invalid racepick id!"));
return;
}
try {
await pb.collection("racepicks").delete(racepick.id);
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
</script>
<Card
imgsrc={get_by_value(drivers, "id", racepick?.pxx ?? "")?.headshot_url ?? headshot_template}
imgid="update_substitution_headshot_preview_{racepick?.id ?? 'create'}"
width="w-full sm:w-auto"
imgwidth={DRIVER_HEADSHOT_WIDTH}
imgheight={DRIVER_HEADSHOT_HEIGHT}
imgonclick={(event: Event) => modalStore.close()}
>
<form method="POST" enctype="multipart/form-data">
<!-- This is also disabled, because the ID should only be -->
<!-- "leaked" to users that are allowed to use the inputs -->
{#if racepick && !disable_inputs}
<input name="id" type="hidden" value={racepick.id} />
{/if}
<input name="user" type="hidden" value={user?.id} />
<input name="race" type="hidden" value={currentrace?.id} />
{#await Promise.all([data.graphics, data.drivers]) then [graphics, drivers]}
<Card
imgsrc={get_by_value<Driver>(drivers, "id", racepick?.pxx ?? "")?.headshot_url ??
get_driver_headshot_template(graphics)}
imgid="headshot_preview"
width="w-full sm:w-auto"
imgwidth={DRIVER_HEADSHOT_WIDTH}
imgheight={DRIVER_HEADSHOT_HEIGHT}
imgonclick={(event: Event) => modalStore.close()}
>
<div class="flex flex-col gap-2">
<!-- PXX select -->
<Dropdown
name="pxx"
input_variable={pxx_select_value}
action={register_pxx_preview_handler}
options={driver_dropdown_options(drivers)}
labelwidth="60px"
disabled={disable_inputs}
bind:value={pxx_select_value}
options={driver_dropdown_options(active_drivers_and_substitutes)}
{labelwidth}
{disabled}
{required}
>
P{currentrace?.pxx ?? "XX"}
P{data.currentrace?.pxx ?? "XX"}
</Dropdown>
<!-- DNF select -->
<Dropdown
name="dnf"
input_variable={dnf_select_value}
options={driver_dropdown_options(drivers)}
labelwidth="60px"
disabled={disable_inputs}
bind:value={dnf_select_value}
options={driver_dropdown_options(active_drivers_and_substitutes)}
{labelwidth}
{disabled}
{required}
>
DNF
</Dropdown>
<Button color="tertiary" {disabled} width="w-full" onclick={random_select_handler}>
Select Random
</Button>
<!-- Save/Delete buttons -->
<div class="flex justify-end gap-2">
{#if racepick}
<Button
formaction="?/update_racepick"
color="secondary"
disabled={disable_inputs}
submit
width="w-1/2"
>
<Button onclick={update_racepick()} color="secondary" {disabled} width="w-1/2">
Save Changes
</Button>
<Button
color="primary"
submit
disabled={disable_inputs}
formaction="?/delete_racepick"
width="w-1/2"
>
Delete
</Button>
<Button onclick={delete_racepick} color="primary" {disabled} width="w-1/2">Delete</Button>
{:else}
<Button formaction="?/create_racepick" color="tertiary" submit width="w-full">
<Button onclick={update_racepick(true)} color="tertiary" {disabled} width="w-full">
Make Pick
</Button>
{/if}
</div>
</div>
</form>
</Card>
</Card>
{/await}

View File

@ -2,107 +2,149 @@
import {
Autocomplete,
getModalStore,
getToastStore,
InputChip,
type AutocompleteOption,
type ModalStore,
type ToastStore,
} from "@skeletonlabs/skeleton";
import { Button, Card, Dropdown } from "$lib/components";
import type { Driver, Race, RaceResult } from "$lib/schema";
import { Button, Card, Dropdown, type DropdownOption } from "$lib/components";
import type { Driver, Race, RaceResult, Substitution } from "$lib/schema";
import { get_by_value } from "$lib/database";
import { race_dropdown_options } from "$lib/dropdown";
import { pb, pbUser } from "$lib/pocketbase";
import { get_error_toast } from "$lib/toast";
import type { PageData } from "../../../routes/data/raceresults/$types";
interface RaceResultCardProps {
/** Data passed from the page context */
data: PageData;
/** The [RaceResult] object used to prefill values. */
result?: RaceResult;
/** The list of [Drivers] for the driver selection */
drivers: Driver[];
/** The list of [Races] for the race selection + PXX display */
races: Race[];
/** Disable all inputs if [true] */
disable_inputs?: boolean;
/** Require all inputs if [true] */
require_inputs?: boolean;
}
let {
result = undefined,
drivers,
races,
disable_inputs = false,
require_inputs = false,
}: RaceResultCardProps = $props();
let { data, result = undefined }: RaceResultCardProps = $props();
// TODO: This does not work at all. Why does it work for other cards???
// Everything exists here, but in the markup it is undefined???
const modalStore: ModalStore = getModalStore();
if ($modalStore[0].meta) {
const meta = $modalStore[0].meta;
// Stuff thats required for the "update" card
disable_inputs = meta.disable_inputs;
drivers = meta.drivers;
races = meta.races;
data = meta.data;
result = meta.result;
// Stuff thats additionally required for the "create" card
require_inputs = meta.require_inputs;
}
const currentrace: Race | undefined = get_by_value(races, "id", result?.race ?? "Invalid");
const toastStore: ToastStore = getToastStore();
// TODO: I have no fucking idea why this solves things...
// Without this, the original values passed through the modalStore
// are undefined within the markup, but not within the <script>...
const disable_inputs2 = disable_inputs;
const drivers2 = drivers;
const races2 = races;
const result2 = result;
const require_inputs2 = require_inputs;
// Await promises
let races: Race[] | undefined = $state(undefined);
data.races.then((r: Race[]) => (races = r));
let race_select_value: string = currentrace?.id ?? "";
let drivers: Driver[] | undefined = $state(undefined);
data.drivers.then((d: Driver[]) => (drivers = d));
let substitutions: Substitution[] | undefined = $state(undefined);
data.substitutions.then((s: Substitution[]) => (substitutions = s));
let raceresults: RaceResult[] | undefined = $state(undefined);
data.raceresults.then((r: RaceResult[]) => (raceresults = r));
// Constants
const labelwidth: string = "70px";
// Reactive state
let required: boolean = $derived(!result);
let disabled: boolean = $derived(!$pbUser?.admin); // TODO: Datelock (prevent entering future result)
let race_select_value: string = $state(result?.race ?? "");
let currentrace: Race | undefined = $derived(
get_by_value<Race>(races ?? [], "id", race_select_value) ?? undefined,
);
let present_results: string[] = $derived.by(() => {
if (!raceresults || raceresults.length === 0) return [];
return raceresults.map((raceresult: RaceResult) => raceresult.race);
});
let pxxs_placeholder: string = $derived(
currentrace
? `Select P${(currentrace.pxx ?? -10) - 3} to P${(currentrace.pxx ?? -10) + 3}...`
: `Select race first...`,
);
let pxxs_input: string = $state("");
let pxxs_chips: string[] = $state(
result2?.pxxs.map((id: string) => get_by_value(drivers2, "id", id)?.code ?? "Invalid") ?? [],
);
let pxxs_chips: string[] = $state([]);
let dnfs_input: string = $state("");
let dnfs_chips: string[] = $state(
result2?.dnfs.map((id: string) => get_by_value(drivers2, "id", id)?.code ?? "Invalid") ?? [],
);
let dnfs_chips: string[] = $state([]);
// Set the pxxs/dnfs states once the drivers are loaded
data.drivers.then(async (drivers: Driver[]) => {
pxxs_chips =
result?.pxxs.map((id: string) => get_by_value(drivers, "id", id)?.code ?? "Invalid") ?? [];
dnfs_chips =
result?.dnfs.map((id: string) => get_by_value(drivers, "id", id)?.code ?? "Invalid") ?? [];
});
// This is the actual data that gets sent through the form
let pxxs_ids: string[] = $state(result2?.pxxs ?? []);
let dnfs_ids: string[] = $state(result2?.dnfs ?? []);
let pxxs_ids: string[] = $state(result?.pxxs ?? []);
let dnfs_ids: string[] = $state(result?.dnfs ?? []);
const pxxs_options: AutocompleteOption<string>[] = drivers2.map((driver: Driver) => {
return {
// NOTE: Because Skeleton displays the values inside the autocomplete input,
// we have to supply the driver code twice and manage a list of ids manually (ugh)
label: driver.code,
value: driver.code,
};
let pxxs_options: AutocompleteOption<string>[] = $derived.by(() => {
if (!race_select_value) return [];
let active_and_substitutes: Driver[] = (drivers ?? []).filter(
(driver: Driver) => driver.active,
);
(substitutions ?? [])
.filter((substitution: Substitution) => substitution.race === race_select_value)
.forEach((substitution: Substitution) => {
const for_index = active_and_substitutes.findIndex(
(driver: Driver) => driver.id === substitution.for,
);
const sub_index = (drivers ?? []).findIndex(
(driver: Driver) => driver.id === substitution.substitute,
);
active_and_substitutes[for_index] = (drivers ?? [])[sub_index];
});
return active_and_substitutes
.sort((a: Driver, b: Driver) => a.firstname.localeCompare(b.firstname))
.map((driver: Driver) => {
return {
// NOTE: Because Skeleton displays the values inside the autocomplete input,
// we have to supply the driver code twice and manage a list of ids manually (ugh)
label: `${driver.firstname} ${driver.lastname}`,
value: driver.code,
};
});
});
const pxxs_whitelist: string[] = drivers2.map((driver: Driver) => {
return driver.code;
});
let pxxs_whitelist: string[] = $derived.by(() =>
(drivers ?? []).map((driver: Driver) => {
return driver.code;
}),
);
// Event handlers
const on_pxxs_chip_select = (event: CustomEvent<AutocompleteOption<string>>): void => {
if (disable_inputs2) return;
if (disabled || !drivers) return;
// Can only select 7 drivers
if (pxxs_chips.length >= 7) return;
if (!pxxs_chips.includes(event.detail.value)) {
pxxs_chips.push(event.detail.value);
pxxs_input = "";
}
// Can only select a driver once
if (pxxs_chips.includes(event.detail.value)) return;
const id: string = get_by_value(drivers2, "code", event.detail.value)?.id ?? "Invalid";
// Manage labels that are displayed
pxxs_chips.push(event.detail.value);
pxxs_input = "";
// Manage ids that are submitted via form
const id: string = get_by_value(drivers.filter((driver: Driver) => driver.active), "code", event.detail.value)?.id ?? "Invalid";
if (!pxxs_ids.includes(id)) {
pxxs_ids.push(id);
}
@ -113,14 +155,17 @@
};
const on_dnfs_chip_select = (event: CustomEvent<AutocompleteOption<string>>): void => {
if (disable_inputs2) return;
if (disabled || !drivers) return;
if (!dnfs_chips.includes(event.detail.value)) {
dnfs_chips.push(event.detail.value);
dnfs_input = "";
}
// Can only select a driver once
if (dnfs_chips.includes(event.detail.value)) return;
const id: string = get_by_value(drivers2, "code", event.detail.value)?.id ?? "Invalid";
// Manage labels that are displayed
dnfs_chips.push(event.detail.value);
dnfs_input = "";
// Manage ids that are submitted via form
const id: string = get_by_value(drivers.filter((driver: Driver) => driver.active), "code", event.detail.value)?.id ?? "Invalid";
if (!dnfs_ids.includes(id)) {
dnfs_ids.push(id);
}
@ -129,47 +174,90 @@
const on_dnfs_chip_remove = (event: CustomEvent): void => {
dnfs_ids.splice(event.detail.chipIndex, 1);
};
// Database actions
const update_raceresult = (create?: boolean): (() => Promise<void>) => {
const handler = async (): Promise<void> => {
if (!race_select_value || race_select_value === "") {
toastStore.trigger(get_error_toast("Please select a race!"));
return;
}
// If enough drivers DNF/DSQ, theoretically pxxs_ids could be empty
// if (!pxxs_ids || pxxs_ids.length !== 7) {
// toastStore.trigger(get_error_toast("Please select all 7 driver placements!"));
// return;
// }
const raceresult_data = {
race: race_select_value,
pxxs: pxxs_ids,
dnfs: dnfs_ids,
};
try {
if (create) {
await pb.collection("raceresults").create(raceresult_data);
} else {
if (!result?.id) {
toastStore.trigger(get_error_toast("Invalid result id!"));
return;
}
await pb.collection("raceresults").update(result.id, raceresult_data);
}
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
return handler;
};
const delete_raceresult = async (): Promise<void> => {
if (!result?.id) {
toastStore.trigger(get_error_toast("Invalid result id!"));
return;
}
try {
await pb.collection("raceresults").delete(result.id);
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
</script>
<Card width="w-full sm:w-[512px]">
<form method="POST" enctype="multipart/form-data">
<!-- This is also disabled, because the ID should only be -->
<!-- "leaked" to users that are allowed to use the inputs -->
{#if result2 && !disable_inputs2}
<input name="id" type="hidden" value={result2.id} />
{/if}
<!-- Send the input chips ids -->
{#each pxxs_ids as pxxs_id}
<input name="pxxs" type="hidden" disabled={disable_inputs2} value={pxxs_id} />
{/each}
{#each dnfs_ids as dnfs_id}
<input name="dnfs" type="hidden" disabled={disable_inputs2} value={dnfs_id} />
{/each}
<div class="flex flex-col gap-2">
<!-- Race select input -->
<Dropdown
name="race"
input_variable={race_select_value}
options={race_dropdown_options(races2)}
labelwidth="70px"
disabled={disable_inputs2}
required={require_inputs2}
>
Race
</Dropdown>
{#await data.races then races}
<Dropdown
name="race"
bind:value={race_select_value}
options={race_dropdown_options(races).filter(
(option: DropdownOption) => result || !present_results.includes(option.value),
)}
{labelwidth}
{disabled}
{required}
>
Race
</Dropdown>
{/await}
<div class="mt-2 flex flex-col gap-2">
{#if race_select_value}
<!-- PXXs autocomplete chips -->
<InputChip
bind:input={pxxs_input}
bind:value={pxxs_chips}
whitelist={pxxs_whitelist}
allowUpperCase
placeholder="Select P{(currentrace?.pxx ?? -10) - 3} to P{(currentrace?.pxx ?? -10) + 3}..."
placeholder={pxxs_placeholder}
name="pxxs_codes"
disabled={disable_inputs2}
required={require_inputs2}
{disabled}
{required}
on:remove={on_pxxs_chip_remove}
/>
<div class="card max-h-48 w-full overflow-y-auto p-2" tabindex="-1">
@ -189,7 +277,7 @@
allowUpperCase
placeholder="Select DNFs..."
name="dnfs_codes"
disabled={disable_inputs2}
{disabled}
on:remove={on_dnfs_chip_remove}
/>
<div class="card max-h-48 w-full overflow-y-auto p-2" tabindex="-1">
@ -203,37 +291,19 @@
<!-- Save/Delete buttons -->
<div class="flex items-center justify-end gap-2">
{#if result2}
<Button
formaction="?/update_raceresult"
color="secondary"
disabled={disable_inputs2}
submit
width="w-1/2"
{#if result}
<Button onclick={update_raceresult()} color="secondary" {disabled} width="w-1/2"
>Save</Button
>
Save
</Button>
<Button
color="primary"
submit
disabled={disable_inputs2}
formaction="?/delete_raceresult"
width="w-1/2"
<Button onclick={delete_raceresult} color="primary" {disabled} width="w-1/2"
>Delete</Button
>
Delete
</Button>
{:else}
<Button
formaction="?/create_raceresult"
color="tertiary"
submit
width="w-full"
disabled={disable_inputs2}
>
<Button onclick={update_raceresult(true)} color="tertiary" {disabled} width="w-full">
Create Result
</Button>
{/if}
</div>
</div>
</form>
{/if}
</div>
</Card>

View File

@ -0,0 +1,399 @@
<script lang="ts">
import { Card, Button, Dropdown, Input } from "$lib/components";
import type { Driver, SeasonPick, Team } from "$lib/schema";
import { get_by_value, get_driver_headshot_template } from "$lib/database";
import {
Autocomplete,
getModalStore,
getToastStore,
InputChip,
type AutocompleteOption,
type ModalStore,
type ToastStore,
} from "@skeletonlabs/skeleton";
import { DRIVER_HEADSHOT_HEIGHT, DRIVER_HEADSHOT_WIDTH } from "$lib/config";
import { driver_dropdown_options, team_dropdown_options } from "$lib/dropdown";
import { get_error_toast } from "$lib/toast";
import { pb, pbUser } from "$lib/pocketbase";
import type { PageData } from "../../../routes/seasonpicks/$types";
interface SeasonPickCardProps {
/** Data passed from the page context */
data: PageData;
/** The [SeasonPick] object used to prefill values. */
seasonpick?: SeasonPick;
}
let { data, seasonpick = undefined }: SeasonPickCardProps = $props();
const modalStore: ModalStore = getModalStore();
if ($modalStore[0].meta) {
const meta = $modalStore[0].meta;
data = meta.data;
seasonpick = meta.seasonpick;
}
const toastStore: ToastStore = getToastStore();
// Await promises
let drivers: Driver[] | undefined = $state(undefined);
data.drivers.then((d: Driver[]) => (drivers = d));
let teams: Team[] | undefined = $state(undefined);
data.teams.then((t: Team[]) => (teams = t));
// Constants
const labelwidth: string = "150px";
// Reactive state
let required: boolean = $derived(!seasonpick);
let disabled: boolean = false; // TODO: Datelock
let hottake_value: string = $state(seasonpick?.hottake ?? "");
let wdc_value: string = $state(seasonpick?.wdcwinner ?? "");
let wcc_value: string = $state(seasonpick?.wccwinner ?? "");
let overtakes_value: string = $state(seasonpick?.mostovertakes ?? "");
let dnfs_value: string = $state(seasonpick?.mostdnfs ?? "");
let doohan_value: string = $state(seasonpick?.doohanstarts.toString() ?? "");
let teamwinners_input: string = $state("");
let teamwinners_chips: string[] = $state([]);
let podiums_input: string = $state("");
let podiums_chips: string[] = $state([]);
// Set the teamwinners/podiums states once the drivers are loaded
data.drivers.then(async (drivers: Driver[]) => {
teamwinners_chips =
seasonpick?.teamwinners.map(
(id: string) => get_by_value(drivers, "id", id)?.code ?? "Invalid",
) ?? [];
podiums_chips =
seasonpick?.podiums.map((id: string) => get_by_value(drivers, "id", id)?.code ?? "Invalid") ??
[];
});
// This is the actual data that gets sent through the form
let teamwinners_ids: string[] = $state(seasonpick?.teamwinners ?? []);
let podiums_ids: string[] = $state(seasonpick?.podiums ?? []);
let teamwinners_options: AutocompleteOption<string>[] = $derived.by(() =>
(drivers ?? [])
.filter((driver: Driver) => driver.active)
.map((driver: Driver) => {
const teamname: string = get_by_value(teams ?? [], "id", driver.team)?.name ?? "Invalid";
return {
firstname: driver.firstname,
lastname: driver.lastname,
code: driver.code,
teamname: teamname,
};
})
.sort((a, b) => a.teamname.localeCompare(b.teamname))
.map((driver) => {
return {
label: `${driver.teamname}: ${driver.firstname} ${driver.lastname}`,
value: driver.code,
};
}),
);
let teamwinners_whitelist: string[] = $derived.by(() =>
(drivers ?? []).map((driver: Driver) => driver.code),
);
let teamwinners_denylist: string[] = $derived.by(() => {
let denylist: string[] = [];
teamwinners_chips
.map((driver: string) => get_by_value(drivers ?? [], "code", driver))
.forEach((driver: Driver | undefined) => {
if (driver) {
(drivers ?? [])
.filter((d: Driver) => d.team === driver.team)
.forEach((d: Driver) => {
denylist.push(d.code);
});
}
});
return denylist;
});
let podiums_options: AutocompleteOption<string>[] = $derived.by(() =>
(drivers ?? [])
.filter((driver: Driver) => driver.active)
.sort((a: Driver, b: Driver) => a.firstname.localeCompare(b.firstname))
.map((driver: Driver) => {
return {
label: `${driver.firstname} ${driver.lastname}`,
value: driver.code,
};
}),
);
// Event handlers
const on_teamwinners_chip_select = (event: CustomEvent<AutocompleteOption<string>>): void => {
if (disabled || !drivers) return;
// Can only select 10 drivers
if (teamwinners_chips.length >= 10) return;
// Can only select a driver once
if (teamwinners_chips.includes(event.detail.value)) return;
// Manage labels that are displayed
teamwinners_chips.push(event.detail.value);
teamwinners_input = "";
// Manage ids that are submitted via form
const id: string = get_by_value(drivers, "code", event.detail.value)?.id ?? "Invalid";
if (!teamwinners_ids.includes(id)) {
teamwinners_ids.push(id);
}
};
const on_teamwinners_chip_remove = (event: CustomEvent): void => {
teamwinners_ids.splice(event.detail.chipIndex, 1);
};
const on_podiums_chip_select = (event: CustomEvent<AutocompleteOption<string>>): void => {
if (disabled || !drivers) return;
// Can only select a driver once
if (podiums_chips.includes(event.detail.value)) return;
// Manage labels that are displayed
podiums_chips.push(event.detail.value);
podiums_input = "";
// Manage ids that are submitted via form
const id: string = get_by_value(drivers, "code", event.detail.value)?.id ?? "Invalid";
if (!podiums_ids.includes(id)) {
podiums_ids.push(id);
}
};
const on_podiums_chip_remove = (event: CustomEvent): void => {
podiums_ids.splice(event.detail.chipIndex, 1);
};
// Database actions
const update_seasonpick = (create?: boolean): (() => Promise<void>) => {
const handler = async (): Promise<void> => {
if (!$pbUser?.id || $pbUser.id === "") {
toastStore.trigger(get_error_toast("Invalid user id!"));
return;
}
if (!hottake_value || hottake_value === "") {
toastStore.trigger(get_error_toast("Please enter a hottake!"));
return;
}
if (!wdc_value || wdc_value === "") {
toastStore.trigger(get_error_toast("Please select a driver for WDC!"));
return;
}
if (!wcc_value || wcc_value === "") {
toastStore.trigger(get_error_toast("Please select a team for WCC!"));
return;
}
if (!overtakes_value || overtakes_value === "") {
toastStore.trigger(get_error_toast("Please select a driver for most overtakes!"));
return;
}
if (!dnfs_value || dnfs_value === "") {
toastStore.trigger(get_error_toast("Please select a driver for most DNFs!"));
return;
}
if (
!doohan_value ||
doohan_value === "" ||
parseInt(doohan_value) <= 0 ||
parseInt(doohan_value) > 24
) {
toastStore.trigger(
get_error_toast("Please enter between 0 and 24 starts for Jack Doohan!"),
);
return;
}
if (!teamwinners_ids || teamwinners_ids.length !== 10) {
toastStore.trigger(get_error_toast("Please select a winner for each team!"));
return;
}
if (!podiums_ids || podiums_ids.length < 3) {
toastStore.trigger(get_error_toast("Please select at least 3 drivers with podiums!"));
return;
}
const seasonpick_data = {
user: $pbUser.id,
hottake: hottake_value,
wdcwinner: wdc_value,
wccwinner: wcc_value,
mostovertakes: overtakes_value,
mostdnfs: dnfs_value,
doohanstarts: doohan_value,
teamwinners: teamwinners_ids,
podiums: podiums_ids,
};
try {
if (create) {
await pb.collection("seasonpicks").create(seasonpick_data);
} else {
if (!seasonpick?.id) {
toastStore.trigger(get_error_toast("Invalid seasonpick id!"));
return;
}
await pb.collection("seasonpicks").update(seasonpick.id, seasonpick_data);
}
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
return handler;
};
const delete_seasonpick = async (): Promise<void> => {
if (!seasonpick?.id) {
toastStore.trigger(get_error_toast("Invalid seasonpick id!"));
return;
}
try {
await pb.collection("seasonpicks").delete(seasonpick.id);
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
</script>
{#await Promise.all([data.graphics, data.drivers, data.teams]) then [graphics, drivers, teams]}
<Card width="w-full sm:w-[512px]">
<div class="flex flex-col gap-2">
<!-- Hottake -->
<Input bind:value={hottake_value} {labelwidth} {disabled} {required}>Hottake</Input>
<!-- WDC select -->
<Dropdown
bind:value={wdc_value}
options={driver_dropdown_options(drivers.filter((driver: Driver) => driver.active))}
{labelwidth}
{disabled}
{required}
>
WDC
</Dropdown>
<!-- WCC select -->
<Dropdown
bind:value={wcc_value}
options={team_dropdown_options(teams)}
{labelwidth}
{disabled}
{required}
>
WCC
</Dropdown>
<!-- Overtakes select -->
<Dropdown
bind:value={overtakes_value}
options={driver_dropdown_options(drivers.filter((driver: Driver) => driver.active))}
{labelwidth}
{disabled}
{required}
>
Most Overtakes
</Dropdown>
<!-- DNFs select -->
<Dropdown
bind:value={dnfs_value}
options={driver_dropdown_options(drivers.filter((driver: Driver) => driver.active))}
{labelwidth}
{disabled}
{required}
>
Most DNFs
</Dropdown>
<!-- Doohan Starts -->
<Input
type="number"
min={0}
max={24}
bind:value={doohan_value}
{labelwidth}
{disabled}
{required}
>
Doohan Starts
</Input>
<!-- Teamwinners autocomplete chips -->
<InputChip
bind:input={teamwinners_input}
bind:value={teamwinners_chips}
whitelist={teamwinners_whitelist}
allowUpperCase
placeholder="Select Teamwinners..."
name="teamwinners_codes"
{disabled}
{required}
on:remove={on_teamwinners_chip_remove}
/>
<div class="card max-h-48 w-full overflow-y-auto p-2" tabindex="-1">
<Autocomplete
bind:input={teamwinners_input}
options={teamwinners_options}
denylist={teamwinners_denylist}
on:selection={on_teamwinners_chip_select}
/>
</div>
<!-- Podiums autocomplete chips -->
<InputChip
bind:input={podiums_input}
bind:value={podiums_chips}
whitelist={teamwinners_whitelist}
allowUpperCase
placeholder="Select Drivers with Podiums..."
name="podiums_codes"
{disabled}
{required}
on:remove={on_podiums_chip_remove}
/>
<div class="card max-h-48 w-full overflow-y-auto p-2" tabindex="-1">
<Autocomplete
bind:input={podiums_input}
options={podiums_options}
denylist={podiums_chips}
on:selection={on_podiums_chip_select}
/>
</div>
<!-- Save/Delete buttons -->
<div class="flex justify-end gap-2">
{#if seasonpick}
<Button onclick={update_seasonpick()} color="secondary" {disabled} width="w-1/2">
Save Changes
</Button>
<Button onclick={delete_seasonpick} color="primary" {disabled} width="w-1/2">
Delete
</Button>
{:else}
<Button onclick={update_seasonpick(true)} color="tertiary" {disabled} width="w-full">
Make Pick
</Button>
{/if}
</div>
</div>
</Card>
{/await}

View File

@ -1,181 +1,179 @@
<script lang="ts">
import { Card, Button, Dropdown } from "$lib/components";
import type { Driver, Race, Substitution } from "$lib/schema";
import { get_by_value } from "$lib/database";
import type { Action } from "svelte/action";
import { getModalStore, type ModalStore } from "@skeletonlabs/skeleton";
import type { Driver, Substitution } from "$lib/schema";
import { get_by_value, get_driver_headshot_template } from "$lib/database";
import {
getModalStore,
getToastStore,
type ModalStore,
type ToastStore,
} from "@skeletonlabs/skeleton";
import { DRIVER_HEADSHOT_HEIGHT, DRIVER_HEADSHOT_WIDTH } from "$lib/config";
import { driver_dropdown_options, race_dropdown_options } from "$lib/dropdown";
import { get_error_toast } from "$lib/toast";
import { pb, pbUser } from "$lib/pocketbase";
import type { PageData } from "../../../routes/data/season/substitutions/$types";
interface SubstitutionCardProps {
/** Data passed from the page context */
data: PageData;
/** The [Substitution] object used to prefill values. */
substitution?: Substitution | undefined;
/** The drivers (to display the headshot) */
drivers: Driver[];
races: Race[];
/** Disable all inputs if [true] */
disable_inputs?: boolean;
/** Require all inputs if [true] */
require_inputs?: boolean;
/** The [src] of the driver headshot template preview */
headshot_template?: string;
/** The value this component's substitute select dropdown will bind to */
substitute_select_value: string;
/** The value this component's driver select dropdown will bind to */
driver_select_value: string;
/** The value this component's race select dropdown will bind to */
race_select_value: string;
substitution?: Substitution;
}
let {
substitution = undefined,
drivers,
races,
disable_inputs = false,
require_inputs = false,
headshot_template = "",
substitute_select_value,
driver_select_value,
race_select_value,
}: SubstitutionCardProps = $props();
let { data, substitution = undefined }: SubstitutionCardProps = $props();
const modalStore: ModalStore = getModalStore();
if ($modalStore[0].meta) {
const meta = $modalStore[0].meta;
// Stuff thats required for the "update" card
data = meta.data;
substitution = meta.substitution;
drivers = meta.drivers;
races = meta.races;
disable_inputs = meta.disable_inputs;
substitute_select_value = meta.substitute_select_value;
driver_select_value = meta.driver_select_value;
race_select_value = meta.race_select_value;
// Stuff thats additionally required for the "create" card
require_inputs = meta.require_inputs;
headshot_template = meta.headshot_template;
}
// This action is used on the <Dropdown> element.
// It will trigger once the Dropdown's <input> elements is mounted.
// This way we'll receive a reference to the object so we can register our event handler.
const register_substitute_preview_handler: Action = (node: HTMLElement) => {
node.addEventListener("DropdownChange", update_substitute_preview);
const toastStore: ToastStore = getToastStore();
// Await promises
let drivers: Driver[] | undefined = $state(undefined);
data.drivers.then((d: Driver[]) => (drivers = d));
// Constants
const labelwidth: string = "120px";
// Reactive state
let required: boolean = $derived(!substitution);
let disabled: boolean = $derived(!$pbUser?.admin);
let active_drivers: Driver[] = $derived((drivers ?? []).filter((d: Driver) => d.active));
let inactive_drivers: Driver[] = $derived((drivers ?? []).filter((d: Driver) => !d.active));
let substitute_value: string = $state(substitution?.substitute ?? "");
let driver_value: string = $state(substitution?.for ?? "");
let race_value: string = $state(substitution?.race ?? "");
// Update preview
$effect(() => {
if (!drivers) return;
const src: string = get_by_value(drivers, "id", substitute_value)?.headshot_url ?? "";
const img: HTMLImageElement = document.getElementById("headshot_preview") as HTMLImageElement;
if (img) img.src = src;
});
// Database actions
const update_substitution = (create?: boolean): (() => Promise<void>) => {
const handler = async (): Promise<void> => {
if (!substitute_value || substitute_value === "") {
toastStore.trigger(get_error_toast("Please select a substitute driver!"));
return;
}
if (!driver_value || driver_value === "") {
toastStore.trigger(get_error_toast("Please select a replaced driver!"));
return;
}
if (!race_value || race_value === "") {
toastStore.trigger(get_error_toast("Please select a race!"));
return;
}
const substitution_data = {
substitute: substitute_value,
for: driver_value,
race: race_value,
};
try {
if (create) {
await pb.collection("substitutions").create(substitution_data);
} else {
if (!substitution?.id) {
toastStore.trigger(get_error_toast("Invalid substitution id!"));
return;
}
await pb.collection("substitutions").update(substitution.id, substitution_data);
}
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
return handler;
};
// This event handler is registered to the Dropdown's <input> element through the action above.
const update_substitute_preview = (event: Event) => {
const target: HTMLInputElement = event.target as HTMLInputElement;
const delete_substitution = async (): Promise<void> => {
if (!substitution?.id) {
toastStore.trigger(get_error_toast("Invalid substitution id!"));
return;
}
// The option "label" gets put into the Dropdown's input value,
// so we need to lookup the driver by "code".
const src: string = get_by_value(drivers, "code", target.value)?.headshot_url || "";
if (src) {
const preview: HTMLImageElement = document.getElementById(
`update_substitution_headshot_preview_${substitution?.id ?? "create"}`,
) as HTMLImageElement;
if (preview) preview.src = src;
try {
await pb.collection("substitutions").delete(substitution.id);
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
</script>
<Card
imgsrc={get_by_value(drivers, "id", substitution?.substitute ?? "")?.headshot_url ??
headshot_template}
imgid="update_substitution_headshot_preview_{substitution?.id ?? 'create'}"
width="w-full sm:w-auto"
imgwidth={DRIVER_HEADSHOT_WIDTH}
imgheight={DRIVER_HEADSHOT_HEIGHT}
imgonclick={(event: Event) => modalStore.close()}
>
<form method="POST" enctype="multipart/form-data">
<!-- This is also disabled, because the ID should only be -->
<!-- "leaked" to users that are allowed to use the inputs -->
{#if substitution && !disable_inputs}
<input name="id" type="hidden" value={substitution.id} />
{/if}
{#await data.drivers then drivers}
<Card
imgsrc={get_by_value(drivers, "id", substitution?.substitute ?? "")?.headshot_url ??
get_driver_headshot_template(data.graphics)}
imgid="headshot_preview"
width="w-full sm:w-auto"
imgwidth={DRIVER_HEADSHOT_WIDTH}
imgheight={DRIVER_HEADSHOT_HEIGHT}
imgonclick={(event: Event) => modalStore.close()}
>
<div class="flex flex-col gap-2">
<!-- Substitute select -->
<Dropdown
name="substitute"
input_variable={substitute_select_value}
action={register_substitute_preview_handler}
options={driver_dropdown_options(drivers)}
labelwidth="120px"
disabled={disable_inputs}
required={require_inputs}
bind:value={substitute_value}
options={driver_dropdown_options(inactive_drivers)}
{labelwidth}
{disabled}
{required}
>
Substitute
</Dropdown>
<!-- Driver select -->
<Dropdown
name="for"
input_variable={driver_select_value}
options={driver_dropdown_options(drivers)}
labelwidth="120px"
disabled={disable_inputs}
required={require_inputs}
bind:value={driver_value}
options={driver_dropdown_options(active_drivers)}
{labelwidth}
{disabled}
{required}
>
For
</Dropdown>
<!-- Race select -->
<Dropdown
name="race"
input_variable={race_select_value}
options={race_dropdown_options(races)}
labelwidth="120px"
disabled={disable_inputs}
required={require_inputs}
>
Race
</Dropdown>
{#await data.races then races}
<Dropdown
bind:value={race_value}
options={race_dropdown_options(races)}
{labelwidth}
{disabled}
{required}
>
Race
</Dropdown>
{/await}
<!-- Save/Delete buttons -->
<div class="flex justify-end gap-2">
{#if substitution}
<Button
formaction="?/update_substitution"
color="secondary"
disabled={disable_inputs}
submit
width="w-1/2"
>
<Button onclick={update_substitution()} color="secondary" {disabled} width="w-1/2">
Save Changes
</Button>
<Button
color="primary"
submit
disabled={disable_inputs}
formaction="?/delete_substitution"
width="w-1/2"
>
<Button onclick={delete_substitution} color="primary" {disabled} width="w-1/2">
Delete
</Button>
{:else}
<Button
formaction="?/create_substitution"
color="tertiary"
submit
width="w-full"
disabled={disable_inputs}
>
<Button onclick={update_substitution(true)} color="tertiary" {disabled} width="w-full">
Create Substitution
</Button>
{/if}
</div>
</div>
</form>
</Card>
</Card>
{/await}

View File

@ -1,176 +1,256 @@
<script lang="ts">
import { get_image_preview_event_handler } from "$lib/image";
import { FileDropzone, getModalStore, type ModalStore } from "@skeletonlabs/skeleton";
import {
FileDropzone,
getModalStore,
getToastStore,
type ModalStore,
type ToastStore,
} from "@skeletonlabs/skeleton";
import { Card, Button, Input, LazyImage } from "$lib/components";
import type { Team } from "$lib/schema";
import { TEAM_BANNER_HEIGHT, TEAM_BANNER_WIDTH } from "$lib/config";
import {
TEAM_BANNER_HEIGHT,
TEAM_BANNER_WIDTH,
TEAM_LOGO_HEIGHT,
TEAM_LOGO_WIDTH,
} from "$lib/config";
import { get_team_banner_template, get_team_logo_template } from "$lib/database";
import { get_error_toast } from "$lib/toast";
import { pb, pbUser } from "$lib/pocketbase";
import { error } from "@sveltejs/kit";
import type { PageData } from "../../../routes/data/season/teams/$types";
interface TeamCardProps {
/** Data from the page context */
data: PageData;
/** The [Team] object used to prefill values. */
team?: Team | undefined;
/** Disable all inputs if [true] */
disable_inputs?: boolean;
/** Require all inputs if [true] */
require_inputs?: boolean;
/** The [src] of the team banner template preview */
banner_template?: string;
/** The [src] of the team logo template preview */
logo_template?: string;
team?: Team;
}
let {
team = undefined,
disable_inputs = false,
require_inputs = false,
banner_template = "",
logo_template = "",
}: TeamCardProps = $props();
let { data, team = undefined }: TeamCardProps = $props();
const modalStore: ModalStore = getModalStore();
if ($modalStore[0].meta) {
const meta = $modalStore[0].meta;
// Stuff thats required for the "update" card
data = meta.data;
team = meta.team;
disable_inputs = meta.disable_inputs;
// Stuff thats additionally required for the "create" card
require_inputs = meta.require_inputs;
banner_template = meta.banner_template;
logo_template = meta.logo_template;
}
const toastStore: ToastStore = getToastStore();
// Constants
const labelwidth: string = "110px";
let colorpreview: string = $state(team?.color ?? "white");
// Reactive state
let required: boolean = $derived(!team);
let disabled: boolean = $derived(!$pbUser?.admin);
let name_value: string = $state(team?.name ?? "");
let color_value: string = $state(team?.color ?? "");
let banner_value: FileList | undefined = $state();
let logo_value: FileList | undefined = $state();
// Database actions
const update_team = (create?: boolean): (() => Promise<void>) => {
const handler = async (): Promise<void> => {
if (!name_value || name_value === "") {
toastStore.trigger(get_error_toast("Please enter a name!"));
return;
}
if (!color_value || color_value === "") {
toastStore.trigger(get_error_toast("Please enter a color!"));
return;
}
// Banner handling
let banner_avif: Blob | undefined = undefined;
const banner_file: File | undefined =
banner_value && banner_value.length === 1 ? banner_value[0] : undefined;
if (banner_file) {
const banner_formdata: FormData = new FormData();
banner_formdata.append("image", banner_file);
banner_formdata.append("width", TEAM_BANNER_WIDTH.toString());
banner_formdata.append("height", TEAM_BANNER_HEIGHT.toString());
try {
const response = await fetch("/api/compress", {
method: "POST",
body: banner_formdata,
});
if (!response.ok) {
error(500, "Compression failed.");
}
banner_avif = await response.blob();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
}
// Logo handling
let logo_avif: Blob | undefined = undefined;
const logo_file: File | undefined =
logo_value && logo_value.length === 1 ? logo_value[0] : undefined;
if (logo_file) {
const logo_formdata: FormData = new FormData();
logo_formdata.append("image", logo_file);
logo_formdata.append("width", TEAM_LOGO_WIDTH.toString());
logo_formdata.append("height", TEAM_LOGO_HEIGHT.toString());
try {
const response = await fetch("/api/compress", {
method: "POST",
body: logo_formdata,
});
if (!response.ok) {
error(500, "Compression failed.");
}
logo_avif = await response.blob();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
}
let team_data = {
name: name_value,
color: color_value,
banner: banner_avif,
logo: logo_avif,
};
// HACK: Having only a single file for the update request
// doesn't work with pocketbase for some reason
if (team_data.banner === undefined) {
delete team_data.banner;
}
if (team_data.logo === undefined) {
delete team_data.logo;
}
try {
if (create) {
if (!banner_avif) {
toastStore.trigger(get_error_toast("Please upload a single team banner!"));
return;
}
if (!logo_avif) {
toastStore.trigger(get_error_toast("Please upload a single team logo!"));
return;
}
await pb.collection("teams").create(team_data);
} else {
if (!team?.id) {
toastStore.trigger(get_error_toast("Invalid team id!"));
return;
}
await pb.collection("teams").update(team.id, team_data);
}
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
return handler;
};
const delete_team = async (): Promise<void> => {
if (!team?.id) {
toastStore.trigger(get_error_toast("Invalid team id!"));
return;
}
try {
await pb.collection("teams").delete(team.id);
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
</script>
<Card
imgsrc={team?.banner_url ?? banner_template}
imgid="update_team_banner_preview_{team?.id ?? 'create'}"
imgsrc={team?.banner_url ?? get_team_banner_template(data.graphics)}
imgid="banner_preview"
width="w-full sm:w-auto"
imgwidth={TEAM_BANNER_WIDTH}
imgheight={TEAM_BANNER_HEIGHT}
imgonclick={(event: Event) => modalStore.close()}
>
<form method="POST" enctype="multipart/form-data">
<!-- This is also disabled, because the ID should only be -->
<!-- "leaked" to users that are allowed to use the inputs -->
{#if team && !disable_inputs}
<input name="id" type="hidden" value={team.id} />
{/if}
<div class="flex flex-col gap-2">
<!-- Team name input -->
<Input bind:value={name_value} autocomplete="off" {labelwidth} {disabled} {required}>
Name
</Input>
<div class="flex flex-col gap-2">
<!-- Team name input -->
<Input
id="team_name_{team?.id ?? 'create'}"
name="name"
value={team?.name ?? ""}
{labelwidth}
autocomplete="off"
disabled={disable_inputs}
required={require_inputs}
>
Name
</Input>
<!-- Team color input -->
<Input
bind:value={color_value}
autocomplete="off"
placeholder="Enter as '#XXXXXX'"
minlength={7}
maxlength={7}
{labelwidth}
{disabled}
{required}
>
Color
<span class="badge ml-2 border" style="color: {color_value}; background: {color_value}">
C
</span>
</Input>
<!-- Team color input -->
<Input
id="team_color_{team?.id ?? 'create'}"
name="color"
value={team?.color ?? ""}
{labelwidth}
autocomplete="off"
placeholder="Enter as '#XXXXXX'"
disabled={disable_inputs}
required={require_inputs}
minlength={7}
maxlength={7}
onchange={(event: Event) => {
colorpreview = (event.target as HTMLInputElement).value;
}}
>
Color
<span class="badge ml-2 border" style="color: {colorpreview}; background: {colorpreview}">
C
</span>
</Input>
<!-- Banner upload -->
<FileDropzone
name="banner"
bind:files={banner_value}
onchange={get_image_preview_event_handler("banner_preview")}
{disabled}
{required}
>
<svelte:fragment slot="message">
<span class="font-bold">Upload Banner</span>
</svelte:fragment>
</FileDropzone>
<!-- Banner upload -->
<FileDropzone
name="banner"
id="team_banner_{team?.id ?? 'create'}"
onchange={get_image_preview_event_handler(
`update_team_banner_preview_${team?.id ?? "create"}`,
)}
disabled={disable_inputs}
required={require_inputs}
>
<svelte:fragment slot="message"
><span class="font-bold">Upload Banner</span></svelte:fragment
>
</FileDropzone>
<!-- Logo upload -->
<FileDropzone
name="logo"
bind:files={logo_value}
onchange={get_image_preview_event_handler("logo_preview")}
{disabled}
{required}
>
<svelte:fragment slot="message">
<div class="inline-flex flex-nowrap items-center gap-2">
<span class="font-bold">Upload Logo</span>
<LazyImage
src={team?.logo_url ?? get_team_logo_template(data.graphics)}
id="logo_preview"
imgwidth={32}
imgheight={32}
/>
</div>
</svelte:fragment>
</FileDropzone>
<!-- Logo upload -->
<FileDropzone
name="logo"
id="team_logo_{team?.id ?? 'create'}"
onchange={get_image_preview_event_handler(
`update_team_logo_preview_${team?.id ?? "create"}`,
)}
disabled={disable_inputs}
required={require_inputs}
>
<svelte:fragment slot="message">
<div class="inline-flex flex-nowrap items-center gap-2">
<span class="font-bold">Upload Logo</span>
<LazyImage
src={team?.logo_url ?? logo_template}
id="update_team_logo_preview_{team?.id ?? 'create'}"
imgwidth={32}
imgheight={32}
/>
</div>
</svelte:fragment>
</FileDropzone>
<!-- Save/Delete buttons -->
<div class="flex justify-end gap-2">
{#if team}
<Button
formaction="?/update_team"
color="secondary"
disabled={disable_inputs}
submit
width="w-1/2"
>
Save
</Button>
<Button
color="primary"
submit
disabled={disable_inputs}
formaction="?/delete_team"
width="w-1/2"
>
Delete
</Button>
{:else}
<Button
formaction="?/create_team"
color="tertiary"
submit
width="w-full"
disabled={disable_inputs}
>
Create Team
</Button>
{/if}
</div>
<!-- Save/Delete buttons -->
<div class="flex justify-end gap-2">
{#if team}
<Button onclick={update_team()} color="secondary" {disabled} width="w-1/2">Save</Button>
<Button onclick={delete_team} color="primary" {disabled} width="w-1/2">Delete</Button>
{:else}
<Button onclick={update_team(true)} color="tertiary" {disabled} width="w-full">
Create Team
</Button>
{/if}
</div>
</form>
</div>
</Card>

View File

@ -0,0 +1,155 @@
<script lang="ts">
import {
getModalStore,
getToastStore,
type ModalStore,
type ToastStore,
} from "@skeletonlabs/skeleton";
import { Card, Button, Dropdown } from "$lib/components";
import type { Driver } from "$lib/schema";
import { DRIVER_HEADSHOT_HEIGHT, DRIVER_HEADSHOT_WIDTH } from "$lib/config";
import { get_by_value, get_driver_headshot_template } from "$lib/database";
import { get_error_toast } from "$lib/toast";
import { pb, pbUser } from "$lib/pocketbase";
import type { PageData } from "../../../routes/data/season/drivers/$types";
import { driver_dropdown_options, team_dropdown_options } from "$lib/dropdown";
interface TeamCardProps {
/** Data from the page context */
data: PageData;
}
let { data }: TeamCardProps = $props();
const modalStore: ModalStore = getModalStore();
if ($modalStore[0].meta) {
const meta = $modalStore[0].meta;
data = meta.data;
}
const toastStore: ToastStore = getToastStore();
// Await promises
let drivers: Driver[] | undefined = $state(undefined);
data.drivers.then((d: Driver[]) => (drivers = d));
// Constants
const labelwidth: string = "110px";
// Reactive state
let required: boolean = true;
let disabled: boolean = $derived(!$pbUser?.admin);
let driver_value: string = $state("");
let team_value: string = $state("");
// Update preview
$effect(() => {
if (!drivers) return;
const src: string = get_by_value(drivers, "id", driver_value)?.headshot_url ?? "";
const img: HTMLImageElement = document.getElementById("headshot_preview") as HTMLImageElement;
if (img) img.src = src;
});
// Database actions
const update_driver = (): (() => Promise<void>) => {
const handler = async (): Promise<void> => {
if (!driver_value || driver_value === "") {
toastStore.trigger(get_error_toast("Please select a driver!"));
return;
}
const old_driver: Driver | undefined = get_by_value(await data.drivers, "id", driver_value);
if (!old_driver) {
toastStore.trigger(get_error_toast("Unable to lookup driver!"));
return;
}
if (!old_driver.headshot_url) {
toastStore.trigger(get_error_toast("Unable to lookup driver headshot!"));
return;
}
if (!team_value || team_value === "" || team_value === old_driver.team) {
toastStore.trigger(get_error_toast("Please select a new team!"));
return;
}
// Create a new driver
const headshot_response = await fetch(old_driver.headshot_url);
const headshot_blob = await headshot_response.blob();
let new_driver_data = {
code: old_driver.code,
firstname: old_driver.firstname,
lastname: old_driver.lastname,
headshot: headshot_blob, // NOTE: Duplicates the image, but no issue for low volume
team: team_value,
active: true,
started_active: false,
};
try {
await pb.collection("drivers").create(new_driver_data);
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
return;
}
// Disable the old driver
let old_driver_data = {
active: false,
};
try {
await pb.collection("drivers").update(driver_value, old_driver_data);
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
return handler;
};
</script>
<Card
imgsrc={get_driver_headshot_template(data.graphics)}
imgid="headshot_preview"
width="w-full sm:w-auto"
imgwidth={DRIVER_HEADSHOT_WIDTH}
imgheight={DRIVER_HEADSHOT_HEIGHT}
imgonclick={(event: Event) => modalStore.close()}
>
<div class="flex flex-col gap-2">
<!-- Driver select -->
{#await data.drivers then drivers}
<Dropdown
bind:value={driver_value}
options={driver_dropdown_options(drivers.filter((driver: Driver) => driver.active))}
{labelwidth}
{disabled}
{required}
>
Driver
</Dropdown>
{/await}
<!-- New team select -->
{#await data.teams then teams}
<Dropdown
bind:value={team_value}
options={team_dropdown_options(teams)}
{labelwidth}
{disabled}
{required}
>
New Team
</Dropdown>
{/await}
<!-- Save/Delete buttons -->
<div class="flex justify-end gap-2">
<Button onclick={update_driver()} color="tertiary" {disabled} width="w-full">
Switch Team
</Button>
</div>
</div>
</Card>

View File

@ -1,10 +1,10 @@
<script lang="ts">
import { page } from "$app/stores";
import { page } from "$app/state";
import type { Snippet } from "svelte";
import { popup, type PopupSettings } from "@skeletonlabs/skeleton";
const is_at_path = (path: string): boolean => {
const pathname: string = $page.url.pathname;
const pathname: string = page.url.pathname;
// return pathname === path;
return pathname.endsWith(path);
};
@ -21,6 +21,9 @@
/** Make the button act as a link. */
href?: string;
/** Open the link inside a new tab. */
newtab?: boolean;
/** Add a width class to the button. */
width?: string;
@ -54,6 +57,7 @@
color = undefined,
submit = false,
href = undefined,
newtab = false,
width = "w-auto",
activate = false,
activate_href = false,
@ -70,6 +74,8 @@
{#if href}
<a
{href}
target={newtab ? "_blank" : undefined}
rel={newtab ? "noopener noreferrer" : undefined}
class="btn m-0 select-none px-2 py-2 {color ? `variant-filled-${color}` : ''} {width} {activate
? 'btn-hover'
: ''} {activate_href && is_at_path(href) ? 'btn-hover' : ''} {shadow
@ -77,6 +83,7 @@
: ''} {extraclass}"
{onclick}
{...restProps}
draggable="false"
>
{@render children()}
</a>

View File

@ -1,34 +1,16 @@
<script lang="ts">
import { ListBox, ListBoxItem, popup, type PopupSettings } from "@skeletonlabs/skeleton";
import type { Snippet } from "svelte";
import type { Action } from "svelte/action";
import type { HTMLInputAttributes } from "svelte/elements";
import { v4 as uuid } from "uuid";
import { type DropdownOption, LazyImage } from "$lib/components";
import type { HTMLSelectAttributes } from "svelte/elements";
import { type DropdownOption } from "$lib/components";
interface DropdownProps extends HTMLInputAttributes {
interface DropdownProps extends HTMLSelectAttributes {
children: Snippet;
/** Placeholder for the empty input element */
placeholder?: string;
/** Form name of the input element, to reference input data after form submission */
name?: string;
/** Manually set the label width, to align multiple inputs vertically. Supply value in CSS units. */
labelwidth?: string;
/** The variable to bind to the input element. Has to be a [$state] so its value can be updated with the input element's contents. */
input_variable: string;
/** Any action to bind to the input element */
action?: Action;
/** The ID of the popup to trigger. UUID by default. */
popup_id?: string;
/** The [PopupSettings] object for the popup to trigger. */
popup_settings?: PopupSettings;
value?: string;
/** The options this autocomplete component allows to choose from.
* Example: [[{ label: "Aston", value: "0" }, { label: "VCARB", value: "1" }]].
@ -38,42 +20,11 @@
let {
children,
placeholder = "",
name = "",
labelwidth = "auto",
input_variable,
action = undefined,
popup_id = uuid(),
popup_settings = {
event: "click",
target: popup_id,
placement: "bottom",
closeQuery: ".listbox-item",
},
value = $bindable(),
options,
...restProps
}: DropdownProps = $props();
/** Find the "label" of an option by its "value" */
const get_label = (value: string): string | undefined => {
return options.find((o) => o.value === value)?.label;
};
// Use an action to fill the "input" variable
// required to dispatch the custom event using $effect
let input: HTMLInputElement | undefined = undefined;
const obtain_input: Action = (node: HTMLElement) => {
input = node as HTMLInputElement;
};
// This will run everyting "input_variable" changes.
// The event is fired when the input's value is updated via JavaScript.
$effect(() => {
// Just list this so SvelteKit picks it up as dependency
input_variable;
if (input) input.dispatchEvent(new CustomEvent("DropdownChange"));
});
</script>
<div class="input-group input-group-divider grid-cols-[auto_1fr_auto]">
@ -83,55 +34,11 @@
>
{@render children()}
</div>
<!-- TODO: How to assign use: conditionally? I don't wan't to repeat the entire input... -->
{#if action}
<input
use:popup={popup_settings}
type="button"
autocomplete="off"
style="height: 42px; text-align: start; text-indent: 12px; border-top-left-radius: 0; border-bottom-left-radius: 0;"
use:obtain_input
use:action
onkeypress={(event: Event) => event.preventDefault()}
value={get_label(input_variable) ?? placeholder}
{...restProps}
/>
{:else}
<input
use:popup={popup_settings}
type="button"
autocomplete="off"
style="height: 42px; text-align: start; text-indent: 12px; border-top-left-radius: 0; border-bottom-left-radius: 0;"
use:obtain_input
onkeypress={(event: Event) => event.preventDefault()}
value={get_label(input_variable) ?? placeholder}
{...restProps}
/>
{/if}
</div>
<div
data-popup={popup_id}
class="card z-10 w-auto overflow-y-scroll p-2 shadow"
style="max-height: 350px;"
>
<ListBox>
<select bind:value class="!outline-none" {...restProps}>
{#each options as option}
<ListBoxItem bind:group={input_variable} {name} value={option.value}>
<div class="flex flex-nowrap">
{#if option.icon_url}
<LazyImage
src={option.icon_url}
alt=""
class="rounded"
style="height: 24px;"
imgwidth={option.icon_width ?? 0}
imgheight={option.icon_height ?? 0}
/>
{/if}
<span class="ml-2">{option.label}</span>
</div>
</ListBoxItem>
<option value={option.value} selected={value === option.value}>
{option.label}
</option>
{/each}
</ListBox>
</select>
</div>

View File

@ -8,11 +8,24 @@
/** Manually set the label width, to align multiple inputs vertically. Supply value in CSS units. */
labelwidth?: string;
/** The variable to bind to the input element. Has to be a [$state] so its value can be updated with the input element's contents. */
value?: string;
/** The type of the input element, e.g. "text". */
type?: string;
/** An optional element at the end of the input group */
tail?: Snippet;
}
let { children, labelwidth = "auto", type = "text", ...restProps }: InputProps = $props();
let {
children,
labelwidth = "auto",
value = $bindable(),
type = "text",
tail = undefined,
...restProps
}: InputProps = $props();
</script>
<div class="input-group input-group-divider grid-cols-[auto_1fr_auto]">
@ -22,5 +35,8 @@
>
{@render children()}
</div>
<input {type} {...restProps} />
<input bind:value class="{tail ? '!border-r' : ''} !border-l" {type} {...restProps} />
{#if tail}
{@render tail()}
{/if}
</div>

View File

@ -1,83 +0,0 @@
<script lang="ts">
import {
Autocomplete,
popup,
type AutocompleteOption,
type PopupSettings,
} from "@skeletonlabs/skeleton";
import type { Snippet } from "svelte";
import { v4 as uuid } from "uuid";
interface SearchProps {
children: Snippet;
/** Placeholder for the empty input element */
placeholder?: string;
/** Form name of the input element, to reference input data after form submission */
name?: string;
/** Manually set the label width, to align multiple inputs vertically. Supply value in CSS units. */
labelwidth?: string;
/** The variable to bind to the input element. Has to be a [$state] so its value can be updated with the input element's contents. */
input_variable: string;
/** The ID of the input element. UUID by default. */
input_id?: string;
/** The ID of the popup to trigger. UUID by default. */
popup_id?: string;
/** The [PopupSettings] object for the popup to trigger. */
popup_settings?: PopupSettings;
/** The event handler updating the [input_variable] after selection. */
selection_handler?: (event: CustomEvent<AutocompleteOption<string>>) => void;
/** The options this autocomplete component allows to choose from.
* Example: [[{ label: "Aston", value: "0" }, { label: "VCARB", value: "1" }]].
*/
options: AutocompleteOption<string, unknown>[];
}
let {
children,
placeholder = "",
name = "",
labelwidth = "auto",
input_variable,
input_id = uuid(),
popup_id = uuid(),
popup_settings = {
event: "focus-click",
target: popup_id,
placement: "bottom",
},
selection_handler = (event: CustomEvent<AutocompleteOption<string>>): void => {
input_variable = event.detail.label;
},
options,
}: SearchProps = $props();
</script>
<div class="input-group input-group-divider grid-cols-[auto_1fr_auto]">
<div
class="input-group-shim select-none text-nowrap text-neutral-900"
style="width: {labelwidth};"
>
{@render children()}
</div>
<input
id={input_id}
type="search"
{placeholder}
{name}
bind:value={input_variable}
use:popup={popup_settings}
/>
</div>
<div data-popup={popup_id} class="card z-10 w-auto p-2 shadow" tabindex="-1">
<Autocomplete bind:input={input_variable} {options} on:selection={selection_handler} />
</div>

View File

@ -6,20 +6,22 @@ import Table from "./Table.svelte";
import Button from "./form/Button.svelte";
import Dropdown from "./form/Dropdown.svelte";
import Input from "./form/Input.svelte";
import Search from "./form/Search.svelte";
import Card from "./cards/Card.svelte";
import DriverCard from "./cards/DriverCard.svelte";
import RaceCard from "./cards/RaceCard.svelte";
import RacePickCard from "./cards/RacePickCard.svelte";
import RaceResultCard from "./cards/RaceResultCard.svelte";
import SeasonPickCard from "./cards/SeasonPickCard.svelte";
import SubstitutionCard from "./cards/SubstitutionCard.svelte";
import TeamCard from "./cards/TeamCard.svelte";
import TeamSwitchCard from "./cards/TeamSwitchCard.svelte";
import type { DropdownOption } from "./form/Dropdown";
import type { TableColumn } from "./Table";
import ChequeredFlagIcon from "./svg/ChequeredFlagIcon.svelte";
import EMailIcon from "./svg/EMailIcon.svelte";
import MenuDrawerIcon from "./svg/MenuDrawerIcon.svelte";
import NameIcon from "./svg/NameIcon.svelte";
import PasswordIcon from "./svg/PasswordIcon.svelte";
@ -37,7 +39,6 @@ export {
Button,
Dropdown,
Input,
Search,
// Cards
Card,
@ -45,8 +46,10 @@ export {
RaceCard,
RacePickCard,
RaceResultCard,
SeasonPickCard,
SubstitutionCard,
TeamCard,
TeamSwitchCard,
// Types
type DropdownOption,
@ -54,6 +57,7 @@ export {
// SVG
ChequeredFlagIcon,
EMailIcon,
NameIcon,
MenuDrawerIcon,
PasswordIcon,

View File

@ -0,0 +1,42 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
fill="currentColor"
class="h-4 w-4 opacity-70"
>
<g
style="stroke: none; stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: none; fill-rule: nonzero; opacity: 1;"
transform="translate(1.4065934065934016 1.4065934065934016) scale(2.81 2.81)"
>
<path
d="M 75.546 78.738 H 14.455 C 6.484 78.738 0 72.254 0 64.283 V 25.716 c 0 -7.97 6.485 -14.455 14.455 -14.455 h 61.091 c 7.97 0 14.454 6.485 14.454 14.455 v 38.567 C 90 72.254 83.516 78.738 75.546 78.738 z M 14.455 15.488 c -5.64 0 -10.228 4.588 -10.228 10.228 v 38.567 c 0 5.64 4.588 10.229 10.228 10.229 h 61.091 c 5.64 0 10.228 -4.589 10.228 -10.229 V 25.716 c 0 -5.64 -4.588 -10.228 -10.228 -10.228 H 14.455 z"
style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(29,29,27); fill-rule: nonzero; opacity: 1;"
transform=" matrix(1 0 0 1 0 0) "
stroke-linecap="round"
/>
<path
d="M 11.044 25.917 C 21.848 36.445 32.652 46.972 43.456 57.5 c 2.014 1.962 5.105 -1.122 3.088 -3.088 C 35.74 43.885 24.936 33.357 14.132 22.83 C 12.118 20.867 9.027 23.952 11.044 25.917 L 11.044 25.917 z"
style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(29,29,27); fill-rule: nonzero; opacity: 1;"
transform=" matrix(1 0 0 1 0 0) "
stroke-linecap="round"
/>
<path
d="M 46.544 57.5 c 10.804 -10.527 21.608 -21.055 32.412 -31.582 c 2.016 -1.965 -1.073 -5.051 -3.088 -3.088 C 65.064 33.357 54.26 43.885 43.456 54.412 C 41.44 56.377 44.529 59.463 46.544 57.5 L 46.544 57.5 z"
style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(29,29,27); fill-rule: nonzero; opacity: 1;"
transform=" matrix(1 0 0 1 0 0) "
stroke-linecap="round"
/>
<path
d="M 78.837 64.952 c -7.189 -6.818 -14.379 -13.635 -21.568 -20.453 c -2.039 -1.933 -5.132 1.149 -3.088 3.088 c 7.189 6.818 14.379 13.635 21.568 20.453 C 77.788 69.973 80.881 66.89 78.837 64.952 L 78.837 64.952 z"
style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(29,29,27); fill-rule: nonzero; opacity: 1;"
transform=" matrix(1 0 0 1 0 0) "
stroke-linecap="round"
/>
<path
d="M 14.446 68.039 c 7.189 -6.818 14.379 -13.635 21.568 -20.453 c 2.043 -1.938 -1.048 -5.022 -3.088 -3.088 c -7.189 6.818 -14.379 13.635 -21.568 20.453 C 9.315 66.889 12.406 69.974 14.446 68.039 L 14.446 68.039 z"
style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(29,29,27); fill-rule: nonzero; opacity: 1;"
transform=" matrix(1 0 0 1 0 0) "
stroke-linecap="round"
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

31
src/lib/date.ts Normal file
View File

@ -0,0 +1,31 @@
import { format } from "date-fns";
/**
* 2025-03-28T17:35
*/
export const isodatetimeformat: string = "yyyy-MM-dd'T'HH:mm";
/**
* 28.03. 17:35
*/
export const shortdatetimeformat: string = "dd.MM.' 'HH:mm";
/**
* 2025-03-28
*/
export const isodateformat: string = "yyyy-MM-dd";
/**
* 17:35
*/
export const timeformat: string = "HH:mm";
/**
* Format a [Date] object using a [date-fns] formatstring.
* This function uses localtime instead of UTC.
*/
export const format_date = <T extends Date | string>(date: T, formatstring: string): string => {
if (!date) return "";
return format(new Date(date), formatstring);
};

View File

@ -9,16 +9,13 @@ import {
TEAM_BANNER_WIDTH,
} from "$lib/config";
let team_dropdown_opts: DropdownOption[] | null = null;
/**
* Generates a list of [DropdownOptions] for a <Dropdown> component.
* Cached until page reload.
*/
export const team_dropdown_options = (teams: Team[]): DropdownOption[] => {
if (!team_dropdown_opts) {
console.log("team_dropdown_options");
team_dropdown_opts = teams.map((team: Team) => {
export const team_dropdown_options = (teams: Team[]): DropdownOption[] =>
teams
.sort((a: Team, b: Team) => a.name.localeCompare(b.name))
.map((team: Team) => {
return {
label: team.name,
value: team.id,
@ -27,44 +24,30 @@ export const team_dropdown_options = (teams: Team[]): DropdownOption[] => {
icon_height: TEAM_BANNER_HEIGHT,
};
});
}
return team_dropdown_opts;
};
let driver_dropdown_opts: DropdownOption[] | null = null;
/**
* Generates a list of [DropdownOptions] for a <Dropdown> component.
* Cached until page reload.
*/
export const driver_dropdown_options = (drivers: Driver[]): DropdownOption[] => {
if (!driver_dropdown_opts) {
console.log("driver_dropdown_options");
driver_dropdown_opts = drivers.map((driver: Driver) => {
export const driver_dropdown_options = (drivers: Driver[]): DropdownOption[] =>
drivers
.sort((a: Driver, b: Driver) => a.lastname.localeCompare(b.lastname))
.map((driver: Driver) => {
return {
label: driver.code,
label: `${driver.firstname} ${driver.lastname}`,
value: driver.id,
icon_url: driver.headshot_url,
icon_width: DRIVER_HEADSHOT_WIDTH,
icon_height: DRIVER_HEADSHOT_HEIGHT,
};
});
}
return driver_dropdown_opts;
};
let race_dropdown_opts: DropdownOption[] | null = null;
/**
* Generates a list of [DropdownOptions] for a <Dropdown> component.
* Cached until page reload.
*/
export const race_dropdown_options = (races: Race[]): DropdownOption[] => {
if (!race_dropdown_opts) {
console.log("race_dropdown_options");
race_dropdown_opts = races.map((race: Race) => {
export const race_dropdown_options = (races: Race[]): DropdownOption[] =>
races
.sort((a: Race, b: Race) => a.step - b.step)
.map((race: Race) => {
return {
label: race.name,
value: race.id,
@ -73,7 +56,3 @@ export const race_dropdown_options = (races: Race[]): DropdownOption[] => {
icon_height: RACE_PICTOGRAM_HEIGHT,
};
});
}
return race_dropdown_opts;
};

375
src/lib/fetch.ts Normal file
View File

@ -0,0 +1,375 @@
import { get } from "svelte/store";
import { pb, pbUser } from "./pocketbase";
import type {
CurrentPickedUser,
Driver,
Graphic,
Hottake,
Race,
RacePick,
RacePickPoints,
RacePickPointsAcc,
RacePickPointsTotal,
RaceResult,
ScrapedDriverStanding,
ScrapedRaceResult,
ScrapedRaceResultAcc,
ScrapedStartingGrid,
ScrapedTeamStanding,
SeasonPick,
SeasonPickedUser,
Substitution,
Team,
User,
} from "./schema";
/**
* Fetch all [Graphics] from the database with file URLs
*/
export const fetch_graphics = async (fetch: (_: any) => Promise<Response>): Promise<Graphic[]> => {
const graphics: Graphic[] = await pb.collection("graphics").getFullList({ fetch: fetch });
graphics.map((graphic: Graphic) => {
graphic.file_url = pb.files.getURL(graphic, graphic.file);
});
return graphics;
};
/**
* Fetch all [Teams] (sorted ascending by name) from the database with file URLs for banners/logos
*/
export const fetch_teams = async (fetch: (_: any) => Promise<Response>): Promise<Team[]> => {
const teams: Team[] = await pb.collection("teams").getFullList({
sort: "+name",
fetch: fetch,
});
teams.map((team: Team) => {
team.banner_url = pb.files.getURL(team, team.banner);
team.logo_url = pb.files.getURL(team, team.logo);
});
return teams;
};
/**
* Fetch all [Drivers] (sorted ascending by code) from the database with file URLs for headshots
*/
export const fetch_drivers = async (fetch: (_: any) => Promise<Response>): Promise<Driver[]> => {
const drivers: Driver[] = await pb.collection("drivers").getFullList({
sort: "+code",
fetch: fetch,
});
drivers.map((driver: Driver) => {
driver.headshot_url = pb.files.getURL(driver, driver.headshot);
});
return drivers;
};
/**
* Fetch all [Races] (sorted ascending by step) from the database with file URLs for pictograms
*/
export const fetch_races = async (fetch: (_: any) => Promise<Response>): Promise<Race[]> => {
const races: Race[] = await pb.collection("races").getFullList({
sort: "+step",
fetch: fetch,
});
races.map((race: Race) => {
race.pictogram_url = pb.files.getURL(race, race.pictogram);
});
return races;
};
/**
* Fetch all [Substitutions] (sorted ascending by race step) from the database
*/
export const fetch_substitutions = async (
fetch: (_: any) => Promise<Response>,
): Promise<Substitution[]> => {
const substitutions: Substitution[] = await pb.collection("substitutions").getFullList({
expand: "race",
fetch: fetch,
});
// Sort by race step (ascending)
substitutions.sort((a: Substitution, b: Substitution) => a.expand.race.step - b.expand.race.step);
return substitutions;
};
/**
* Fetch all [RaceResults] (sorted descending by race step - newest first) from the database
*/
export const fetch_raceresults = async (
fetch: (_: any) => Promise<Response>,
): Promise<RaceResult[]> => {
const raceresults: RaceResult[] = await pb
.collection("raceresultsdesc")
.getFullList({ fetch: fetch });
return raceresults;
};
/**
* Fetch all [Users] (sorted ascending by username) with file URLs for avatars
*/
export const fetch_users = async (fetch: (_: any) => Promise<Response>): Promise<User[]> => {
const users: User[] = await pb
.collection("users")
.getFullList({ fetch: fetch, sort: "+username" });
users.map((user: User) => {
if (user.avatar) {
user.avatar_url = pb.files.getURL(user, user.avatar);
}
});
return users;
};
/**
* Fetch the first [Race] without result from the database with file URL for the pictogram
*/
export const fetch_currentrace = async (
fetch: (_: any) => Promise<Response>,
): Promise<Race | null> => {
const currentrace: Race[] = await pb.collection("currentrace").getFullList({ fetch: fetch });
// The currentrace collection either has a single or no entries
if (currentrace.length == 0) return null;
currentrace[0].pictogram_url = pb.files.getURL(currentrace[0], currentrace[0].pictogram);
return currentrace[0];
};
/**
* Fetch all visible [RacePicks] from the database
*/
export const fetch_visibleracepicks = async (
fetch: (_: any) => Promise<Response>,
): Promise<RacePick[]> => {
const racepicks: RacePick[] = await pb
.collection("visibleracepicks")
.getFullList({ fetch: fetch, expand: "user" });
return racepicks;
};
/**
* Fetch the [RacePick] for the current race by the current user from the database
*/
export const fetch_currentracepick = async (
fetch: (_: any) => Promise<Response>,
): Promise<RacePick | undefined> => {
const user: User | undefined = get(pbUser);
if (!user) return undefined;
const currentpickeduser: CurrentPickedUser = await pb
.collection("currentpickedusers")
.getOne(user.id, { fetch: fetch });
if (!currentpickeduser.picked) return undefined;
const racepick: RacePick = await pb
.collection("racepicks")
.getOne(currentpickeduser.picked, { fetch: fetch });
return racepick;
};
/**
* Fetch all visible [SeasonPicks] from the database
*/
export const fetch_visibleseasonpicks = async (
fetch: (_: any) => Promise<Response>,
): Promise<SeasonPick[]> => {
const seasonpicks: SeasonPick[] = await pb
.collection("visibleseasonpicks")
.getFullList({ fetch: fetch, expand: "user" });
return seasonpicks;
};
/**
* Fetch all [Hottakes] from the databse
*/
export const fetch_hottakes = async (fetch: (_: any) => Promise<Response>): Promise<Hottake[]> => {
const hottakes: Hottake[] = await pb
.collection("hottakes")
.getFullList({ fetch: fetch, expand: "user" });
return hottakes;
};
/**
* Fetch the [SeasonPick] by the current user from the database
*/
export const fetch_currentseasonpick = async (
fetch: (_: any) => Promise<Response>,
): Promise<SeasonPick | undefined> => {
const user: User | undefined = get(pbUser);
if (!user) return undefined;
const seasonpickeduser: CurrentPickedUser = await pb
.collection("seasonpickedusers")
.getOne(user.id, { fetch: fetch });
if (!seasonpickeduser.picked) return undefined;
const seasonpick: SeasonPick = await pb
.collection("seasonpicks")
.getOne(seasonpickeduser.picked, { fetch: fetch });
return seasonpick;
};
/**
* Fetch all [Users] (with the extra field "picked" that is truthy
* if the user already picked for the current race)
* for the current race with file URLs for the avatars
*/
export const fetch_currentpickedusers = async (
fetch: (_: any) => Promise<Response>,
): Promise<CurrentPickedUser[]> => {
const currentpickedusers: CurrentPickedUser[] = await pb
.collection("currentpickedusers")
.getFullList({ fetch: fetch });
currentpickedusers.map((currentpickeduser: CurrentPickedUser) => {
if (currentpickeduser.avatar) {
currentpickeduser.avatar_url = pb.files.getURL(currentpickeduser, currentpickeduser.avatar);
}
});
return currentpickedusers;
};
/**
* Fetch all [Users] (with the extra field "picked" that is truthy
* if the user already picked for the season) with file URLs for the avatars
*/
export const fetch_seasonpickedusers = async (
fetch: (_: any) => Promise<Response>,
): Promise<SeasonPickedUser[]> => {
const seasonpickedusers: SeasonPickedUser[] = await pb
.collection("seasonpickedusers")
.getFullList({ fetch: fetch });
seasonpickedusers.map((seasonpickeduser: SeasonPickedUser) => {
if (seasonpickeduser.avatar) {
seasonpickeduser.avatar_url = pb.files.getURL(seasonpickeduser, seasonpickeduser.avatar);
}
});
return seasonpickedusers;
};
/**
* Fetch all [RacePickPoints] from the database
*/
export const fetch_racepickpoints = async (
fetch: (_: any) => Promise<Response>,
): Promise<RacePickPoints[]> => {
const racepickpoints: RacePickPoints[] = await pb
.collection("racepickpoints")
.getFullList({ fetch: fetch });
return racepickpoints;
};
/**
* Fetch all [RacePickPointsAcc] from the database, ordered ascendingly by step.
*/
export const fetch_racepickpointsacc = async (
fetch: (_: any) => Promise<Response>,
): Promise<RacePickPointsAcc[]> => {
const racepickpointsacc: RacePickPointsAcc[] = await pb
.collection("racepickpointsacc")
.getFullList({ fetch: fetch });
return racepickpointsacc;
};
/**
* Fetch all [RacePickPointsTotal] from the database, ordered descendingly by total points.
*/
export const fetch_racepickpointstotal = async (
fetch: (_: any) => Promise<Response>,
): Promise<RacePickPointsTotal[]> => {
const racepickpointstotal: RacePickPointsTotal[] = await pb
.collection("racepickpointstotal")
.getFullList({ fetch: fetch });
return racepickpointstotal;
};
/**
* Fetch all [ScrapedDriverStandings] from the database, ordered ascendingly by position.
*/
export const fetch_scraped_driverstandings = async (
fetch: (_: any) => Promise<Response>,
): Promise<ScrapedDriverStanding[]> => {
const scraped_driverstandings: ScrapedDriverStanding[] = await pb
.collection("scraped_driverstandings")
.getFullList({ fetch: fetch, sort: "+position" });
return scraped_driverstandings;
};
/**
* Fetch all [ScrapedTeamStandings] from the database, ordered ascendingly by position.
*/
export const fetch_scraped_teamstandings = async (
fetch: (_: any) => Promise<Response>,
): Promise<ScrapedTeamStanding[]> => {
const scraped_teamstandings: ScrapedTeamStanding[] = await pb
.collection("scraped_teamstandings")
.getFullList({ fetch: fetch, sort: "+position" });
return scraped_teamstandings;
};
/**
* Fetch all [ScrapedStartingGrids] from the database, ordered descendingly by race step.
*/
export const fetch_scraped_startinggrids = async (
fetch: (_: any) => Promise<Response>,
): Promise<ScrapedStartingGrid[]> => {
const scraped_startinggrids: ScrapedStartingGrid[] = await pb
.collection("scraped_startinggrids")
.getFullList({ fetch: fetch, sort: "-race_step,+position" });
return scraped_startinggrids;
};
/**
* Fetch all [ScrapedRaceResults] from the database, ordered descendingly by race step.
*/
export const fetch_scraped_raceresults = async (
fetch: (_: any) => Promise<Response>,
): Promise<ScrapedRaceResult[]> => {
const scraped_raceresults: ScrapedRaceResult[] = await pb
.collection("scraped_raceresults")
.getFullList({ fetch: fetch, sort: "-race_step,+position" });
return scraped_raceresults;
};
/**
* Fetch all [ScrapedRaceResultsAcc] from the database, ordered ascendingly by race step.
*/
export const fetch_scraped_raceresultsacc = async (
fetch: (_: any) => Promise<Response>,
): Promise<ScrapedRaceResultAcc[]> => {
const scraped_raceresultsacc: ScrapedRaceResultAcc[] = await pb
.collection("scraped_raceresultsacc")
.getFullList({ fetch: fetch, sort: "+race_step" });
return scraped_raceresultsacc;
};

View File

@ -1,87 +0,0 @@
import { error } from "@sveltejs/kit";
/**
* Obtain the value of the key "id" and remove it from the FormData.
* Throws SvelteKit error(400) if "id" is not found.
*/
export const form_data_get_and_remove_id = (data: FormData): string => {
const id: string | undefined = data.get("id")?.toString();
if (!id) error(400, "Missing ID");
data.delete("id");
return id;
};
/**
* Remove empty fields (even whitespace) and empty files from FormData objects.
* Keys listed in [except] won't be removed although they are empty.
*/
export const form_data_clean = (data: FormData, except: string[] = []): FormData => {
let delete_keys: string[] = [];
for (const [key, value] of data.entries()) {
if (
!except.includes(key) &&
(value === null ||
value === undefined ||
(typeof value === "string" && value.trim() === "") ||
(typeof value === "object" && "size" in value && value.size === 0))
) {
delete_keys.push(key);
}
}
delete_keys.forEach((key) => {
data.delete(key);
});
return data;
};
/**
* Remove the specified [keys] from the [data] object.
*/
export const form_data_remove = (data: FormData, keys: string[]): void => {
let delete_keys: string[] = [];
for (const [key, value] of data.entries()) {
if (keys.includes(key)) {
delete_keys.push(key);
}
}
delete_keys.forEach((key) => {
data.delete(key);
});
};
/**
* Throws SvelteKit error(400) if form_data does not contain key.
*/
export const form_data_ensure_key = (data: FormData, key: string): void => {
if (!data.get(key)) error(400, `Key "${key}" missing from form_data!`);
};
/**
* Throws SvelteKit error(400) if form_data does not contain all keys.
*/
export const form_data_ensure_keys = (data: FormData, keys: string[]): void => {
keys.map((key) => form_data_ensure_key(data, key));
};
/**
* Modify a single [FormData] element to satisfy PocketBase's date format.
* Date format: 2024-12-31 12:00:00.000Z
*/
export const form_data_fix_date = (data: FormData, key: string): boolean => {
const value: string | undefined = data.get(key)?.toString();
if (!value) return false;
const date: string = new Date(value).toISOString();
data.set(key, date);
return true;
};
export const form_data_fix_dates = (data: FormData, keys: string[]): boolean[] => {
return keys.map((key) => form_data_fix_date(data, key));
};

88
src/lib/pocketbase.ts Normal file
View File

@ -0,0 +1,88 @@
import Pocketbase, { type RecordModel, type RecordSubscription } from "pocketbase";
import type { Graphic, User } from "$lib/schema";
import { env } from "$env/dynamic/public";
import { invalidate } from "$app/navigation";
import { get, writable, type Writable } from "svelte/store";
export let pb = new Pocketbase(env.PUBLIC_PBURL || "http://192.168.86.50:8090");
// Keep this in a writable store, because this is basically a $state.
// We can't use $state in non-component files though.
export let pbUser: Writable<User | undefined> = writable(undefined);
const update_user = async (record: RecordModel): Promise<void> => {
let avatar_url: string;
if (record.avatar) {
avatar_url = pb.files.getURL(record, record.avatar);
} else {
const driver_headshot_template: Graphic = await pb
.collection("graphics")
.getFirstListItem('name="driver_headshot_template"');
avatar_url = pb.files.getURL(driver_headshot_template, driver_headshot_template.file);
}
pbUser.set({
id: record.id,
verified: record.verified,
username: record.username,
firstname: record.firstname,
email: record.email ?? "",
avatar: record.avatar,
avatar_url: avatar_url,
admin: record.admin,
} as User);
};
// Update the pbUser object when authStore changes (e.g. after logging in)
pb.authStore.onChange(async () => {
if (!pb.authStore.isValid) {
console.log("pb.authStore is invalid: Setting pbUser to undefined");
pbUser.set(undefined);
return;
}
if (!pb.authStore.record) {
console.log("pb.authStore.record is null: Setting pbUser to undefined");
pbUser.set(undefined);
return;
}
await update_user(pb.authStore.record);
console.log("Updating pbUser...");
console.dir(get(pbUser), { depth: null });
}, true);
export const clear_auth = (): void => {
console.log("Cleared pb.authStore");
pb.authStore.clear();
};
export const refresh_auth = async (): Promise<void> => {
if (pb.authStore.isValid) {
console.log("Refreshed pb.authStore");
await pb.collection("users").authRefresh();
} else {
console.log("pb.autStore is invalid: Did not refresh pb.authStore");
pb.authStore.clear();
}
};
/**
* Subscribe to PocketBase realtime collections
*/
export const subscribe = (collections: string[]) => {
collections.forEach((collection: string) => {
pb.collection(collection).subscribe("*", (event: RecordSubscription<RecordModel>) => {
invalidate(`data:${collection}`);
});
});
};
/**
* Unsubscribe from PocketBase realtime collections
*/
export const unsubscribe = (collections: string[]) => {
collections.forEach((collection: string) => {
pb.collection(collection).unsubscribe("*");
});
};

View File

@ -11,9 +11,11 @@ export interface Graphic {
export interface User {
id: string;
verified: boolean;
username: string;
firstname: string;
avatar: string;
email?: string;
avatar?: string;
avatar_url?: string;
admin: boolean;
}
@ -39,9 +41,7 @@ export interface Driver {
headshot_url?: string;
team: string;
active: boolean;
expand: {
team: Team;
};
started_active: boolean;
}
export interface Race {
@ -80,6 +80,28 @@ export interface RacePick {
};
}
export interface SeasonPick {
id: string;
user: string;
hottake: string;
wdcwinner: string;
wccwinner: string;
mostovertakes: string;
mostdnfs: string;
doohanstarts: number;
teamwinners: string[];
podiums: string[];
expand: {
user: User;
};
}
export interface Hottake {
id: string;
user: string;
hottake: string;
}
export interface RaceResult {
id: string;
race: string;
@ -94,5 +116,83 @@ export interface CurrentPickedUser {
avatar: string;
avatar_url?: string;
admin: boolean;
picked: boolean;
picked: string | null;
}
export interface SeasonPickedUser {
id: string;
username: string;
firstname: string;
avatar: string;
avatar_url?: string;
admin: boolean;
picked: string | null;
}
// Points Data
export interface RacePickPoints {
id: string;
user: string;
step: number;
pxx_points: number;
dnf_points: number;
}
export interface RacePickPointsAcc {
id: string;
user: string;
step: number;
acc_pxx_points: number;
acc_dnf_points: number;
acc_points: number;
}
export interface RacePickPointsTotal {
id: string;
user: string;
total_pxx_points: number;
total_dnf_points: number;
total_points: number;
total_points_per_pick: number;
}
// Scraped Data
export interface ScrapedStartingGrid {
id: string;
race_step: number; // This maps to races
driver_code: string; // This maps to drivers
position: number;
time: string;
}
export interface ScrapedRaceResult {
id: string;
race_step: number; // This maps to races
driver_code: string; // This maps to drivers
position: number;
status: string; // Either contains time to leader or DNF/DSQ...
points: number;
}
export interface ScrapedRaceResultAcc {
id: string;
race_step: number; // This maps to races
driver_code: string; // This maps to drivers
acc_points: number;
}
export interface ScrapedDriverStanding {
id: string;
driver_code: string; // This maps to drivers
position: number;
points: number;
}
export interface ScrapedTeamStanding {
id: string;
team_fullname: string; // TODO: This does NOT map to teams! Add fullname to team data!
position: number;
points: number;
}

View File

@ -7,17 +7,21 @@ import sharp from "sharp";
*/
export const image_to_avif = async (
data: ArrayBuffer,
width: number | undefined = undefined,
height: number | undefined = undefined,
width?: number,
height?: number,
quality: number = 50,
effort: number = 4,
): Promise<Blob> => {
console.log(
`Compressing ${data.byteLength} Bytes to ${width ?? -1}x${height ?? -1} avif with quality ${quality} and effort ${effort}...`,
);
const compressed: Buffer = await sharp(data)
.resize(width, height)
.avif({ quality: quality, effort: effort })
.toBuffer();
console.log(`image_to_avif: ${data.byteLength} Bytes -> ${compressed.length} Bytes`);
console.log(`Compressed ${data.byteLength} Bytes to ${compressed.length} Bytes`);
return new Blob([compressed]);
};

181
src/lib/server/scrape.ts Normal file
View File

@ -0,0 +1,181 @@
import type {
ScrapedDriverStanding,
ScrapedStartingGrid,
ScrapedRaceResult,
ScrapedTeamStanding,
} from "$lib/schema";
import * as cheerio from "cheerio";
// TODO: Validate the generated stuff
export const base_url: string = "https://www.formula1.com/en/results/2025";
/**
* Returns a list of links to all past races of the season,
* based on official f1.com data.
*/
export const scrape_race_links = async (): Promise<string[]> => {
const races_response = await fetch(`${base_url}/races`);
const races_text = await races_response.text();
const $ = cheerio.load(races_text);
const race_links: string[] = [];
$("tbody > tr > td:first-child > p > a[href]", "table.f1-table").each((_, element) => {
const href: string = element.attribs["href"];
// Keks changed the link format, cut off the start
const substring: string = href.replace("/../../en/results/2025/", "");
race_links.push(substring);
});
console.log(`Found ${race_links.length} races...`);
console.log(race_links);
return race_links;
};
/**
* Returns a list of [ScrapedStartingGrids] for all races contained in [race_links],
* based on official f1.com data.
*/
export const scrape_starting_grids = async (
race_links: string[],
): Promise<ScrapedStartingGrid[]> => {
// Update the race_links to point to the qualifications
const starting_grid_links: string[] = race_links.map((link: string) =>
link.replace("/race-result", "/starting-grid"),
);
const starting_grids: ScrapedStartingGrid[] = [];
await Promise.all(
starting_grid_links.map(async (link: string, index: number) => {
console.log(`Fetching qualifying results from ${base_url}/${link}...`);
const starting_grids_response = await fetch(`${base_url}/${link}`);
const starting_grids_text = await starting_grids_response.text();
const $ = cheerio.load(starting_grids_text);
// Obtain the positions for this starting grid for each driver
$("tbody > tr", "table.f1-table").each((driver_index, element) => {
const $$ = cheerio.load(element);
let result: ScrapedStartingGrid = {
id: "",
race_step: index + 1,
driver_code: $$(
"td:nth-child(3) > p > span:first-child > span:last-child > span:last-child",
).text(),
position: driver_index + 1, // parseInt($$("td:nth-child(1) > p").text()),
time: $$("td:nth-child(5) > p").text(),
};
starting_grids.push(result);
});
}),
);
console.log(`Scraped ${starting_grids.length} starting grids...`);
// console.log(starting_grids);
return starting_grids;
};
/**
* Returns a list of [ScrapedRaceResults] for all races contained in [race_links],
* based on official f1.com data.
*/
export const scrape_race_results = async (race_links: string[]): Promise<ScrapedRaceResult[]> => {
const race_results: ScrapedRaceResult[] = [];
await Promise.all(
race_links.map(async (link: string, index: number) => {
console.log(`Fetching race results from ${base_url}/${link}...`);
const race_response = await fetch(`${base_url}/${link}`);
const race_text = await race_response.text();
const $ = cheerio.load(race_text);
// Obtain the results for this race for each driver
$("tbody > tr", "table.f1-table").each((driver_index, element) => {
const $$ = cheerio.load(element);
let result: ScrapedRaceResult = {
id: "",
race_step: index + 1,
driver_code: $$(
"td:nth-child(3) > p > span:first-child > span:last-child > span:last-child",
).text(),
position: driver_index + 1, // parseInt($$("td:nth-child(1) > p").text()),
status: $$("td:nth-child(6) > p").text(),
points: parseInt($$("td:nth-child(7) > p").text()),
};
// DSQ'd/DNF'd drivers have NaN positions
// if (Number.isNaN(result.position)) {
// result.position = driver_index;
// }
race_results.push(result);
});
}),
);
console.log(`Scraped ${race_results.length} race results...`);
// console.log(race_results);
return race_results;
};
/**
* Returns a list of [ScrapedDriverStandings], based on official f1.com data.
*/
export const scrape_driver_standings = async (): Promise<ScrapedDriverStanding[]> => {
const standings_response = await fetch(`${base_url}/drivers`);
const standings_text = await standings_response.text();
const $ = cheerio.load(standings_text);
const driver_standings: ScrapedDriverStanding[] = [];
$("tbody > tr", "table.f1-table").each((driver_index, element) => {
const $$ = cheerio.load(element);
let standing: ScrapedDriverStanding = {
id: "",
driver_code: $$("td:nth-child(2) > p > a > span:last-child > span:last-child").text(),
position: driver_index + 1,
points: parseInt($$("td:nth-child(5) > p").text()),
};
driver_standings.push(standing);
});
console.log(`Scraped ${driver_standings.length} driver standings...`);
// console.log(driver_standings);
return driver_standings;
};
/**
* Returns a list of [ScrapedTeamStandings], based on official f1.com data.
*/
export const scrape_team_standings = async (): Promise<ScrapedTeamStanding[]> => {
const standings_response = await fetch(`${base_url}/team`);
const standings_text = await standings_response.text();
const $ = cheerio.load(standings_text);
const team_standings: ScrapedTeamStanding[] = [];
$("tbody > tr", "table.f1-table").each((team_index, element) => {
const $$ = cheerio.load(element);
let standing: ScrapedTeamStanding = {
id: "",
team_fullname: $$("td:nth-child(2) > p > a").text(),
position: team_index + 1,
points: parseInt($$("td:nth-child(3) > p").text()),
};
team_standings.push(standing);
});
console.log(`Scraped ${team_standings.length} team standings...`);
// console.log(team_standings);
return team_standings;
};

63
src/lib/toast.ts Normal file
View File

@ -0,0 +1,63 @@
import type { ToastSettings } from "@skeletonlabs/skeleton";
export const get_info_toast = (
message: string,
timeout: number | null = 2000,
action_label: string | undefined = undefined,
action_response: (() => void) | undefined = undefined,
): ToastSettings =>
get_toast(message, "variant-filled-tertiary", timeout, action_label, action_response);
export const get_warning_toast = (
message: string,
timeout: number | null = 2000,
action_label: string | undefined = undefined,
action_response: (() => void) | undefined = undefined,
): ToastSettings =>
get_toast(message, "variant-filled-secondary", timeout, action_label, action_response);
export const get_error_toast = (
message: string,
timeout: number | null = 2000,
action_label: string | undefined = undefined,
action_response: (() => void) | undefined = undefined,
): ToastSettings =>
get_toast(message, "variant-filled-primary", timeout, action_label, action_response);
/**
* Utility function to create [ToastSettings].
* If [timeout] is defined, the toast will disappear automatically and the dismiss-button will be hidden.
* If [timeout] is undefined, the toast will stay until dismissed from the dismiss-button.
* If [action_label] and [action_response] are defined, a callback function will be executed after accepting.
* In this case, [timeout] behaves as if undefined.
*/
const get_toast = (
message: string,
background: string,
timeout: number | null = 2000,
action_label: string | undefined = undefined,
action_response: (() => void) | undefined = undefined,
): ToastSettings => {
return {
message,
background,
// If we have a timeout and no action, dismiss is hidden
hideDismiss: !!timeout && (!action_label || !action_response),
// If we have a timeout and no action, the timeout is used
timeout: !!timeout && (!action_label || !action_response) ? timeout : undefined,
// If we have a timeout and no action, autohide is true
autohide: !!timeout && (!action_label || !action_response),
// If we have a label and a response, use the action
action:
!!action_label && !!action_response
? {
label: action_label,
response: action_response,
}
: undefined,
};
};

View File

@ -1,88 +0,0 @@
import type { Driver, Graphic, Race, Substitution, Team } from "$lib/schema";
import type { LayoutServerLoad } from "./$types";
// On each page load (every route), this function runs serverside.
// The "locals.user" object is only available on the server,
// since it's populated inside hooks.server.ts per request.
// It will populate the "user" attribute of each page's "data" object,
// so each page has access to the current user (or knows if no one is signed in).
export const load: LayoutServerLoad = ({ locals }) => {
const fetch_graphics = async (): Promise<Graphic[]> => {
const graphics: Graphic[] = await locals.pb
.collection("graphics")
.getFullList({ fetch: fetch });
graphics.map((graphic: Graphic) => {
graphic.file_url = locals.pb.files.getURL(graphic, graphic.file);
});
return graphics;
};
const fetch_teams = async (): Promise<Team[]> => {
const teams: Team[] = await locals.pb.collection("teams").getFullList({
sort: "+name",
fetch: fetch,
});
teams.map((team: Team) => {
team.banner_url = locals.pb.files.getURL(team, team.banner);
team.logo_url = locals.pb.files.getURL(team, team.logo);
});
return teams;
};
const fetch_drivers = async (): Promise<Driver[]> => {
const drivers: Driver[] = await locals.pb.collection("drivers").getFullList({
sort: "+code",
fetch: fetch,
});
drivers.map((driver: Driver) => {
driver.headshot_url = locals.pb.files.getURL(driver, driver.headshot);
});
return drivers;
};
const fetch_races = async (): Promise<Race[]> => {
const races: Race[] = await locals.pb.collection("races").getFullList({
sort: "+step",
fetch: fetch,
});
races.map((race: Race) => {
race.pictogram_url = locals.pb.files.getURL(race, race.pictogram);
});
return races;
};
const fetch_substitutions = async (): Promise<Substitution[]> => {
const substitutions: Substitution[] = await locals.pb.collection("substitutions").getFullList({
expand: "race",
fetch: fetch,
});
// Sort by race step (ascending)
substitutions.sort(
(a: Substitution, b: Substitution) => a.expand.race.step - b.expand.race.step,
);
return substitutions;
};
return {
// User information
user: locals.user,
admin: locals.user?.admin ?? false,
// Return static data asynchronously
graphics: fetch_graphics(),
teams: fetch_teams(),
drivers: fetch_drivers(),
races: fetch_races(),
substitutions: fetch_substitutions(),
};
};

View File

@ -1,10 +1,8 @@
<script lang="ts">
import "../app.css";
import type { Snippet } from "svelte";
import { onDestroy, onMount, type Snippet } from "svelte";
import type { LayoutData } from "./$types";
import { page } from "$app/stores";
import { page } from "$app/state";
import {
Button,
MenuDrawerIcon,
@ -19,9 +17,11 @@
NameIcon,
RacePickCard,
RaceResultCard,
SeasonPickCard,
EMailIcon,
TeamSwitchCard,
} from "$lib/components";
import { get_avatar_preview_event_handler } from "$lib/image";
import {
AppBar,
storePopup,
@ -29,6 +29,7 @@
Drawer,
getDrawerStore,
Modal,
Toast,
getModalStore,
type DrawerSettings,
Avatar,
@ -36,8 +37,18 @@
type DrawerStore,
type ModalStore,
type ModalComponent,
type ToastStore,
getToastStore,
SlideToggle,
} from "@skeletonlabs/skeleton";
import { computePosition, autoUpdate, offset, shift, flip, arrow } from "@floating-ui/dom";
import { invalidate } from "$app/navigation";
import { get_error_toast, get_info_toast, get_warning_toast } from "$lib/toast";
import { clear_auth, pb, pbUser, refresh_auth, subscribe, unsubscribe } from "$lib/pocketbase";
import { AVATAR_HEIGHT, AVATAR_WIDTH } from "$lib/config";
import { error } from "@sveltejs/kit";
import type { User } from "$lib/schema";
import type { RecordModel } from "pocketbase";
let { data, children }: { data: LayoutData; children: Snippet } = $props();
@ -48,14 +59,19 @@
const modalStore: ModalStore = getModalStore();
const modalRegistry: Record<string, ModalComponent> = {
// Card data (e.g. team, driver etc.) is passed using $modalStore[0].meta
teamCard: { ref: TeamCard },
driverCard: { ref: DriverCard },
raceCard: { ref: RaceCard },
racePickCard: { ref: RacePickCard },
raceResultCard: { ref: RaceResultCard },
seasonPickCard: { ref: SeasonPickCard },
substitutionCard: { ref: SubstitutionCard },
teamCard: { ref: TeamCard },
teamSwitchCard: { ref: TeamSwitchCard },
};
// Toast config
const toastStore: ToastStore = getToastStore();
// Drawer config
const drawerStore: DrawerStore = getDrawerStore();
let drawerOpen: boolean = false;
@ -85,7 +101,7 @@
const drawer_settings_base: DrawerSettings = {
position: "top",
height: "auto",
padding: "lg:px-96 pt-14", // pt-14 is 56px, so its missing 4px for the 60px navbar...
padding: "2xl:px-96 pt-14", // pt-14 is 56px, so its missing 4px for the 60px navbar...
bgDrawer: "bg-surface-100",
duration: 150,
};
@ -124,12 +140,228 @@
// Popups config
storePopup.set({ computePosition, autoUpdate, offset, shift, flip, arrow });
// Reactive state
let username_value: string = $state($pbUser?.username ?? "");
let firstname_value: string = $state($pbUser?.firstname ?? "");
let email_value: string = $state($pbUser?.email ?? "");
let password_value: string = $state("");
let avatar_value: FileList | undefined = $state();
let registration_mode: boolean = $state(false);
// Add "Enter" event listeners for login/register text inputs
const enter_handler = (event: KeyboardEvent) => {
if (event.key === "Enter") {
// Cancel the default action, if needed
event.preventDefault();
registration_mode ? update_profile(true) : login();
}
};
// Database actions
const login = async (): Promise<void> => {
if (!username_value || username_value.trim() === "") {
toastStore.trigger(get_error_toast("Please enter your username!"));
return;
}
if (!password_value || password_value.trim() === "") {
toastStore.trigger(get_error_toast("Please enter your password!"));
return;
}
try {
await pb.collection("users").authWithPassword(username_value, password_value);
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
await invalidate("data:user");
drawerStore.close();
username_value = $pbUser?.username ?? "";
firstname_value = $pbUser?.firstname ?? "";
email_value = $pbUser?.email ?? "";
password_value = "";
};
const logout = async (): Promise<void> => {
clear_auth();
await invalidate("data:user");
drawerStore.close();
username_value = "";
firstname_value = "";
email_value = "";
password_value = "";
};
const forgot_password = async (): Promise<void> => {
if (!username_value || username_value.trim() === "") {
toastStore.trigger(get_error_toast("Please enter a username!"));
return;
}
try {
const user: RecordModel = await pb
.collection("users")
.getFirstListItem(`username="${username_value}"`);
if (!user.email) {
toastStore.trigger(get_error_toast("You did not set a recovery e-mail address!"));
return;
}
await pb.collection("users").requestPasswordReset(user.email);
toastStore.trigger(get_info_toast("Check your inbox!"));
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
const update_profile = (create?: boolean): (() => Promise<void>) => {
const handler = async (): Promise<void> => {
// Avatar handling
let avatar_avif: Blob | undefined = undefined;
const avatar_file: File | undefined =
avatar_value && avatar_value.length === 1 ? avatar_value[0] : undefined;
if (avatar_file) {
const avatar_formdata: FormData = new FormData();
avatar_formdata.append("image", avatar_file);
avatar_formdata.append("width", AVATAR_WIDTH.toString());
avatar_formdata.append("height", AVATAR_HEIGHT.toString());
try {
const response = await fetch("/api/compress", {
method: "POST",
body: avatar_formdata,
});
if (!response.ok) {
error(500, "Compression failed.");
}
avatar_avif = await response.blob();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
}
try {
if (create) {
if (!username_value || username_value.trim() === "") {
toastStore.trigger(get_error_toast("Please enter a username!"));
return;
}
if (!firstname_value || firstname_value.trim() === "") {
toastStore.trigger(get_error_toast("Please enter your first name!"));
return;
}
if (!email_value || email_value.trim() === "") {
toastStore.trigger(get_error_toast("Please enter your e-mail address!"));
return;
}
if (!password_value || password_value.trim() === "") {
toastStore.trigger(get_error_toast("Please enter a password!"));
return;
}
await pb.collection("users").create({
username: username_value.trim(),
firstname: firstname_value.trim(),
email: email_value.trim(),
emailVisibility: true,
password: password_value.trim(),
passwordConfirm: password_value.trim(), // lol
admin: false,
});
await pb.collection("users").requestVerification(email_value.trim());
toastStore.trigger(get_info_toast("Check your inbox!"));
// Just in case
clear_auth();
await login();
} else {
if (!$pbUser?.id || $pbUser.id === "") {
toastStore.trigger(get_error_toast("Invalid user id!"));
return;
}
await pb.collection("users").update($pbUser.id, {
username: username_value.trim().length > 0 ? username_value.trim() : $pbUser.username,
firstname:
firstname_value.trim().length > 0 ? firstname_value.trim() : $pbUser.firstname,
avatar: avatar_avif,
});
if (email_value && email_value.trim() !== $pbUser.email) {
await pb.collection("users").requestEmailChange(email_value.trim());
// When changing the email address, the auth token is invalidated
await logout();
toastStore.trigger(get_info_toast("Check your inbox!"));
toastStore.trigger(
get_warning_toast("Please login AFTER confirming your e-mail address!", 5000),
);
}
drawerStore.close();
}
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
return handler;
};
// Real-time updates without reloading
onMount(() =>
subscribe([
"users",
"drivers",
"racepicks",
"raceresults",
"races",
"seasonpicks",
"substitutions",
"teams",
"scraped_startinggrids",
"scraped_raceresults",
"scraped_driverstandings",
"scraped_teamstandings",
// The view collections do not receive realtime events
]),
);
onDestroy(() =>
unsubscribe([
"users",
"drivers",
"racepicks",
"raceresults",
"races",
"seasonpicks",
"substitutions",
"teams",
"scraped_startinggrids",
"scraped_raceresults",
"scraped_driverstandings",
"scraped_teamstandings",
]),
);
</script>
<LoadingIndicator />
<Modal components={modalRegistry} regionBackdrop="!overflow-y-scroll" />
<Toast zIndex="z-[1000]" />
<Drawer zIndex="z-30">
<!-- Use p-3 because the drawer has a 5px overlap with the navbar -->
{#if $drawerStore.id === "menu_drawer"}
@ -152,6 +384,16 @@
<Button href="/rules" onclick={close_drawer} color="surface" width="w-full" shadow>
Rules
</Button>
<Button
href="https://gitea.vps.chriphost.de/christoph/svelte-formula11/projects/1"
onclick={close_drawer}
color="surface"
width="w-full"
shadow
newtab
>
Roadmap
</Button>
</div>
{:else if $drawerStore.id === "data_drawer"}
<!-- Data Drawer -->
@ -170,6 +412,15 @@
>
Season
</Button>
<Button
href="/data/official/driverstandings"
onclick={close_drawer}
color="surface"
width="w-full"
shadow
>
Official
</Button>
<Button href="/data/users" onclick={close_drawer} color="surface" width="w-full" shadow>
Users
</Button>
@ -179,96 +430,139 @@
<!-- Login Drawer -->
<!-- Login Drawer -->
<div class="flex flex-col gap-2 p-2 pt-3">
<h4 class="h4 select-none">Enter Username and Password</h4>
<form method="POST" class="contents">
<!-- Supply the pathname so the form can redirect to the current page. -->
<input type="hidden" name="redirect_url" value={$page.url.pathname} />
<Input name="username" placeholder="Username" autocomplete="username" required>
<UserIcon />
</Input>
<Input name="firstname" placeholder="First Name (leave empty for login)" autocomplete="off">
<div class="flex">
<h4 class="h4 select-none text-nowrap align-middle font-bold" style="line-height: 32px;">
Login or Register
</h4>
<div class="w-full"></div>
<div class="flex gap-2">
<span class="align-middle" style="line-height: 32px;">Login</span>
<SlideToggle
name="registrationmode"
background="bg-tertiary-500"
active="bg-tertiary-500"
bind:checked={registration_mode}
/>
<span class="align-middle" style="line-height: 32px;">Register</span>
</div>
</div>
<Input
bind:value={username_value}
placeholder="Username"
autocomplete="username"
minlength={3}
maxlength={10}
required
onkeypress={enter_handler}
>
<UserIcon />
</Input>
<div
class="{registration_mode
? ''
: 'mt-[-8px] h-0'} overflow-hidden transition-all duration-150 ease-out"
>
<Input
bind:value={firstname_value}
placeholder="First Name"
autocomplete="off"
tabindex={registration_mode ? 0 : -1}
onkeypress={enter_handler}
>
<NameIcon />
</Input>
<Input name="password" type="password" placeholder="Password" autocomplete="off" required>
<PasswordIcon />
</div>
<div
class="{registration_mode
? ''
: 'mt-[-8px] h-0'} overflow-hidden transition-all duration-150 ease-out"
>
<Input
id="login_email"
type="email"
bind:value={email_value}
placeholder="E-Mail"
autocomplete="email"
tabindex={registration_mode ? 0 : -1}
onkeypress={enter_handler}
>
<EMailIcon />
</Input>
<div class="flex justify-end gap-2">
<Button
formaction="/profile?/login"
onclick={close_drawer}
color="tertiary"
submit
shadow
>
Login
</Button>
<Button
formaction="/profile?/create_profile"
onclick={close_drawer}
color="tertiary"
submit
shadow
>
Register
</Button>
</div>
</form>
</div>
<Input
id="login_password"
bind:value={password_value}
type="password"
placeholder="Password"
autocomplete="off"
required
onkeypress={enter_handler}
>
<PasswordIcon />
</Input>
<div
class="{!registration_mode
? ''
: 'mt-[-8px] h-0'} flex w-full gap-2 overflow-hidden transition-all duration-150 ease-out"
>
<Button onclick={forgot_password} color="primary" width="w-full">Forgot Password</Button>
<Button onclick={login} color="tertiary" width="w-full" shadow>Login</Button>
</div>
<div
class="{registration_mode
? ''
: 'mt-[-8px] h-0'} w-full overflow-hidden transition-all duration-150 ease-out"
>
<Button onclick={update_profile(true)} color="tertiary" width="w-full" shadow>
Register
</Button>
</div>
</div>
{:else if $drawerStore.id === "profile_drawer" && data.user}
{:else if $drawerStore.id === "profile_drawer" && $pbUser}
<!-- Profile Drawer -->
<!-- Profile Drawer -->
<!-- Profile Drawer -->
<div class="flex flex-col gap-2 p-2 pt-3">
<h4 class="h4 select-none">Edit Profile</h4>
<form method="POST" enctype="multipart/form-data" class="contents">
<!-- Supply the pathname so the form can redirect to the current page. -->
<input type="hidden" name="redirect_url" value={$page.url.pathname} />
<input type="hidden" name="id" value={data.user.id} />
<Input
name="username"
value={data.user.username}
placeholder="Username"
autocomplete="username"
>
<UserIcon />
</Input>
<Input
name="firstname"
value={data.user.firstname}
placeholder="First Name"
autocomplete="off"
>
<NameIcon />
</Input>
<FileDropzone
name="avatar"
onchange={get_avatar_preview_event_handler("user_avatar_preview")}
>
<svelte:fragment slot="message"
><span class="font-bold">Upload Avatar</span></svelte:fragment
>
</FileDropzone>
<div class="flex justify-end gap-2">
<Button
formaction="/profile?/update_profile"
onclick={close_drawer}
color="secondary"
submit
shadow
>
Save Changes
</Button>
<Button
formaction="/profile?/logout"
onclick={close_drawer}
color="primary"
submit
shadow
>
Logout
</Button>
</div>
</form>
<h4 class="h4 select-none align-middle font-bold" style="line-height: 32px;">Edit Profile</h4>
<Input
bind:value={username_value}
maxlength={10}
placeholder="Username"
autocomplete="username"
>
<UserIcon />
</Input>
<Input bind:value={firstname_value} placeholder="First Name" autocomplete="off">
<NameIcon />
</Input>
<Input bind:value={email_value} placeholder="E-Mail" autocomplete="email">
<EMailIcon />
{#snippet tail()}
{#if $pbUser}
<div
class="input-group-shim select-none text-nowrap text-neutral-900
{$pbUser.verified ? 'bg-tertiary-500' : 'bg-primary-500'}"
>
{$pbUser.verified ? "Verified" : "Not Verified"}
</div>
{/if}
{/snippet}
</Input>
<FileDropzone
name="avatar"
bind:files={avatar_value}
onchange={get_avatar_preview_event_handler("user_avatar_preview")}
>
<svelte:fragment slot="message">
<span class="font-bold">Upload Avatar</span>
</svelte:fragment>
</FileDropzone>
<div class="flex justify-end gap-2">
<Button onclick={update_profile()} color="secondary" width="w-full" shadow>
Save Changes
</Button>
<Button onclick={logout} color="primary" width="w-full" shadow>Logout</Button>
</div>
</div>
{/if}
</Drawer>
@ -305,6 +599,14 @@
<Button href="/leaderboard" color="primary" activate_href>Leaderboard</Button>
<Button href="/statistics" color="primary" activate_href>Statistics</Button>
<Button href="/rules" color="primary" activate_href>Rules</Button>
<Button
href="https://gitea.vps.chriphost.de/christoph/svelte-formula11/projects/1"
color="primary"
activate_href
newtab
>
Roadmap
</Button>
</div>
<svelte:fragment slot="trail">
@ -313,17 +615,17 @@
<Button
color="primary"
onclick={data_drawer}
activate={$page.url.pathname.startsWith("/data")}>Data</Button
activate={page.url.pathname.startsWith("/data")}>Data</Button
>
{#if !data.user}
{#if !$pbUser}
<!-- Login drawer -->
<Button color="primary" onclick={login_drawer}>Login</Button>
{:else}
<!-- Profile drawer -->
<Avatar
id="user_avatar_preview"
src={data.user.avatar_url}
src={$pbUser?.avatar_url}
rounded="rounded-full"
width="w-10"
background="bg-primary-50"

28
src/routes/+layout.ts Normal file
View File

@ -0,0 +1,28 @@
import { fetch_graphics } from "$lib/fetch";
import { pbUser } from "$lib/pocketbase";
import { get } from "svelte/store";
import type { LayoutLoad } from "./$types";
import type { User } from "$lib/schema";
// This makes the page client-side rendered
export const ssr = false;
// On each page load (every route), this function runs serverside.
// The "locals.user" object is only available on the server,
// since it's populated inside hooks.server.ts per request.
// It will populate the "user" attribute of each page's "data" object,
// so each page has access to the current user (or knows if no one is signed in).
export const load: LayoutLoad = async ({ fetch, depends }) => {
depends("data:graphics");
return {
// NOTE: Don't do this! The user object will be updated after this, so it will be undefined!
//
// User information (synchronous)
// user: get(pbUser),
// admin: get(pbUser)?.admin ?? false,
// Return static data
graphics: await fetch_graphics(fetch),
};
};

View File

@ -0,0 +1,45 @@
import { image_to_avif } from "$lib/server/image";
import { error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
/**
* This route is available at /api/compress.
* It will return the image as a compressed [avif] [Blob].
* We need this because [sharp] is a server-side node library.
*/
export const POST: RequestHandler = async ({ request }) => {
console.log("Hit /api/compress...");
const data: FormData = await request.formData();
const image: File | undefined = data.has("image") ? (data.get("image") as File) : undefined;
const width: number | undefined = data.has("width")
? parseInt(data.get("width")?.toString() ?? "-1")
: undefined;
const height: number | undefined = data.has("height")
? parseInt(data.get("height")?.toString() ?? "-1")
: undefined;
const quality: number | undefined = data.has("quality")
? parseInt(data.get("quality")?.toString() ?? "-1")
: undefined;
const effort: number | undefined = data.has("effort")
? parseInt(data.get("effort")?.toString() ?? "-1")
: undefined;
if (!image) {
error(500, "Can't compress image without image data");
}
const compressedImage: Blob = await image_to_avif(
await image.arrayBuffer(),
width,
height,
quality,
effort,
);
return new Response(compressedImage, {
headers: {
"Content-Type": "image/avif",
},
});
};

View File

@ -0,0 +1,164 @@
import {
fetch_scraped_driverstandings,
fetch_scraped_raceresults,
fetch_scraped_startinggrids,
fetch_scraped_teamstandings,
} from "$lib/fetch";
import { pb } from "$lib/pocketbase";
import type {
ScrapedDriverStanding,
ScrapedRaceResult,
ScrapedStartingGrid,
ScrapedTeamStanding,
} from "$lib/schema";
import {
scrape_driver_standings,
scrape_race_links,
scrape_race_results,
scrape_starting_grids,
scrape_team_standings,
} from "$lib/server/scrape";
import type { RequestHandler } from "./$types";
/**
* This route is available at /api/scrape.
* It will fetch current statistics from f1.com and insert them into the database.
*/
// TODO: If this function aborts, it will leave the official data in an inconsistent state...
// Would be nice to use transactions for this, do I need to implement this as PB extension?
export const POST: RequestHandler = async ({ request }) => {
console.log("Fetching race results from f1.com...");
// Obtain the results for each race
const racelinks: string[] = await scrape_race_links();
const startinggrids: ScrapedStartingGrid[] = await scrape_starting_grids(racelinks);
const raceresults: ScrapedRaceResult[] = await scrape_race_results(racelinks);
const driverstandings: ScrapedDriverStanding[] = await scrape_driver_standings();
const teamstandings: ScrapedTeamStanding[] = await scrape_team_standings();
// Clear existing PocketBase data
// TODO: Do I really have to fetch everything just to delete it???
let deleted: number = 0;
const scraped_startinggrids: ScrapedStartingGrid[] = await fetch_scraped_startinggrids(fetch);
for (const grid of scraped_startinggrids) {
try {
await pb.collection("scraped_startinggrids").delete(grid.id);
} catch (e) {
console.log(e);
return new Response(); // TODO: Return error
}
deleted++;
}
console.log(`Deleted ${deleted}/${scraped_startinggrids.length} starting grids`);
deleted = 0;
const scraped_raceresults: ScrapedRaceResult[] = await fetch_scraped_raceresults(fetch);
for (const result of scraped_raceresults) {
try {
await pb.collection("scraped_raceresults").delete(result.id);
} catch (e) {
console.log(e);
return new Response(); // TODO: Return error
}
deleted++;
}
console.log(`Deleted ${deleted}/${scraped_raceresults.length} race results.`);
deleted = 0;
const scraped_driverstandings: ScrapedDriverStanding[] =
await fetch_scraped_driverstandings(fetch);
for (const standing of scraped_driverstandings) {
try {
await pb.collection("scraped_driverstandings").delete(standing.id);
} catch (e) {
console.log(e);
return new Response(); // TODO: Return error
}
deleted++;
}
console.log(`Deleted ${deleted}/${scraped_driverstandings.length} driver standings.`);
deleted = 0;
const scraped_teamstandings: ScrapedTeamStanding[] = await fetch_scraped_teamstandings(fetch);
for (const standing of scraped_teamstandings) {
try {
await pb.collection("scraped_teamstandings").delete(standing.id);
} catch (e) {
console.log(e);
return new Response(); // TODO: Return error
}
deleted++;
}
console.log(`Deleted ${deleted}/${scraped_teamstandings.length} team standings.`);
// Submit new data to PocketBase
let submissions: number = 0;
for (const grid of startinggrids) {
try {
// TODO: Authenticate this
await pb.collection("scraped_startinggrids").create(grid);
} catch (e) {
console.log("Error occured while submitting scraped data to PocketBase:");
console.log(e);
console.log("Error occured for this starting grid:");
console.log(grid);
console.log("Aborting submissions...");
return new Response(); // TODO: Return error
}
submissions++;
}
console.log(`Submitted ${submissions}/${startinggrids.length} starting grids.`);
submissions = 0;
for (const result of raceresults) {
try {
// TODO: Authenticate this
await pb.collection("scraped_raceresults").create(result);
} catch (e) {
console.log("Error occured while submitting scraped data to PocketBase:");
console.log(e);
console.log("Error occured for this race result:");
console.log(result);
console.log("Aborting submissions...");
return new Response(); // TODO: Return error
}
submissions++;
}
console.log(`Submitted ${submissions}/${raceresults.length} race results.`);
submissions = 0;
for (const standing of driverstandings) {
try {
// TODO: Authenticate this
await pb.collection("scraped_driverstandings").create(standing);
} catch (e) {
console.log("Error occured while submitting scraped data to PocketBase:");
console.log(e);
console.log("Error occured for this driver standing:");
console.log(standing);
console.log("Aborting submissions...");
return new Response(); // TODO: Return error
}
submissions++;
}
console.log(`Submitted ${submissions}/${driverstandings.length} driver standings.`);
submissions = 0;
for (const standing of teamstandings) {
try {
// TODO: Authenticate this
await pb.collection("scraped_teamstandings").create(standing);
} catch (e) {
console.log("Error occured while submitting scraped data to PocketBase:");
console.log(e);
console.log("Error occured for this team standing:");
console.log(standing);
console.log("Aborting submissions...");
return new Response(); // TODO: Return error
}
submissions++;
}
console.log(`Submitted ${submissions}/${teamstandings.length} team standings.`);
return new Response(); // TODO: Return success
};

View File

@ -0,0 +1,58 @@
<script lang="ts">
import { invalidate } from "$app/navigation";
import { Button } from "$lib/components";
import { pbUser } from "$lib/pocketbase";
import { get_error_toast, get_warning_toast } from "$lib/toast";
import { getToastStore, type ToastStore } from "@skeletonlabs/skeleton";
import type { Snippet } from "svelte";
let { children }: { children: Snippet } = $props();
const toastStore: ToastStore = getToastStore();
const scrape_message: string =
"This will clear and redownload all data from f1.com. Please don't refresh the page during the process.";
// This callback will be executed once the admin presses the "Proceed"-button in the warning toast
const scrape_callback = async () => {
const response: Response = await fetch("/api/scrape", { method: "POST" });
invalidate("data:official");
};
const scrape_official_data = async () => {
if (!$pbUser || !$pbUser.admin) {
toastStore.trigger(get_error_toast("Only admins may perform this action!"));
return;
}
// No timeout + action toast
toastStore.trigger(get_warning_toast(scrape_message, null, "Proceed", scrape_callback));
};
</script>
<div class="fixed left-0 right-0 top-14 z-10 flex justify-center">
<div
class="mx-2 flex w-full justify-center gap-2 bg-primary-500 pb-2 pt-3 shadow rounded-bl-container-token rounded-br-container-token"
>
<Button href="driverstandings" color="primary" activate_href>Drivers</Button>
<Button href="teamstandings" color="primary" activate_href>Teams</Button>
<Button href="startinggrids" color="primary" activate_href>Grids</Button>
<Button href="raceresults" color="primary" activate_href>Race Results</Button>
</div>
</div>
<!-- Each child's contents will be inserted here -->
<div style="margin-top: 56px;">
<div class="pb-2">
<Button
width="w-full"
color="tertiary"
onclick={scrape_official_data}
shadow
disabled={!$pbUser?.admin}
>
<span class="font-bold">Refresh All Data</span>
</Button>
</div>
{@render children()}
</div>

View File

@ -0,0 +1,37 @@
<script lang="ts">
import { Table, type TableColumn } from "$lib/components";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
const standings_columns: TableColumn[] = $derived([
{
data_value_name: "driver_code",
label: "Driver",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
{
data_value_name: "position",
label: "Position",
},
{
data_value_name: "points",
label: "Points",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
]);
</script>
<svelte:head>
<title>Formula 11 - Official Driver Standings</title>
</svelte:head>
{#await data.scraped_driverstandings then standings}
<Table
data={standings}
columns={standings_columns}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await}

View File

@ -0,0 +1,11 @@
import { fetch_drivers, fetch_scraped_driverstandings } from "$lib/fetch";
import type { PageLoad } from "../../../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:official", "data:drivers");
return {
scraped_driverstandings: fetch_scraped_driverstandings(fetch),
drivers: fetch_drivers(fetch),
};
};

View File

@ -0,0 +1,55 @@
<script lang="ts">
import { Table, type TableColumn } from "$lib/components";
import type { PageData } from "./$types";
import { get_by_value } from "$lib/database";
import type { Race } from "$lib/schema";
let { data }: { data: PageData } = $props();
const results_columns: TableColumn[] = $derived([
{
data_value_name: "race_step",
label: "Race",
valuefun: async (value: string): Promise<string> => {
const racename: string = get_by_value(await data.races, "step", value)?.name ?? "Invalid";
return `<span class='badge variant-filled-surface'>${racename}</span>`;
},
},
{
data_value_name: "race_step",
label: "Step",
},
{
data_value_name: "driver_code",
label: "Driver",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
{
data_value_name: "position",
label: "Position",
},
{
data_value_name: "status",
label: "Status",
},
{
data_value_name: "points",
label: "Points",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
]);
</script>
<svelte:head>
<title>Formula 11 - Official Race Results</title>
</svelte:head>
{#await data.scraped_raceresults then results}
<Table
data={results}
columns={results_columns}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await}

View File

@ -0,0 +1,12 @@
import { fetch_drivers, fetch_races, fetch_scraped_raceresults } from "$lib/fetch";
import type { PageLoad } from "../../../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:official", "data:races", "data:drivers");
return {
scraped_raceresults: fetch_scraped_raceresults(fetch),
races: fetch_races(fetch),
drivers: fetch_drivers(fetch),
};
};

View File

@ -0,0 +1,49 @@
<script lang="ts">
import { Table, type TableColumn } from "$lib/components";
import type { PageData } from "./$types";
import { get_by_value } from "$lib/database";
import type { Race } from "$lib/schema";
let { data }: { data: PageData } = $props();
const grids_columns: TableColumn[] = $derived([
{
data_value_name: "race_step",
label: "Race",
valuefun: async (value: string): Promise<string> => {
const racename: string = get_by_value(await data.races, "step", value)?.name ?? "Invalid";
return `<span class='badge variant-filled-surface'>${racename}</span>`;
},
},
{
data_value_name: "race_step",
label: "Step",
},
{
data_value_name: "driver_code",
label: "Driver",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
{
data_value_name: "position",
label: "Position",
},
{
data_value_name: "time",
label: "Time",
},
]);
</script>
<svelte:head>
<title>Formula 11 - Official Starting Grids</title>
</svelte:head>
{#await data.scraped_startinggrids then grids}
<Table
data={grids}
columns={grids_columns}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await}

View File

@ -0,0 +1,12 @@
import { fetch_drivers, fetch_races, fetch_scraped_startinggrids } from "$lib/fetch";
import type { PageLoad } from "../../../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:official", "data:races", "data:drivers");
return {
scraped_startinggrids: fetch_scraped_startinggrids(fetch),
races: fetch_races(fetch),
drivers: fetch_drivers(fetch),
};
};

View File

@ -0,0 +1,37 @@
<script lang="ts">
import { Table, type TableColumn } from "$lib/components";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
const standings_columns: TableColumn[] = $derived([
{
data_value_name: "team_fullname",
label: "Team",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
{
data_value_name: "position",
label: "Position",
},
{
data_value_name: "points",
label: "Points",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
]);
</script>
<svelte:head>
<title>Formula 11 - Official Team Standings</title>
</svelte:head>
{#await data.scraped_teamstandings then standings}
<Table
data={standings}
columns={standings_columns}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await}

View File

@ -0,0 +1,11 @@
import { fetch_scraped_teamstandings, fetch_teams } from "$lib/fetch";
import type { PageLoad } from "../../../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:official", "data:teams");
return {
scraped_teamstandings: fetch_scraped_teamstandings(fetch),
teams: fetch_teams(fetch),
};
};

View File

@ -1,55 +0,0 @@
import {
form_data_clean,
form_data_ensure_keys,
form_data_get_and_remove_id,
form_data_remove,
} from "$lib/form";
import type { Driver, Graphic, Race, RaceResult } from "$lib/schema";
import type { Actions, PageServerLoad } from "./$types";
export const actions = {
create_raceresult: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
const data: FormData = form_data_clean(await request.formData());
form_data_ensure_keys(data, ["race", "pxxs"]);
form_data_remove(data, ["pxxs_codes", "dnfs_codes"]);
await locals.pb.collection("raceresults").create(data);
},
update_raceresult: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
const data: FormData = form_data_clean(await request.formData());
form_data_remove(data, ["pxxs_codes", "dnfs_codes"]);
const id: string = form_data_get_and_remove_id(data);
console.dir(data, { depth: null });
await locals.pb.collection("raceresults").update(id, data);
},
delete_raceresult: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
const data: FormData = form_data_clean(await request.formData());
form_data_remove(data, ["pxxs_codes", "dnfs_codes"]);
const id: string = form_data_get_and_remove_id(data);
await locals.pb.collection("raceresults").delete(id);
},
} as Actions;
export const load: PageServerLoad = async ({ fetch, locals }) => {
// TODO: Duplicated code from racepicks/+page.server.ts
const fetch_raceresults = async (): Promise<RaceResult[]> => {
const raceresults: RaceResult[] = await locals.pb.collection("raceresultsdesc").getFullList();
return raceresults;
};
return {
results: await fetch_raceresults(),
};
};

View File

@ -4,10 +4,34 @@
import { Button, Table, type TableColumn } from "$lib/components";
import { get_by_value } from "$lib/database";
import { PXX_COLORS } from "$lib/config";
import type { RaceResult } from "$lib/schema";
let { data }: { data: PageData } = $props();
const results_columns: TableColumn[] = [
const modalStore: ModalStore = getModalStore();
const result_handler = async (event: Event, id?: string) => {
const result: RaceResult | undefined = get_by_value(
await data.raceresults,
"id",
id ?? "Invalid",
);
if (id && !result) return;
const modalSettings: ModalSettings = {
type: "component",
component: "raceResultCard",
meta: {
data,
result,
},
};
modalStore.trigger(modalSettings);
};
const results_columns: TableColumn[] = $derived([
{
data_value_name: "race",
label: "Step",
@ -30,6 +54,8 @@
data_value_name: "pxxs",
label: "Standing",
valuefun: async (value: string): Promise<string> => {
if (value.length === 0 || value === "") return "";
const pxxs_array: string[] = value.toString().split(",");
const pxxs_codes: string[] = await Promise.all(
pxxs_array.map(
@ -58,44 +84,23 @@
return dnfs_codes.join("");
},
},
];
const modalStore: ModalStore = getModalStore();
const results_handler = async (event: Event, id: string) => {
const modalSettings: ModalSettings = {
type: "component",
component: "raceResultCard",
meta: {
disable_inputs: !data.admin,
drivers: await data.drivers,
races: await data.races,
result: get_by_value(data.results, "id", id),
},
};
modalStore.trigger(modalSettings);
};
const create_result_handler = async (event: Event) => {
const modalSettings: ModalSettings = {
type: "component",
component: "raceResultCard",
meta: {
disable_inputs: !data.admin,
drivers: await data.drivers,
races: await data.races,
require_inputs: true,
},
};
modalStore.trigger(modalSettings);
};
]);
</script>
<svelte:head>
<title>Formula 11 - Race Results</title>
</svelte:head>
<div class="pb-2">
<Button width="w-full" color="tertiary" onclick={create_result_handler} shadow>
<Button width="w-full" color="tertiary" onclick={result_handler} shadow>
<span class="font-bold">Create Race Result</span>
</Button>
</div>
<Table data={data.results} columns={results_columns} handler={results_handler} />
{#await data.raceresults then results}
<Table
data={results}
columns={results_columns}
handler={result_handler}
height="h-[calc(100vh-210px)] lg:h-[calc(100vh-126px)]"
/>
{/await}

View File

@ -0,0 +1,13 @@
import { fetch_drivers, fetch_raceresults, fetch_races, fetch_substitutions } from "$lib/fetch";
import type { PageLoad } from "../../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:drivers", "data:races", "data:raceresults", "data:substitutions");
return {
drivers: fetch_drivers(fetch),
races: fetch_races(fetch),
raceresults: fetch_raceresults(fetch),
substitutions: fetch_substitutions(fetch),
};
};

View File

@ -1,59 +0,0 @@
import { DRIVER_HEADSHOT_HEIGHT, DRIVER_HEADSHOT_WIDTH } from "$lib/config";
import { form_data_clean, form_data_ensure_keys, form_data_get_and_remove_id } from "$lib/form";
import { image_to_avif } from "$lib/server/image";
import type { Actions } from "@sveltejs/kit";
export const actions = {
create_driver: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
const data: FormData = form_data_clean(await request.formData());
form_data_ensure_keys(data, ["firstname", "lastname", "code", "team", "headshot"]);
// The toggle switch will report "on" or nothing
data.set("active", data.has("active") ? "true" : "false");
// Compress headshot
const headshot_avif: Blob = await image_to_avif(
await (data.get("headshot") as File).arrayBuffer(),
DRIVER_HEADSHOT_WIDTH,
DRIVER_HEADSHOT_HEIGHT,
);
data.set("headshot", headshot_avif);
await locals.pb.collection("drivers").create(data);
},
update_driver: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
const data: FormData = form_data_clean(await request.formData());
const id: string = form_data_get_and_remove_id(data);
// The toggle switch will report "on" or nothing
data.set("active", data.has("active") ? "true" : "false");
if (data.has("headshot")) {
// Compress headshot
const headshot_avif: Blob = await image_to_avif(
await (data.get("headshot") as File).arrayBuffer(),
DRIVER_HEADSHOT_WIDTH,
DRIVER_HEADSHOT_HEIGHT,
);
data.set("headshot", headshot_avif);
}
await locals.pb.collection("drivers").update(id, data);
},
delete_driver: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
const data: FormData = form_data_clean(await request.formData());
const id: string = form_data_get_and_remove_id(data);
await locals.pb.collection("drivers").delete(id);
},
} satisfies Actions;

View File

@ -1,24 +1,42 @@
<script lang="ts">
import { Button, type TableColumn, Table } from "$lib/components";
import { get_by_value, get_driver_headshot_template } from "$lib/database";
import { get_by_value } from "$lib/database";
import type { Driver, Team } from "$lib/schema";
import { getModalStore, type ModalSettings, type ModalStore } from "@skeletonlabs/skeleton";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
const update_driver_team_select_values: { [key: string]: string } = $state({}); // <driver.id, team.id>
const update_driver_active_values: { [key: string]: boolean } = $state({});
data.drivers.then((drivers: Driver[]) =>
drivers.forEach((driver: Driver) => {
update_driver_team_select_values[driver.id] = driver.team;
update_driver_active_values[driver.id] = driver.active;
}),
);
update_driver_team_select_values["create"] = "";
update_driver_active_values["create"] = true;
const modalStore: ModalStore = getModalStore();
const driver_handler = async (event: Event, id?: string) => {
const driver: Driver | undefined = get_by_value(await data.drivers, "id", id ?? "Invalid");
const drivers_columns: TableColumn[] = [
if (id && !driver) return;
const modalSettings: ModalSettings = {
type: "component",
component: "driverCard",
meta: {
data,
driver,
},
};
modalStore.trigger(modalSettings);
};
const teamswitch_handler = async (event: Event, id?: string) => {
const modalSettings: ModalSettings = {
type: "component",
component: "teamSwitchCard",
meta: {
data,
},
};
modalStore.trigger(modalSettings);
};
const drivers_columns: TableColumn[] = $derived([
{
data_value_name: "code",
label: "Driver Code",
@ -32,9 +50,7 @@
label: "Team",
valuefun: async (value: string): Promise<string> => {
const team: Team | undefined = get_by_value(await data.teams, "id", value);
return team
? `<span class='badge border mr-2' style='color: ${team.color}; background: ${team.color};'>C</span>${team.name}`
: "<span class='badge variant-filled-primary'>Invalid</span>";
return `<span class='badge mr-2' style='color: ${team?.color ?? "#FFFFFF"}; background: ${team?.color ?? "#FFFFFF"};'>C</span>${team?.name ?? "Invalid"}`;
},
},
{
@ -43,53 +59,32 @@
valuefun: async (value: boolean): Promise<string> =>
`<span class='badge variant-filled-${value ? "tertiary" : "primary"} text-center' style='width: 36px;'>${value ? "Yes" : "No"}</span>`,
},
];
const modalStore: ModalStore = getModalStore();
/** Shows the DriverCard modal to edit the clicked driver */
const drivers_handler = async (event: Event, id: string) => {
const driver: Driver | undefined = get_by_value(await data.drivers, "id", id);
if (!driver) return;
const modalSettings: ModalSettings = {
type: "component",
component: "driverCard",
meta: {
driver: driver,
teams: await data.teams,
team_select_value: update_driver_team_select_values[driver.id],
active_value: update_driver_active_values[driver.id],
disable_inputs: !data.admin,
},
};
modalStore.trigger(modalSettings);
};
const create_driver_handler = async (event: Event) => {
const modalSettings: ModalSettings = {
type: "component",
component: "driverCard",
meta: {
teams: await data.teams,
team_select_value: update_driver_team_select_values["create"],
active_value: update_driver_active_values["create"],
disable_inputs: !data.admin,
require_inputs: true,
headshot_template: get_driver_headshot_template(await data.graphics),
},
};
modalStore.trigger(modalSettings);
};
{
data_value_name: "started_active",
label: "Started Active",
valuefun: async (value: boolean): Promise<string> =>
`<span class='badge variant-filled-${value ? "tertiary" : "primary"} text-center' style='width: 36px;'>${value ? "Yes" : "No"}</span>`,
},
]);
</script>
<div class="pb-2">
<Button width="w-full" color="tertiary" onclick={create_driver_handler} shadow>
<svelte:head>
<title>Formula 11 - Drivers</title>
</svelte:head>
<div class="flex gap-2 pb-2">
<Button width="w-full" color="tertiary" onclick={driver_handler} shadow>
<span class="font-bold">Create New Driver</span>
</Button>
<Button width="w-full" color="secondary" onclick={teamswitch_handler} shadow>
<span class="font-bold">Switch Driver Team</span>
</Button>
</div>
{#await data.drivers then drivers}
<Table data={drivers} columns={drivers_columns} handler={drivers_handler} />
<Table
data={drivers}
columns={drivers_columns}
handler={driver_handler}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await}

View File

@ -0,0 +1,11 @@
import { fetch_drivers, fetch_teams } from "$lib/fetch";
import type { PageLoad } from "../../../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:teams", "data:drivers");
return {
teams: fetch_teams(fetch),
drivers: fetch_drivers(fetch),
};
};

View File

@ -1,64 +0,0 @@
import { RACE_PICTOGRAM_HEIGHT, RACE_PICTOGRAM_WIDTH } from "$lib/config";
import {
form_data_clean,
form_data_ensure_keys,
form_data_fix_dates,
form_data_get_and_remove_id,
} from "$lib/form";
import { image_to_avif } from "$lib/server/image";
import type { Actions } from "@sveltejs/kit";
export const actions = {
create_race: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
const data: FormData = form_data_clean(await request.formData());
form_data_ensure_keys(data, ["name", "step", "pictogram", "pxx", "qualidate", "racedate"]);
form_data_fix_dates(data, ["sprintqualidate", "sprintdate", "qualidate", "racedate"]);
// Compress pictogram
const pictogram_avif: Blob = await image_to_avif(
await (data.get("pictogram") as File).arrayBuffer(),
RACE_PICTOGRAM_WIDTH,
RACE_PICTOGRAM_HEIGHT,
);
data.set("pictogram", pictogram_avif);
await locals.pb.collection("races").create(data);
},
update_race: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
// Do not remove empty sprint dates so they can be cleared by updating the record
const data: FormData = form_data_clean(await request.formData(), [
"sprintqualidate",
"sprintdate",
]);
form_data_fix_dates(data, ["sprintqualidate", "sprintdate", "qualidate", "racedate"]);
const id: string = form_data_get_and_remove_id(data);
if (data.has("pictogram")) {
// Compress pictogram
const pictogram_avif: Blob = await image_to_avif(
await (data.get("pictogram") as File).arrayBuffer(),
RACE_PICTOGRAM_WIDTH,
RACE_PICTOGRAM_HEIGHT,
);
data.set("pictogram", pictogram_avif);
}
await locals.pb.collection("races").update(id, data);
},
delete_race: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
const data: FormData = form_data_clean(await request.formData());
const id: string = form_data_get_and_remove_id(data);
await locals.pb.collection("races").delete(id);
},
} satisfies Actions;

View File

@ -2,12 +2,33 @@
import { Button, Table, type TableColumn } from "$lib/components";
import { getModalStore, type ModalSettings, type ModalStore } from "@skeletonlabs/skeleton";
import type { PageData } from "./$types";
import { get_by_value, get_race_pictogram_template } from "$lib/database";
import { get_by_value } from "$lib/database";
import type { Race } from "$lib/schema";
import { format_date, shortdatetimeformat } from "$lib/date";
let { data }: { data: PageData } = $props();
const races_columns: TableColumn[] = [
const modalStore: ModalStore = getModalStore();
const race_handler = async (event: Event, id?: string) => {
const race: Race | undefined = get_by_value(await data.races, "id", id ?? "Invalid");
if (id && !race) return;
const modalSettings: ModalSettings = {
type: "component",
component: "raceCard",
meta: {
data,
race,
},
};
modalStore.trigger(modalSettings);
};
// The date value functions convert UTC from PocketBase to localtime
const races_columns: TableColumn[] = $derived([
{
data_value_name: "name",
label: "Name",
@ -18,63 +39,40 @@
{
data_value_name: "sprintqualidate",
label: "Sprint Quali",
valuefun: async (value: string): Promise<string> => value.slice(0, -5), // Cutoff the "Z" from the ISO datetime
valuefun: async (value: string): Promise<string> => format_date(value, shortdatetimeformat),
},
{
data_value_name: "sprintdate",
label: "Sprint Race",
valuefun: async (value: string): Promise<string> => value.slice(0, -5),
valuefun: async (value: string): Promise<string> => format_date(value, shortdatetimeformat),
},
{
data_value_name: "qualidate",
label: "Quali",
valuefun: async (value: string): Promise<string> => value.slice(0, -5),
valuefun: async (value: string): Promise<string> => format_date(value, shortdatetimeformat),
},
{
data_value_name: "racedate",
label: "Race",
valuefun: async (value: string): Promise<string> => value.slice(0, -5),
valuefun: async (value: string): Promise<string> => format_date(value, shortdatetimeformat),
},
];
const modalStore: ModalStore = getModalStore();
const races_handler = async (event: Event, id: string) => {
const race: Race | undefined = get_by_value(await data.races, "id", id);
if (!race) return;
const modalSettings: ModalSettings = {
type: "component",
component: "raceCard",
meta: {
race: race,
disable_inputs: !data.admin,
},
};
modalStore.trigger(modalSettings);
};
const create_race_handler = async (event: Event) => {
const modalSettings: ModalSettings = {
type: "component",
component: "raceCard",
meta: {
disable_inputs: !data.admin,
require_inputs: true,
pictogram_template: get_race_pictogram_template(await data.graphics),
},
};
modalStore.trigger(modalSettings);
};
]);
</script>
<svelte:head>
<title>Formula 11 - Races</title>
</svelte:head>
<div class="pb-2">
<Button width="w-full" color="tertiary" onclick={create_race_handler} shadow>
<Button width="w-full" color="tertiary" onclick={race_handler} shadow>
<span class="font-bold">Create New Race</span>
</Button>
</div>
{#await data.races then races}
<Table data={races} columns={races_columns} handler={races_handler} />
<Table
data={races}
columns={races_columns}
handler={race_handler}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await}

View File

@ -0,0 +1,10 @@
import { fetch_races } from "$lib/fetch";
import type { PageLoad } from "../../../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:races");
return {
races: fetch_races(fetch),
};
};

View File

@ -1,31 +0,0 @@
import { form_data_clean, form_data_ensure_keys, form_data_get_and_remove_id } from "$lib/form";
import type { Actions } from "@sveltejs/kit";
export const actions = {
create_substitution: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
const data: FormData = form_data_clean(await request.formData());
form_data_ensure_keys(data, ["substitute", "for", "race"]);
await locals.pb.collection("substitutions").create(data);
},
update_substitution: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
const data: FormData = form_data_clean(await request.formData());
const id: string = form_data_get_and_remove_id(data);
await locals.pb.collection("substitutions").update(id, data);
},
delete_substitution: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
const data: FormData = form_data_clean(await request.formData());
const id: string = form_data_get_and_remove_id(data);
await locals.pb.collection("substitutions").delete(id);
},
} satisfies Actions;

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { get_by_value, get_driver_headshot_template } from "$lib/database";
import { get_by_value } from "$lib/database";
import { getModalStore, type ModalSettings, type ModalStore } from "@skeletonlabs/skeleton";
import type { PageData } from "./$types";
import type { Race, Substitution } from "$lib/schema";
@ -7,22 +7,29 @@
let { data }: { data: PageData } = $props();
// TODO: Cleanup
const update_substitution_substitute_select_values: { [key: string]: string } = $state({});
const update_substitution_for_select_values: { [key: string]: string } = $state({});
const update_substitution_race_select_values: { [key: string]: string } = $state({});
data.substitutions.then((substitutions: Substitution[]) =>
substitutions.forEach((substitution: Substitution) => {
update_substitution_substitute_select_values[substitution.id] = substitution.substitute;
update_substitution_for_select_values[substitution.id] = substitution.for;
update_substitution_race_select_values[substitution.id] = substitution.race;
}),
);
update_substitution_substitute_select_values["create"] = "";
update_substitution_for_select_values["create"] = "";
update_substitution_race_select_values["create"] = "";
const modalStore: ModalStore = getModalStore();
const substitution_handler = async (event: Event, id?: string) => {
const substitution: Substitution | undefined = get_by_value(
await data.substitutions,
"id",
id ?? "Invalid",
);
const substitutions_columns: TableColumn[] = [
if (id && !substitution) return;
const modalSettings: ModalSettings = {
type: "component",
component: "substitutionCard",
meta: {
data,
substitution,
},
};
modalStore.trigger(modalSettings);
};
const substitutions_columns: TableColumn[] = $derived([
{
data_value_name: "expand",
label: "Step",
@ -49,56 +56,23 @@
valuefun: async (value: string): Promise<string> =>
get_by_value(await data.races, "id", value)?.name ?? "Invalid",
},
];
const modalStore: ModalStore = getModalStore();
const substitutions_handler = async (event: Event, id: string) => {
const substitution: Substitution | undefined = get_by_value(await data.substitutions, "id", id);
if (!substitution) return;
const modalSettings: ModalSettings = {
type: "component",
component: "substitutionCard",
meta: {
substitution: substitution,
drivers: await data.drivers,
races: await data.races,
substitute_select_value: update_substitution_substitute_select_values[substitution.id],
driver_select_value: update_substitution_for_select_values[substitution.id],
race_select_value: update_substitution_race_select_values[substitution.id],
disable_inputs: !data.admin,
},
};
modalStore.trigger(modalSettings);
};
const create_substitution_handler = async (event: Event) => {
const modalSettings: ModalSettings = {
type: "component",
component: "substitutionCard",
meta: {
drivers: await data.drivers,
races: await data.races,
substitute_select_value: update_substitution_substitute_select_values["create"],
driver_select_value: update_substitution_for_select_values["create"],
disable_inputs: !data.admin,
race_select_value: update_substitution_race_select_values["create"],
require_inputs: true,
headshot_template: get_driver_headshot_template(await data.graphics),
},
};
modalStore.trigger(modalSettings);
};
]);
</script>
<svelte:head>
<title>Formula 11 - Substitutions</title>
</svelte:head>
<div class="pb-2">
<Button width="w-full" color="tertiary" onclick={create_substitution_handler} shadow>
<Button width="w-full" color="tertiary" onclick={substitution_handler} shadow>
<span class="font-bold">Create New Substitution</span>
</Button>
</div>
{#await data.substitutions then substitutions}
<Table data={substitutions} columns={substitutions_columns} handler={substitutions_handler} />
<Table
data={substitutions}
columns={substitutions_columns}
handler={substitution_handler}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await}

View File

@ -0,0 +1,12 @@
import { fetch_drivers, fetch_races, fetch_substitutions } from "$lib/fetch";
import type { PageLoad } from "../../../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:races", "data:drivers", "data:substitutions");
return {
races: fetch_races(fetch),
drivers: fetch_drivers(fetch),
substitutions: fetch_substitutions(fetch),
};
};

View File

@ -1,74 +0,0 @@
import type { Actions } from "./$types";
import { form_data_clean, form_data_ensure_keys, form_data_get_and_remove_id } from "$lib/form";
import { image_to_avif } from "$lib/server/image";
import {
TEAM_BANNER_HEIGHT,
TEAM_BANNER_WIDTH,
TEAM_LOGO_HEIGHT,
TEAM_LOGO_WIDTH,
} from "$lib/config";
export const actions = {
create_team: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
const data: FormData = form_data_clean(await request.formData());
form_data_ensure_keys(data, ["name", "banner", "logo", "color"]);
// Compress banner
const banner_avif: Blob = await image_to_avif(
await (data.get("banner") as File).arrayBuffer(),
TEAM_BANNER_WIDTH,
TEAM_BANNER_HEIGHT,
);
data.set("banner", banner_avif);
// Compress logo
const logo_avif: Blob = await image_to_avif(
await (data.get("logo") as File).arrayBuffer(),
TEAM_LOGO_WIDTH,
TEAM_LOGO_HEIGHT,
);
data.set("logo", logo_avif);
await locals.pb.collection("teams").create(data);
},
update_team: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
const data: FormData = form_data_clean(await request.formData());
const id: string = form_data_get_and_remove_id(data);
if (data.has("banner")) {
// Compress banner
const banner_avif: Blob = await image_to_avif(
await (data.get("banner") as File).arrayBuffer(),
TEAM_BANNER_WIDTH,
TEAM_BANNER_HEIGHT,
);
data.set("banner", banner_avif);
}
if (data.has("logo")) {
// Compress logo
const logo_avif: Blob = await image_to_avif(
await (data.get("logo") as File).arrayBuffer(),
TEAM_LOGO_WIDTH,
TEAM_LOGO_HEIGHT,
);
data.set("logo", logo_avif);
}
await locals.pb.collection("teams").update(id, data);
},
delete_team: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
const data: FormData = form_data_clean(await request.formData());
const id: string = form_data_get_and_remove_id(data);
await locals.pb.collection("teams").delete(id);
},
} satisfies Actions;

View File

@ -3,11 +3,30 @@
import type { Team } from "$lib/schema";
import { getModalStore, type ModalSettings, type ModalStore } from "@skeletonlabs/skeleton";
import type { PageData } from "./$types";
import { get_by_value, get_team_banner_template, get_team_logo_template } from "$lib/database";
import { get_by_value } from "$lib/database";
let { data }: { data: PageData } = $props();
const teams_columns: TableColumn[] = [
const modalStore: ModalStore = getModalStore();
const team_handler = async (event: Event, id?: string) => {
const team: Team | undefined = get_by_value(await data.teams, "id", id ?? "Invalid");
// If we expect to find a team but don't, abort
if (id && !team) return;
const modalSettings: ModalSettings = {
type: "component",
component: "teamCard",
meta: {
data,
team,
},
};
modalStore.trigger(modalSettings);
};
const teams_columns: TableColumn[] = $derived([
{
data_value_name: "name",
label: "Name",
@ -18,49 +37,25 @@
data_value_name: "color",
label: "Color",
valuefun: async (value: string): Promise<string> =>
`<span class='badge border mr-2' style='color: ${value}; background: ${value};'>C</span>`,
`<span class='badge mr-2' style='color: ${value}; background: ${value};'>C</span>`,
},
];
const modalStore: ModalStore = getModalStore();
const teams_handler = async (event: Event, id: string) => {
const team: Team | undefined = get_by_value(await data.teams, "id", id);
if (!team) return;
const modalSettings: ModalSettings = {
type: "component",
component: "teamCard",
meta: {
team: team,
disable_inputs: !data.admin,
},
};
modalStore.trigger(modalSettings);
};
const create_team_handler = async (event: Event) => {
const modalSettings: ModalSettings = {
type: "component",
component: "teamCard",
meta: {
banner_template: get_team_banner_template(await data.graphics),
logo_template: get_team_logo_template(await data.graphics),
require_inputs: true,
disable_inputs: !data.admin,
},
};
modalStore.trigger(modalSettings);
};
]);
</script>
<svelte:head>
<title>Formula 11 - Teams</title>
</svelte:head>
<div class="pb-2">
<Button width="w-full" color="tertiary" onclick={create_team_handler} shadow>
<Button width="w-full" color="tertiary" onclick={team_handler} shadow>
<span class="font-bold">Create New Team</span>
</Button>
</div>
{#await data.teams then teams}
<Table data={teams} columns={teams_columns} handler={teams_handler} />
<Table
data={teams}
columns={teams_columns}
handler={team_handler}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await}

View File

@ -0,0 +1,10 @@
import { fetch_teams } from "$lib/fetch";
import type { PageLoad } from "../../../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:teams");
return {
teams: fetch_teams(fetch),
};
};

View File

@ -1,20 +0,0 @@
import type { Graphic, User } from "$lib/schema";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ fetch, locals }) => {
const fetch_users = async (): Promise<User[]> => {
const users: User[] = await locals.pb
.collection("users")
.getFullList({ fetch: fetch, sort: "+username" });
users.map((user: User) => {
user.avatar_url = locals.pb.files.getURL(user, user.avatar);
});
return users;
};
return {
users: await fetch_users(),
};
};

View File

@ -7,12 +7,13 @@
let { data }: { data: PageData } = $props();
const users_columns: TableColumn[] = [
{
data_value_name: "username",
label: "Username",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
// Don't display the username for login "security" (lol)
// {
// data_value_name: "username",
// label: "Username",
// valuefun: async (value: string): Promise<string> =>
// `<span class='badge variant-filled-surface'>${value}</span>`,
// },
{
data_value_name: "firstname",
label: "First Name",
@ -21,7 +22,7 @@
data_value_name: "avatar_url",
label: "Avatar",
valuefun: async (value: string): Promise<string> =>
`<img class='rounded-full w-10 bg-surface-400' src='${value ? value : get_by_value(await data.graphics, "name", "driver_headshot_template")?.file_url}'/>`,
`<img class='rounded-full w-10 bg-surface-400' src='${value ? value : get_by_value(data.graphics, "name", "driver_headshot_template")?.file_url}'/>`,
},
{
data_value_name: "admin",
@ -38,4 +39,13 @@
};
</script>
<Table data={data.users} columns={users_columns} handler={users_handler} />
<svelte:head>
<title>Formula 11 - Users</title>
</svelte:head>
<Table
data={data.users}
columns={users_columns}
handler={users_handler}
height="h-[calc(100vh-160px)] lg:h-[calc(100vh-76px)]"
/>

View File

@ -0,0 +1,10 @@
import { fetch_users } from "$lib/fetch";
import type { PageLoad } from "../../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:users");
return {
users: await fetch_users(fetch),
};
};

View File

@ -1 +1,96 @@
<h1>Leaderboard</h1>
<script lang="ts">
import { make_chart_options } from "$lib/chart";
import { Table, type TableColumn } from "$lib/components";
import { get_by_value } from "$lib/database";
import type { RacePickPoints, RacePickPointsAcc, User } from "$lib/schema";
import type { PageData } from "./$types";
import {
LineChart,
ScaleTypes,
type ChartTabularData,
type LineChartOptions,
} from "@carbon/charts-svelte";
import "@carbon/charts-svelte/styles.css";
let { data }: { data: PageData } = $props();
// Await promises
let users: User[] | undefined = $state(undefined);
data.users.then((u: User[]) => (users = u));
let racepickpoints: RacePickPoints[] | undefined = $state(undefined);
data.racepickpoints.then((r: RacePickPoints[]) => (racepickpoints = r));
let racepickpointsacc: RacePickPointsAcc[] | undefined = $state(undefined);
data.racepickpointsacc.then((r: RacePickPointsAcc[]) => (racepickpointsacc = r));
const leaderboard_columns: TableColumn[] = $derived([
{
data_value_name: "user",
label: "User",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${get_by_value(await data.users, "id", value)?.firstname ?? "Invalid"}</span>`,
},
{
data_value_name: "total_points",
label: "Total",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
{
data_value_name: "total_pxx_points",
label: "PXX",
},
{
data_value_name: "total_dnf_points",
label: "DNF",
},
{
data_value_name: "total_points_per_pick",
label: "Per Pick",
valuefun: async (value: string): Promise<string> => Number.parseFloat(value).toFixed(2),
},
]);
const points_chart_data: ChartTabularData = $derived.by(() => {
if (!users || !racepickpointsacc) return [];
return users
.map((user: User) => {
return {
group: user.firstname,
step: "0",
points: 0,
};
})
.concat(
racepickpointsacc.map((points: RacePickPointsAcc) => {
return {
group: get_by_value(users ?? [], "id", points.user)?.firstname || "INVALID",
step: points.step.toString(),
points: points.acc_points,
};
}),
);
});
const points_chart_options: LineChartOptions = make_chart_options(
"I ❤️ CumSum",
"step",
"points",
);
</script>
<svelte:head>
<title>Formula 11 - Leaderboard</title>
</svelte:head>
<div class="card w-full bg-surface-100 p-2 shadow">
<LineChart data={points_chart_data} options={points_chart_options} />
</div>
<div class="mt-2">
{#await data.racepickpointstotal then racepickpointstotal}
<Table data={racepickpointstotal} columns={leaderboard_columns} />
{/await}
</div>

View File

@ -0,0 +1,18 @@
import {
fetch_users,
fetch_racepickpoints,
fetch_racepickpointsacc,
fetch_racepickpointstotal,
} from "$lib/fetch";
import type { PageLoad } from "../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:users", "data:raceresults");
return {
users: fetch_users(fetch),
racepickpoints: fetch_racepickpoints(fetch),
racepickpointsacc: fetch_racepickpointsacc(fetch),
racepickpointstotal: fetch_racepickpointstotal(fetch),
};
};

View File

@ -1,89 +0,0 @@
import { form_data_clean, form_data_ensure_keys, form_data_get_and_remove_id } from "$lib/form";
import { error, redirect } from "@sveltejs/kit";
import type { Actions } from "./$types";
import { image_to_avif } from "$lib/server/image";
import { AVATAR_HEIGHT, AVATAR_WIDTH } from "$lib/config";
export const actions = {
create_profile: async ({ request, locals }): Promise<void> => {
const data: FormData = form_data_clean(await request.formData());
form_data_ensure_keys(data, ["username", "firstname", "password", "redirect_url"]);
// Confirm password lol
await locals.pb.collection("users").create({
username: data.get("username")?.toString(),
firstname: data.get("firstname")?.toString(),
password: data.get("password")?.toString(),
passwordConfirm: data.get("password")?.toString(),
admin: false,
});
// Directly login after registering
await locals.pb
.collection("users")
.authWithPassword(data.get("username")?.toString(), data.get("password")?.toString());
// The current page is sent with the form, redirect to that page
redirect(303, data.get("redirect_url")?.toString() ?? "/");
},
// TODO: PocketBase API rule: Only the active user should be able to modify itself
update_profile: async ({ request, locals }): Promise<void> => {
const data: FormData = form_data_clean(await request.formData());
form_data_ensure_keys(data, ["redirect_url"]);
const id: string = form_data_get_and_remove_id(data);
if (data.has("avatar")) {
// Compress image
const compressed: Blob = await image_to_avif(
await (data.get("avatar") as File).arrayBuffer(),
AVATAR_WIDTH,
AVATAR_HEIGHT,
);
// At most 20kB
if (compressed.size > 20000) {
error(400, "Avatar too large!");
}
data.set("avatar", compressed);
}
await locals.pb.collection("users").update(id, data);
redirect(303, data.get("redirect_url")?.toString() ?? "/");
},
login: async ({ request, locals }) => {
if (locals.user) {
error(400, "Already logged in!");
}
const data: FormData = form_data_clean(await request.formData());
form_data_ensure_keys(data, ["username", "password", "redirect_url"]);
try {
await locals.pb
.collection("users")
.authWithPassword(data.get("username")?.toString(), data.get("password")?.toString());
} catch (err) {
error(400, "Failed to login!");
}
redirect(303, data.get("redirect_url")?.toString() ?? "/");
},
logout: async ({ request, locals }) => {
if (!locals.user) {
error(400, "Not logged in!");
}
const data: FormData = form_data_clean(await request.formData());
form_data_ensure_keys(data, ["redirect_url"]);
locals.pb.authStore.clear();
locals.user = undefined;
redirect(303, data.get("redirect_url")?.toString() ?? "/");
},
} satisfies Actions;

View File

@ -1,93 +0,0 @@
import { form_data_clean, form_data_ensure_keys, form_data_get_and_remove_id } from "$lib/form";
import type { CurrentPickedUser, Race, RacePick, RaceResult } from "$lib/schema";
import type { Actions, PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ fetch, locals }) => {
const fetch_racepicks = async (): Promise<RacePick[]> => {
// Don't expand race/pxx/dnf since we already fetched those
const racepicks: RacePick[] = await locals.pb
.collection("racepicks")
.getFullList({ fetch: fetch, expand: "user" });
// TODO: Fill in the expanded race pictogram_url fields
return racepicks;
};
const fetch_currentrace = async (): Promise<Race | null> => {
const currentrace: Race[] = await locals.pb.collection("currentrace").getFullList();
// The currentrace collection either has a single or no entries
if (currentrace.length == 0) return null;
currentrace[0].pictogram_url = await locals.pb.files.getURL(
currentrace[0],
currentrace[0].pictogram,
);
return currentrace[0];
};
const fetch_currentpickedusers = async (): Promise<CurrentPickedUser[]> => {
const currentpickedusers: CurrentPickedUser[] = await locals.pb
.collection("currentpickedusers")
.getFullList();
currentpickedusers.map((currentpickeduser: CurrentPickedUser) => {
if (currentpickeduser.avatar) {
currentpickeduser.avatar_url = locals.pb.files.getURL(
currentpickeduser,
currentpickeduser.avatar,
);
}
});
return currentpickedusers;
};
// TODO: Duplicated code from data/raceresults/+page.server.ts
const fetch_raceresults = async (): Promise<RaceResult[]> => {
// Don't expand races/pxxs/dnfs since we already fetched those
const raceresults: RaceResult[] = await locals.pb.collection("raceresultsdesc").getFullList();
return raceresults;
};
return {
racepicks: await fetch_racepicks(),
currentrace: await fetch_currentrace(),
currentpickedusers: await fetch_currentpickedusers(),
raceresults: await fetch_raceresults(),
};
};
export const actions = {
create_racepick: async ({ request, locals }) => {
const data: FormData = form_data_clean(await request.formData());
form_data_ensure_keys(data, ["user", "race", "pxx", "dnf"]);
if (locals.user?.id !== data.get("user")) return { unauthorized: true };
await locals.pb.collection("racepicks").create(data);
},
update_racepick: async ({ request, locals }) => {
const data: FormData = form_data_clean(await request.formData());
form_data_ensure_keys(data, ["user", "race"]);
const id: string = form_data_get_and_remove_id(data);
if (locals.user?.id !== data.get("user")) return { unauthorized: true };
await locals.pb.collection("racepicks").update(id, data);
},
delete_racepick: async ({ request, locals }) => {
const data: FormData = form_data_clean(await request.formData());
form_data_ensure_keys(data, ["user", "race"]);
const id: string = form_data_get_and_remove_id(data);
if (locals.user?.id !== data.get("user")) return { unauthorized: true };
await locals.pb.collection("racepicks").delete(id);
},
} satisfies Actions;

View File

@ -19,93 +19,93 @@
RACE_PICTOGRAM_HEIGHT,
RACE_PICTOGRAM_WIDTH,
} from "$lib/config";
import { runes, substring } from "runes2";
import { format_date, shortdatetimeformat } from "$lib/date";
import type { CurrentPickedUser, RacePick } from "$lib/schema";
import { get_by_value, get_driver_headshot_template } from "$lib/database";
import { format } from "date-fns";
import { pbUser } from "$lib/pocketbase";
let { data }: { data: PageData } = $props();
const currentpick: RacePick | undefined =
data.racepicks.filter(
(racepick: RacePick) =>
racepick.expand.user.username === data.user?.username &&
racepick.race === data.currentrace?.id,
)[0] ?? undefined;
let pxx_select_value: string = $state(currentpick?.pxx ?? "");
let dnf_select_value: string = $state(currentpick?.dnf ?? "");
const modalStore: ModalStore = getModalStore();
const create_guess_handler = async (event: Event) => {
const racepick_handler = async (event: Event) => {
const modalSettings: ModalSettings = {
type: "component",
component: "racePickCard",
meta: {
racepick: currentpick,
currentrace: data.currentrace,
user: data.user,
drivers: await data.drivers,
disable_inputs: false, // TODO: Datelock
headshot_template: get_driver_headshot_template(await data.graphics),
pxx_select_value: pxx_select_value,
dnf_select_value: dnf_select_value,
data,
racepick: data.racepick,
},
};
modalStore.trigger(modalSettings);
};
const pickedusers = data.currentpickedusers.filter(
(currentpickeduser: CurrentPickedUser) => currentpickeduser.picked,
);
const outstandingusers = data.currentpickedusers.filter(
(currentpickeduser: CurrentPickedUser) => !currentpickeduser.picked,
// Users that have already picked the current race
let pickedusers: Promise<CurrentPickedUser[]> = $derived.by(async () =>
(await data.currentpickedusers).filter(
(currentpickeduser: CurrentPickedUser) => currentpickeduser.picked,
),
);
const dateformat: string = "dd.MM' 'HH:mm";
const formatdate = (date: string): string => format(new Date(date), dateformat);
// Users that didn't already pick the current race
let outstandingusers: Promise<CurrentPickedUser[]> = $derived.by(async () =>
(await data.currentpickedusers).filter(
(currentpickeduser: CurrentPickedUser) => !currentpickeduser.picked,
),
);
const race_popupsettings = (target: string): PopupSettings => {
return {
event: "click",
target: target,
placement: "right",
placement: "right-start",
};
};
</script>
{#if data.currentrace}
<Accordion class="card mx-auto bg-surface-500 shadow" regionPanel="pt-0" width="w-full">
<AccordionItem>
<svelte:fragment slot="lead"><ChequeredFlagIcon /></svelte:fragment>
<svelte:fragment slot="summary">
<span class="font-bold">Next Race Guess</span>
</svelte:fragment>
<svelte:fragment slot="content">
<!-- Show information about the next race -->
<div class="justify-center gap-2 lg:flex">
<div class="mt-2 flex gap-2">
<div class="card flex w-full flex-col p-2 shadow">
<svelte:head>
<title>Formula 11 - Race Picks</title>
</svelte:head>
<!-- Only show the userguess if signed in and we have a next race -->
{#if $pbUser && data.currentrace}
{#await Promise.all( [data.drivers, data.currentpickedusers, pickedusers, outstandingusers], ) then [drivers, currentpicked, picked, outstanding]}
<!-- HACK: relative was required to get the shadow to show up above the upper table occluder? -->
<Accordion
class="card relative z-20 mx-auto bg-surface-500 shadow"
regionPanel="pt-0"
width="w-full"
>
<AccordionItem>
<svelte:fragment slot="lead"><ChequeredFlagIcon /></svelte:fragment>
<svelte:fragment slot="summary">
<span class="font-bold">Next Race Guess</span>
</svelte:fragment>
<svelte:fragment slot="content">
<div class="grid grid-cols-2 gap-2 lg:mx-auto lg:w-fit lg:grid-cols-6">
<!-- Show information about the next race -->
<div class="card flex w-full min-w-40 flex-col p-2 shadow lg:max-w-40">
<span class="font-bold">
Step {data.currentrace.step}: {data.currentrace.name}
{data.currentrace.name}
</span>
{#if data.currentrace.sprintdate}
<div class="flex gap-2">
<span class="w-12">SQuali:</span>
<span>{formatdate(data.currentrace.sprintqualidate)}</span>
<span>{format_date(data.currentrace.sprintqualidate, shortdatetimeformat)}</span>
</div>
<div class="flex gap-2">
<span class="w-12">SRace:</span>
<span>{formatdate(data.currentrace.sprintdate)}</span>
<span>{format_date(data.currentrace.sprintdate, shortdatetimeformat)}</span>
</div>
{/if}
<div class="flex gap-2">
<span class="w-12">Quali:</span>
<span>{formatdate(data.currentrace.qualidate)}</span>
<span>{format_date(data.currentrace.qualidate, shortdatetimeformat)}</span>
</div>
<div class="flex gap-2">
<span class="w-12">Race:</span>
<span>{formatdate(data.currentrace.racedate)}</span>
<span>{format_date(data.currentrace.racedate, shortdatetimeformat)}</span>
</div>
<div class="m-auto flex">
<div class="mr-1 mt-1">
@ -114,112 +114,108 @@
<Countdown date={data.currentrace.racedate} extraclass="font-bold" />
</div>
</div>
<div class="card w-full p-2 shadow">
<span class="text-nowrap font-bold">Track Layout:</span>
<!-- Show race pictogram -->
<div class="card w-full min-w-40 p-2 shadow lg:max-w-40">
<h1 class="mb-2 text-nowrap font-bold">Track Layout:</h1>
<LazyImage
src={data.currentrace.pictogram_url ?? "Invalid"}
imgwidth={RACE_PICTOGRAM_WIDTH}
imgheight={RACE_PICTOGRAM_HEIGHT}
containerstyle="height: 115px; margin: auto;"
containerstyle="height: 105px; margin: auto;"
imgstyle="background: transparent;"
/>
</div>
</div>
<!-- Only show the userguess if signed in -->
{#if data.user}
<div class="mt-2 flex gap-2">
<div class="card w-full p-2 pb-0 shadow">
<h1 class="mb-2 text-nowrap font-bold">Your P{data.currentrace.pxx} Pick:</h1>
{#await data.graphics then graphics}
{#await data.drivers then drivers}
<LazyImage
src={get_by_value(drivers, "id", currentpick?.pxx ?? "")?.headshot_url ??
get_driver_headshot_template(graphics)}
imgwidth={DRIVER_HEADSHOT_WIDTH}
imgheight={DRIVER_HEADSHOT_HEIGHT}
containerstyle="height: 115px; margin: auto;"
imgclass="bg-transparent cursor-pointer"
hoverzoom
onclick={create_guess_handler}
/>
{/await}
{/await}
</div>
<div class="card w-full p-2 pb-0 shadow">
<h1 class="mb-2 text-nowrap font-bold">Your DNF Pick:</h1>
{#await data.graphics then graphics}
{#await data.drivers then drivers}
<LazyImage
src={get_by_value(drivers, "id", currentpick?.dnf ?? "")?.headshot_url ??
get_driver_headshot_template(graphics)}
imgwidth={DRIVER_HEADSHOT_WIDTH}
imgheight={DRIVER_HEADSHOT_HEIGHT}
containerstyle="height: 115px; margin: auto;"
imgclass="bg-transparent cursor-pointer"
hoverzoom
onclick={create_guess_handler}
/>
{/await}
{/await}
<!-- PXX pick -->
<div class="card w-full min-w-40 p-2 pb-0 shadow lg:max-w-40">
<h1 class="mb-2 text-nowrap font-bold">Your P{data.currentrace.pxx} Pick:</h1>
<LazyImage
src={get_by_value(drivers, "id", data.racepick?.pxx ?? "")?.headshot_url ??
get_driver_headshot_template(data.graphics)}
imgwidth={DRIVER_HEADSHOT_WIDTH}
imgheight={DRIVER_HEADSHOT_HEIGHT}
containerstyle="height: 115px; margin: auto;"
imgclass="bg-transparent cursor-pointer"
hoverzoom
onclick={racepick_handler}
/>
</div>
<!-- DNF pick -->
<div class="card w-full min-w-40 p-2 pb-0 shadow lg:max-w-40">
<h1 class="mb-2 text-nowrap font-bold">Your DNF Pick:</h1>
<LazyImage
src={get_by_value(drivers, "id", data.racepick?.dnf ?? "")?.headshot_url ??
get_driver_headshot_template(data.graphics)}
imgwidth={DRIVER_HEADSHOT_WIDTH}
imgheight={DRIVER_HEADSHOT_HEIGHT}
containerstyle="height: 115px; margin: auto;"
imgclass="bg-transparent cursor-pointer"
hoverzoom
onclick={racepick_handler}
/>
</div>
<!-- Show users that have picked -->
<div class="card max-h-[155px] w-full min-w-40 p-2 shadow lg:max-w-40">
<h1 class="text-nowrap font-bold">
Picked ({picked.length}/{currentpicked.length}):
</h1>
<div class="mt-1 grid max-h-[110px] grid-cols-4 gap-x-0 gap-y-0.5 overflow-y-scroll">
{#each picked as user}
<LazyImage
src={user.avatar_url ?? get_driver_headshot_template(data.graphics)}
imgwidth={AVATAR_WIDTH}
imgheight={AVATAR_HEIGHT}
containerstyle="height: 35px; width: 35px;"
imgclass="bg-surface-400 rounded-full"
tooltip={user.firstname}
/>
{/each}
</div>
</div>
{/if}
<!-- Show users that have and have not picked yet -->
<div class="mt-2 flex gap-2">
<div class="card w-full p-2 shadow">
<!-- Show users that have not picked yet -->
<div class="card max-h-[155px] w-full min-w-40 p-2 shadow lg:max-w-40">
<h1 class="text-nowrap font-bold">
Picked ({pickedusers.length}/{data.currentpickedusers.length}):
Missing ({outstanding.length}/{currentpicked.length}):
</h1>
<div class="mt-1 grid grid-cols-4 gap-x-2 gap-y-0.5">
{#await data.graphics then graphics}
{#each pickedusers.slice(0, 16) as user}
<LazyImage
src={user.avatar_url ?? get_driver_headshot_template(graphics)}
imgwidth={AVATAR_WIDTH}
imgheight={AVATAR_HEIGHT}
containerstyle="height: 35px; width: 35px;"
imgclass="bg-surface-400 rounded-full"
/>
{/each}
{/await}
</div>
</div>
<div class="card w-full p-2 shadow">
<h1 class="text-nowrap font-bold">
Outstanding ({outstandingusers.length}/{data.currentpickedusers.length}):
</h1>
<div class="mt-1 grid grid-cols-4 gap-x-0 gap-y-0.5">
{#await data.graphics then graphics}
{#each outstandingusers.slice(0, 16) as user}
<LazyImage
src={user.avatar_url ?? get_driver_headshot_template(graphics)}
imgwidth={AVATAR_WIDTH}
imgheight={AVATAR_HEIGHT}
containerstyle="height: 35px; width: 35px;"
imgclass="bg-surface-400 rounded-full"
/>
{/each}
{/await}
<div class="mt-1 grid max-h-[110px] grid-cols-4 gap-x-0 gap-y-0.5 overflow-y-scroll">
{#each outstanding as user}
<LazyImage
src={user.avatar_url ?? get_driver_headshot_template(data.graphics)}
imgwidth={AVATAR_WIDTH}
imgheight={AVATAR_HEIGHT}
containerstyle="height: 35px; width: 35px;"
imgclass="bg-surface-400 rounded-full"
tooltip={user.firstname}
/>
{/each}
</div>
</div>
</div>
</div>
</svelte:fragment>
</AccordionItem>
</Accordion>
</svelte:fragment>
</AccordionItem>
</Accordion>
{/await}
{/if}
<!-- The fookin table -->
<div class="flex">
<div>
<div
class="{!$pbUser
? 'mt-[-8px]'
: ''} relative h-[calc(100vh-200px)] w-[calc(100vw-16px)] scroll-pl-8 scroll-pt-[72px] flex-col overflow-scroll max-lg:hide-scrollbar lg:h-[calc(100vh-116px)] lg:scroll-pl-[152px]"
>
<div class="sticky top-0 z-[15] flex w-full min-w-fit">
<!-- Points color coding legend -->
<!-- Use mt-3/mt-4 to account for 2x padding around the avatar. -->
<div class="mt-4 h-10 w-7 lg:w-36">
<div
class="sticky left-0 z-20 h-16 w-7 min-w-7 max-w-7 bg-surface-50 pt-2 lg:w-36 lg:min-w-36 lg:max-w-36 lg:pt-4"
>
<div class="hidden h-5 text-sm font-bold lg:block">Points:</div>
<div
class="flex h-full flex-col overflow-hidden rounded-b-lg rounded-t-lg shadow lg:h-5 lg:flex-row lg:!rounded-l-lg lg:!rounded-r-lg lg:rounded-b-none lg:rounded-t-none"
class="flex h-full flex-col overflow-hidden rounded-b-lg rounded-t-lg lg:h-5 lg:flex-row lg:!rounded-l-lg lg:!rounded-r-lg lg:rounded-b-none lg:rounded-t-none"
>
<!-- Large Screens: -->
<span
@ -255,29 +251,70 @@
</div>
</div>
{#await data.races then races}
{#each data.raceresults as result}
{@const race = get_by_value(races, "id", result.race)}
<!-- Avatars -->
<div class="flex h-16 w-full bg-surface-50">
{#await data.currentpickedusers then currentpicked}
{#each currentpicked as user}
<div
class="card ml-1 mt-2 w-full min-w-14 rounded-b-none bg-surface-400 py-2
{$pbUser && $pbUser.username === user.username ? '!bg-primary-400' : ''}"
>
<!-- Avatar + name display at the top -->
<div class="m-auto flex h-10 w-fit">
<LazyImage
src={user.avatar_url ?? get_driver_headshot_template(data.graphics)}
imgwidth={AVATAR_WIDTH}
imgheight={AVATAR_HEIGHT}
containerstyle="height: 40px; width: 40px;"
imgclass="bg-surface-400 rounded-full"
tooltip={user.firstname}
/>
<div
style="height: 40px; line-height: 40px;"
class="ml-2 hidden text-nowrap text-center align-middle max-2xl:text-sm max-xl:text-xs lg:block"
>
{user.firstname}
</div>
</div>
</div>
{/each}
{/await}
</div>
</div>
<div
use:popup={race_popupsettings(race?.id ?? "Invalid")}
class="card mt-2 flex h-20 w-7 flex-col !rounded-r-none bg-surface-300 p-2 shadow lg:w-36"
>
<span class="hidden text-sm font-bold lg:block">
{race?.step}: {race?.name}
</span>
<span class="block rotate-90 text-sm font-bold lg:hidden">
{race?.name.slice(0, 8)}{(race?.name.length ?? 8) > 8 ? "." : ""}
</span>
<span class="hidden text-sm lg:block">Date: {formatdate(race?.racedate ?? "")}</span>
<span class="hidden text-sm lg:block">Guessed: P{race?.pxx}</span>
</div>
<div class="flex w-full min-w-fit">
<!-- Race name display -->
<div
class="sticky left-0 z-10 w-7 min-w-7 max-w-7 bg-surface-50 lg:w-36 lg:min-w-36 lg:max-w-36"
>
{#await Promise.all( [data.races, data.raceresults, data.drivers], ) then [races, raceresults, drivers]}
{#each raceresults as result}
{@const race = get_by_value(races, "id", result.race)}
<!-- The race result popup is triggered on click on the race -->
<div data-popup={race?.id ?? "Invalid"} class="card z-50 p-2 shadow">
<span class="font-bold">Result:</span>
<div class="mt-2 flex flex-col gap-1">
{#await data.drivers then drivers}
<div
use:popup={race_popupsettings(race?.id ?? "Invalid")}
class="card mt-1 flex h-16 w-7 cursor-pointer flex-col !rounded-r-none bg-surface-400 lg:h-20 lg:w-36 lg:p-2"
>
<!-- For large screens -->
<span class="hidden text-sm font-bold lg:block">
{race?.step}: {race?.name}
</span>
<span class="hidden text-sm lg:block">
Date: {format_date(race?.racedate ?? "", shortdatetimeformat)}
</span>
<span class="hidden text-sm lg:block">Guessed: P{race?.pxx}</span>
<!-- For small screens -->
<!-- TODO: This requires the race name to end with an emoji, but this is never enforced -->
<div class="mx-[2px] my-[18px] block text-lg font-bold lg:hidden">
{runes(race?.name ?? "Invalid").at(-1)}
</div>
</div>
<!-- The race result popup is triggered on click on the race -->
<div data-popup={race?.id ?? "Invalid"} class="card z-50 bg-surface-400 p-2 shadow">
<span class="font-bold">Result:</span>
<div class="mt-2 flex flex-col gap-1">
{#each result.pxxs as pxx, index}
{@const driver = get_by_value(drivers, "id", pxx)}
<div class="flex gap-2">
@ -301,47 +338,21 @@
</span>
</div>
{/each}
{/await}
</div>
</div>
</div>
{/each}
{/await}
</div>
{/each}
{/await}
</div>
<div class="hide-scrollbar flex w-full overflow-x-scroll pb-2">
<!-- Not ideal but currentpickedusers contains all users, so we do not need to fetch the users separately -->
{#each data.currentpickedusers as user}
{@const picks = data.racepicks.filter((pick: RacePick) => pick.user === user.id)}
<!-- Picks -->
<div class="flex w-full">
<!-- Not ideal but currentpickedusers contains all users, so we do not need to fetch the users separately -->
{#await Promise.all( [data.currentpickedusers, data.racepicks, data.races, data.drivers, data.raceresults], ) then [currentpicked, racepicks, races, drivers, raceresults]}
{#each currentpicked as user}
{@const picks = racepicks.filter((pick: RacePick) => pick.user === user.id)}
<div
class="card ml-1 mt-2 w-full min-w-12 overflow-hidden py-2 shadow lg:ml-2 {data.user &&
data.user.username === user.username
? 'bg-primary-300'
: ''}"
>
<!-- Avatar + name display at the top -->
<div class="mx-auto flex h-10 w-fit">
{#await data.graphics then graphics}
<LazyImage
src={user.avatar_url ?? get_driver_headshot_template(graphics)}
imgwidth={AVATAR_WIDTH}
imgheight={AVATAR_HEIGHT}
containerstyle="height: 40px; width: 40px;"
imgclass="bg-surface-400 rounded-full"
/>
{/await}
<div
style="height: 40px; line-height: 40px;"
class="ml-2 hidden text-nowrap text-center align-middle lg:block"
>
<!-- TODO: Setting to toggle between username or firstname display -->
{user.username}
</div>
</div>
{#await data.races then races}
{#await data.drivers then drivers}
{#each data.raceresults as result}
<div class="ml-1 w-full min-w-14">
{#each raceresults as result}
{@const race = get_by_value(races, "id", result.race)}
{@const pick = picks.filter((pick: RacePick) => pick.race === race?.id)[0]}
{@const pxxcolor = PXX_COLORS[result.pxxs.indexOf(pick?.pxx ?? "Invalid")]}
@ -349,16 +360,16 @@
result.dnfs.indexOf(pick?.dnf ?? "Invalid") >= 0 ? PXX_COLORS[3] : PXX_COLORS[-1]}
{#if pick}
<div class="mt-2 h-20 w-full border bg-surface-300 p-1 lg:p-2">
<div class="mt-1 h-16 w-full border bg-surface-200 px-1 py-2 lg:h-20 lg:px-2">
<div class="mx-auto flex h-full w-fit flex-col justify-evenly">
<span
class="p-1 text-center text-sm rounded-container-token"
class="w-10 p-1 text-center text-xs rounded-container-token lg:text-sm"
style="background: {pxxcolor};"
>
{get_by_value(drivers, "id", pick?.pxx ?? "")?.code}
</span>
<span
class="p-1 text-center text-sm rounded-container-token"
class="w-10 p-1 text-center text-xs rounded-container-token lg:text-sm"
style="background: {dnfcolor};"
>
{get_by_value(drivers, "id", pick?.dnf ?? "")?.code}
@ -366,21 +377,12 @@
</div>
</div>
{:else}
<div class="mt-2 h-20 w-full"></div>
<div class="mt-1 h-16 w-full px-1 py-2 lg:h-20 lg:px-2"></div>
{/if}
{/each}
{/await}
{/await}
</div>
{/each}
</div>
{/each}
{/await}
</div>
</div>
</div>
<!-- "Table" of past guesses (not an actual table this time): -->
<!-- - Left column (rounded div column with race information: name, step, pxx) -->
<!-- - Show full result on-click? -->
<!-- - Rounded middle block (rest of width) with guesses: -->
<!-- - Just avatar at the top (reduce width) with points below, username on hover? -->
<!-- - Color-coded compact pxx + dnf picks (with podium + skull icons to differentiate?) -->
<!-- Make 2 variations (races as rows and races as columns) + settings toggle? -->

View File

@ -0,0 +1,35 @@
import {
fetch_currentpickedusers,
fetch_currentrace,
fetch_drivers,
fetch_visibleracepicks,
fetch_raceresults,
fetch_races,
fetch_substitutions,
fetch_currentracepick,
} from "$lib/fetch";
import type { PageLoad } from "../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends(
"data:user",
"data:racepicks",
"data:users",
"data:raceresults",
"data:drivers",
"data:races",
"data:substitutions",
);
return {
racepicks: fetch_visibleracepicks(fetch),
currentpickedusers: fetch_currentpickedusers(fetch),
raceresults: fetch_raceresults(fetch),
drivers: fetch_drivers(fetch),
races: fetch_races(fetch),
substitutions: fetch_substitutions(fetch),
currentrace: await fetch_currentrace(fetch),
racepick: await fetch_currentracepick(fetch),
};
};

View File

@ -2,6 +2,10 @@
import { Card } from "$lib/components";
</script>
<svelte:head>
<title>Formula 11 - Rules</title>
</svelte:head>
<div class="grid grid-cols-1 gap-2 xl:grid-cols-4">
<Card>
<h1 class="text-lg font-bold">Format</h1>

View File

@ -1 +1,524 @@
<h1>Season Picks</h1>
<script lang="ts">
import {
Accordion,
AccordionItem,
getModalStore,
type ModalSettings,
type ModalStore,
} from "@skeletonlabs/skeleton";
import type { PageData } from "./$types";
import type { Driver, Hottake, SeasonPick, SeasonPickedUser, User } from "$lib/schema";
import { ChequeredFlagIcon, LazyImage } from "$lib/components";
import {
get_by_value,
get_driver_headshot_template,
get_team_banner_template,
} from "$lib/database";
import {
AVATAR_HEIGHT,
AVATAR_WIDTH,
DRIVER_HEADSHOT_HEIGHT,
DRIVER_HEADSHOT_WIDTH,
TEAM_BANNER_HEIGHT,
TEAM_BANNER_WIDTH,
} from "$lib/config";
import Countdown from "$lib/components/Countdown.svelte";
import { pbUser } from "$lib/pocketbase";
let { data }: { data: PageData } = $props();
const modalStore: ModalStore = getModalStore();
const seasonpick_handler = async (event: Event) => {
const modalSettings: ModalSettings = {
type: "component",
component: "seasonPickCard",
meta: {
data,
seasonpick: data.seasonpick,
},
};
modalStore.trigger(modalSettings);
};
// Users that have already picked the season
let pickedusers: Promise<SeasonPickedUser[]> = $derived.by(async () =>
(await data.seasonpickedusers).filter(
(seasonpickeduser: SeasonPickedUser) => seasonpickeduser.picked,
),
);
// Users that didn't already pick the season
let outstandingusers: Promise<SeasonPickedUser[]> = $derived.by(async () =>
(await data.seasonpickedusers).filter(
(seasonpickeduser: SeasonPickedUser) => !seasonpickeduser.picked,
),
);
</script>
<svelte:head>
<title>Formula 11 - Season Picks</title>
</svelte:head>
<!-- Await this here so the accordion doesn't lag when opening -->
<!-- Only show the stuff if signed in -->
{#if $pbUser}
{#await Promise.all( [data.drivers, data.teams, data.seasonpickedusers, pickedusers, outstandingusers], ) then [drivers, teams, currentpicked, picked, outstanding]}
{@const teamwinners = data.seasonpick
? data.seasonpick.teamwinners
.map((id: string) => get_by_value(drivers, "id", id) as Driver)
.sort((a: Driver, b: Driver) => a.team.localeCompare(b.team))
: undefined}
{@const podiums = data.seasonpick
? data.seasonpick.podiums
.map((id: string) => get_by_value(drivers, "id", id) as Driver)
.sort((a: Driver, b: Driver) => a.code.localeCompare(b.code))
.sort((a: Driver, b: Driver) => a.team.localeCompare(b.team))
: undefined}
<!-- HACK: relative was required to get the shadow to show up above the upper table occluder? -->
<Accordion
class="card relative z-20 mx-auto bg-surface-500 shadow"
regionPanel="pt-2"
width="w-full"
>
<AccordionItem>
<svelte:fragment slot="lead"><ChequeredFlagIcon /></svelte:fragment>
<svelte:fragment slot="summary">
<span class="font-bold">Your Season Pick</span>
</svelte:fragment>
<svelte:fragment slot="content">
<div class="grid grid-cols-2 gap-2 lg:mx-auto lg:w-fit lg:grid-cols-5 2xl:grid-cols-10">
<!-- Hottake -->
<div class="card w-full min-w-40 p-2 lg:max-w-40">
<h1 class="mb-2 text-nowrap font-bold">Hottake:</h1>
<span class="text-sm">{data.seasonpick?.hottake}</span>
</div>
<!-- Doohanstarts -->
<div class="card w-full min-w-40 p-2 lg:max-w-40">
<h1 class="mb-2 text-nowrap font-bold">Doohan Starts:</h1>
{#if data.seasonpick}
<span class="text-sm">
Jack Doohan startet {data.seasonpick?.doohanstarts} mal.
</span>
{/if}
</div>
<!-- WDC -->
<div class="card w-full min-w-40 p-2 pb-0 lg:max-w-40">
<h1 class="mb-2 text-nowrap font-bold">WDC:</h1>
<LazyImage
src={get_by_value(drivers, "id", data.seasonpick?.wdcwinner ?? "")?.headshot_url ??
get_driver_headshot_template(data.graphics)}
imgwidth={DRIVER_HEADSHOT_WIDTH}
imgheight={DRIVER_HEADSHOT_HEIGHT}
containerstyle="height: 115px; margin: auto;"
imgclass="bg-transparent cursor-pointer"
hoverzoom
onclick={seasonpick_handler}
/>
</div>
<!-- WDC -->
<div class="card w-full min-w-40 p-2 pb-0 lg:max-w-40">
<h1 class="mb-2 text-nowrap font-bold">WCC:</h1>
<LazyImage
src={get_by_value(teams, "id", data.seasonpick?.wccwinner ?? "")?.banner_url ??
get_team_banner_template(data.graphics)}
imgwidth={TEAM_BANNER_WIDTH}
imgheight={TEAM_BANNER_HEIGHT}
containerstyle="height: 80px; margin: auto;"
imgclass="bg-transparent cursor-pointer rounded-md"
hoverzoom
onclick={seasonpick_handler}
/>
</div>
<!-- Overtakes -->
<div class="card w-full min-w-40 p-2 pb-0 lg:max-w-40">
<h1 class="mb-2 text-nowrap font-bold">Most Overtakes:</h1>
<LazyImage
src={get_by_value(drivers, "id", data.seasonpick?.mostovertakes ?? "")
?.headshot_url ?? get_driver_headshot_template(data.graphics)}
imgwidth={DRIVER_HEADSHOT_WIDTH}
imgheight={DRIVER_HEADSHOT_HEIGHT}
containerstyle="height: 115px; margin: auto;"
imgclass="bg-transparent cursor-pointer"
hoverzoom
onclick={seasonpick_handler}
/>
</div>
<!-- DNFs -->
<div class="card w-full min-w-40 p-2 pb-0 lg:max-w-40">
<h1 class="mb-2 text-nowrap font-bold">Most DNFs:</h1>
<LazyImage
src={get_by_value(drivers, "id", data.seasonpick?.mostdnfs ?? "")?.headshot_url ??
get_driver_headshot_template(data.graphics)}
imgwidth={DRIVER_HEADSHOT_WIDTH}
imgheight={DRIVER_HEADSHOT_HEIGHT}
containerstyle="height: 115px; margin: auto;"
imgclass="bg-transparent cursor-pointer"
hoverzoom
onclick={seasonpick_handler}
/>
</div>
<!-- Teamwinners -->
<div class="card max-h-[155px] w-full min-w-40 p-2 lg:max-w-40">
<h1 class="text-nowrap font-bold">Teamwinners:</h1>
<div class="mt-1 grid max-h-[110px] grid-cols-4 gap-x-0 gap-y-0.5 overflow-y-scroll">
{#if teamwinners}
{#each teamwinners as winner}
<LazyImage
src={winner.headshot_url ?? get_driver_headshot_template(data.graphics)}
imgwidth={AVATAR_WIDTH}
imgheight={AVATAR_HEIGHT}
containerstyle="height: 35px; width: 35px;"
imgclass="bg-surface-400 rounded-full"
tooltip={winner ? `${winner.firstname} ${winner.lastname}` : undefined}
/>
{/each}
{/if}
</div>
</div>
<!-- Podiums -->
<div class="card max-h-[155px] w-full min-w-40 p-2 lg:max-w-40">
<h1 class="text-nowrap font-bold">Podiums:</h1>
<div class="mt-1 grid max-h-[110px] grid-cols-4 gap-x-0 gap-y-0.5 overflow-y-scroll">
{#if podiums}
{#each podiums as podium}
<LazyImage
src={podium.headshot_url ?? get_driver_headshot_template(data.graphics)}
imgwidth={AVATAR_WIDTH}
imgheight={AVATAR_HEIGHT}
containerstyle="height: 35px; width: 35px;"
imgclass="bg-surface-400 rounded-full"
tooltip={podium ? `${podium.firstname} ${podium.lastname}` : undefined}
/>
{/each}
{/if}
</div>
</div>
<!-- Show users that have picked -->
<div class="card max-h-[155px] w-full min-w-40 p-2 lg:max-w-40">
<h1 class="text-nowrap font-bold">
Picked ({picked.length}/{currentpicked.length}):
</h1>
<div class="mt-1 grid max-h-[110px] grid-cols-4 gap-x-0 gap-y-0.5 overflow-y-scroll">
{#each picked as user}
<LazyImage
src={user.avatar_url ?? get_driver_headshot_template(data.graphics)}
imgwidth={AVATAR_WIDTH}
imgheight={AVATAR_HEIGHT}
containerstyle="height: 35px; width: 35px;"
imgclass="bg-surface-400 rounded-full"
tooltip={user.firstname}
/>
{/each}
</div>
</div>
<!-- Show users that have not picked yet -->
<div class="card max-h-[155px] w-full min-w-40 p-2 lg:max-w-40">
<h1 class="text-nowrap font-bold">
Missing ({outstanding.length}/{currentpicked.length}):
</h1>
<div class="mt-1 grid max-h-[110px] grid-cols-4 gap-x-0 gap-y-0.5 overflow-y-scroll">
{#each outstanding as user}
<LazyImage
src={user.avatar_url ?? get_driver_headshot_template(data.graphics)}
imgwidth={AVATAR_WIDTH}
imgheight={AVATAR_HEIGHT}
containerstyle="height: 35px; width: 35px;"
imgclass="bg-surface-400 rounded-full"
tooltip={user.firstname}
/>
{/each}
</div>
</div>
</div>
</svelte:fragment>
</AccordionItem>
</Accordion>
{/await}
{/if}
<!-- HACK: Subtract additional ~100px from height because fucking Safari reports the viewport height as if the urlbar was hidden. Original size should be calc(100vh-116px). -->
<div
class="{!$pbUser
? 'mt-[-8px]'
: ''} relative h-[calc(100vh-200px)] w-[calc(100vw-16px)] scroll-pl-8 scroll-pt-[72px] flex-col overflow-scroll max-lg:hide-scrollbar lg:h-[calc(100vh-116px)] lg:scroll-pl-[152px]"
>
<div class="sticky top-0 z-[15] flex w-full min-w-fit">
<!-- TODO: Points popup? -->
<div
class="sticky left-0 z-20 h-16 w-7 min-w-7 max-w-7 bg-surface-50 lg:w-36 lg:min-w-36 lg:max-w-36"
></div>
<!-- Avatars -->
<div class="flex h-16 w-full bg-surface-50">
{#await data.seasonpickedusers then seasonpicked}
{#each seasonpicked as user}
<div
class="card ml-1 mt-2 w-full min-w-36 rounded-b-none bg-surface-400 py-2
{$pbUser && $pbUser.username === user.username ? '!bg-primary-400' : ''}"
>
<!-- Avatar + name display at the top -->
<div class="m-auto flex h-10 w-fit">
<LazyImage
src={user.avatar_url ?? get_driver_headshot_template(data.graphics)}
imgwidth={AVATAR_WIDTH}
imgheight={AVATAR_HEIGHT}
containerstyle="height: 40px; width: 40px;"
imgclass="bg-surface-400 rounded-full"
tooltip={user.firstname}
/>
<div
style="height: 40px; line-height: 40px;"
class="ml-2 text-nowrap text-center align-middle"
>
{user.firstname}
</div>
</div>
</div>
{/each}
{/await}
</div>
</div>
<div class="flex w-full min-w-fit">
<!-- Categories -->
<div
class="sticky left-0 z-10 w-7 min-w-7 max-w-7 bg-surface-50 lg:w-36 lg:min-w-36 lg:max-w-36"
>
<!-- Hottake -->
<div class="card mt-1 flex h-32 w-7 flex-col !rounded-r-none bg-surface-400 p-2 lg:w-36">
<span class="hidden text-nowrap text-sm font-bold lg:block">Hottake</span>
<span class="block rotate-90 text-nowrap text-xs font-bold lg:hidden">Hottake</span>
</div>
{#await data.seasonpicks then seasonpicks}
{#if seasonpicks.length > 0}
<!-- Drivers Champion -->
<div class="card mt-1 flex h-20 w-7 flex-col !rounded-r-none bg-surface-400 p-2 lg:w-36">
<span class="hidden text-nowrap text-sm font-bold lg:block">Drivers<br />Champion</span>
<span class="block rotate-90 text-nowrap text-xs font-bold lg:hidden">WDC</span>
</div>
<!-- Constructors Champion -->
<div class="card mt-1 flex h-20 w-7 flex-col !rounded-r-none bg-surface-400 p-2 lg:w-36">
<span class="hidden text-nowrap text-sm font-bold lg:block">
Constructors<br />Champion
</span>
<span class="block rotate-90 text-nowrap text-xs font-bold lg:hidden">WCC</span>
</div>
<!-- Overtakes -->
<div class="card mt-1 flex h-20 w-7 flex-col !rounded-r-none bg-surface-400 p-2 lg:w-36">
<span class="hidden text-nowrap text-sm font-bold lg:block">Most Overtakes</span>
<span class="block rotate-90 text-nowrap text-xs font-bold lg:hidden">Overtakes</span>
</div>
<!-- DNFs -->
<div class="card mt-1 flex h-20 w-7 flex-col !rounded-r-none bg-surface-400 p-2 lg:w-36">
<span class="hidden text-nowrap text-sm font-bold lg:block">Most DNFs</span>
<span class="block rotate-90 text-nowrap text-xs font-bold lg:hidden">DNFs</span>
</div>
<!-- Doohan Starts -->
<div class="card mt-1 flex h-20 w-7 flex-col !rounded-r-none bg-surface-400 p-2 lg:w-36">
<span class="hidden text-nowrap text-sm font-bold lg:block">Doohan Starts</span>
<span class="block rotate-90 text-nowrap text-xs font-bold lg:hidden">Doohan</span>
</div>
<!-- Teamwinners -->
<div class="card mt-1 flex h-64 w-7 flex-col !rounded-r-none bg-surface-400 p-2 lg:w-36">
<span class="hidden text-nowrap text-sm font-bold lg:block"
>Team-Battle<br />Winners</span
>
<span class="block rotate-90 text-nowrap text-xs font-bold lg:hidden">Teamwinners</span>
</div>
<!-- Podiums -->
<div class="card mt-1 flex h-64 w-7 flex-col !rounded-r-none bg-surface-400 p-2 lg:w-36">
<span class="hidden text-nowrap text-sm font-bold lg:block">Podiums</span>
<span class="block rotate-90 text-nowrap text-xs font-bold lg:hidden">Podiums</span>
</div>
{/if}
{/await}
</div>
<!-- Picks -->
<div class="flex w-full">
{#await Promise.all( [data.seasonpickedusers, data.seasonpicks, data.hottakes, data.drivers, data.teams], ) then [seasonpicked, seasonpicks, hottakes, drivers, teams]}
{#each seasonpicked as user}
{@const hottake =
hottakes.filter((take: Hottake) => take.user === user.id)[0] ?? undefined}
{@const pick =
seasonpicks.filter((pick: SeasonPick) => pick.user === user.id)[0] ?? undefined}
{@const wdcwinner = pick ? get_by_value(drivers, "id", pick.wdcwinner) : undefined}
{@const wccwinner = pick ? get_by_value(teams, "id", pick.wccwinner) : undefined}
{@const mostovertakes = pick
? get_by_value(drivers, "id", pick.mostovertakes)
: undefined}
{@const mostdnfs = pick ? get_by_value(drivers, "id", pick.mostdnfs) : undefined}
{@const drivers_startedactive = drivers
.filter((driver: Driver) => driver.started_active)
.sort((a: Driver, b: Driver) => a.code.localeCompare(b.code))
.sort((a: Driver, b: Driver) => a.team.localeCompare(b.team))}
<div class="ml-1 w-full min-w-36">
<!-- Hottake -->
<div
class="mt-1 h-32 w-full overflow-y-scroll border bg-surface-200 px-1 py-2 leading-3 lg:px-2"
>
<div class="mx-auto w-fit text-xs font-bold lg:text-sm">
{hottake?.hottake ?? "?"}
</div>
</div>
{#if seasonpicks.length > 0}
<!-- Drivers Champion -->
<div class="mt-1 h-20 w-full border bg-surface-200 px-1 py-2 leading-3 lg:px-2">
<div class="mx-auto w-fit">
<!-- NOTE: The containerstyle should be 64x64, don't know why that doesn't fit... (also below) -->
<LazyImage
src={wdcwinner?.headshot_url ?? get_driver_headshot_template(data.graphics)}
imgwidth={DRIVER_HEADSHOT_HEIGHT}
imgheight={DRIVER_HEADSHOT_WIDTH}
containerstyle="height: 62px;"
imgclass="bg-surface-400 rounded-md"
tooltip={wdcwinner ? `${wdcwinner.firstname} ${wdcwinner.lastname}` : undefined}
/>
</div>
</div>
<!-- Constructors Champion -->
<div class="mt-1 h-20 w-full border bg-surface-200 p-1 px-1 py-2 leading-3 lg:px-2">
<div class="mx-auto w-fit">
<LazyImage
src={wccwinner?.banner_url ?? get_team_banner_template(data.graphics)}
imgwidth={TEAM_BANNER_WIDTH}
imgheight={TEAM_BANNER_HEIGHT}
containerstyle="height: 62px;"
imgclass="bg-surface-400 rounded-md"
tooltip={wccwinner?.name}
/>
</div>
</div>
<!-- Most Overtakes -->
<div class="mt-1 h-20 w-full border bg-surface-200 px-1 py-2 leading-3 lg:px-2">
<div class="mx-auto w-fit">
<LazyImage
src={mostovertakes?.headshot_url ?? get_driver_headshot_template(data.graphics)}
imgwidth={DRIVER_HEADSHOT_HEIGHT}
imgheight={DRIVER_HEADSHOT_WIDTH}
containerstyle="height: 62px;"
imgclass="bg-surface-400 rounded-md"
tooltip={mostovertakes
? `${mostovertakes.firstname} ${mostovertakes.lastname}`
: undefined}
/>
</div>
</div>
<!-- Most DNFs -->
<div class="mt-1 h-20 w-full border bg-surface-200 px-1 py-2 leading-3 lg:px-2">
<div class="mx-auto w-fit">
<LazyImage
src={mostdnfs?.headshot_url ?? get_driver_headshot_template(data.graphics)}
imgwidth={DRIVER_HEADSHOT_HEIGHT}
imgheight={DRIVER_HEADSHOT_WIDTH}
containerstyle="height: 62px;"
imgclass="bg-surface-400 rounded-md"
tooltip={mostdnfs ? `${mostdnfs.firstname} ${mostdnfs.lastname}` : undefined}
/>
</div>
</div>
<!-- Doohan Starts -->
<div class="mt-1 h-20 w-full border bg-surface-200 p-1 px-1 py-2 leading-3 lg:px-2">
<div class="mx-auto w-fit text-xs lg:text-sm">
Jack Doohan startet <span class="font-bold">{pick?.doohanstarts ?? "?"}</span> mal.
</div>
</div>
<!-- Teamwinners -->
<div
class="mt-1 h-64 w-full overflow-y-scroll border bg-surface-200 p-1 px-1 py-2 leading-3 lg:px-2"
>
{#if pick && pick.teamwinners}
<div class="grid grid-cols-2 gap-1">
{#each drivers_startedactive as driver}
{@const color: string = get_by_value(teams, "id", driver?.team ?? "")?.color ?? ""}
<div class="mx-auto w-fit text-xs lg:text-sm">
<div
class="flex gap-1"
style="opacity: {pick.teamwinners.includes(driver.id) ? 1.0 : 0.1};"
>
<span
class="badge h-5 w-5"
style="color: {color}; background-color: {color};"
>
</span>
<span class="w-7 align-middle" style="line-height: 20px;">
{driver?.code}
</span>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Podiums -->
<!-- TODO: Replace all style tags throughout the page with custom classes like height here -->
<div
class="mt-1 h-64 w-full overflow-y-scroll border bg-surface-200 p-1 px-1 py-2 leading-3 shadow lg:px-2"
>
{#if pick && pick.podiums}
<div class="grid grid-cols-2 gap-1">
{#each drivers_startedactive as driver}
{@const color: string = get_by_value(teams, "id", driver?.team ?? "")?.color ?? ""}
<div class="mx-auto w-fit text-xs lg:text-sm">
<div
class="flex gap-1"
style="opacity: {pick.podiums.includes(driver.id) ? 1.0 : 0.1};"
>
<span
class="badge h-5 w-5"
style="color: {color}; background-color: {color};"
>
</span>
<span class="w-7 align-middle" style="line-height: 20px;">
{driver?.code}
</span>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{/each}
{/await}
</div>
</div>
</div>
<!-- Season pick lock countdown -->
{#await Promise.all([data.seasonpicks, data.currentrace]) then [seasonpicks, currentrace]}
{#if seasonpicks.length <= 0 && currentrace}
<div class="card mx-auto w-fit p-2 shadow">
<span class="mr-2 text-xl font-medium">Season pick modification will be locked in</span>
<Countdown date={currentrace.qualidate} gotext="0d 0h 0m" extraclass="font-bold text-xl" />
<span class="ml-2 text-xl font-medium">.</span>
</div>
{/if}
{/await}

View File

@ -0,0 +1,25 @@
import {
fetch_currentseasonpick,
fetch_drivers,
fetch_hottakes,
fetch_seasonpickedusers,
fetch_visibleseasonpicks,
fetch_teams,
fetch_currentrace,
} from "$lib/fetch";
import type { PageLoad } from "../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:teams", "data:drivers", "data:seasonpicks", "data:user", "data:users");
return {
teams: fetch_teams(fetch),
drivers: fetch_drivers(fetch),
seasonpicks: fetch_visibleseasonpicks(fetch),
hottakes: fetch_hottakes(fetch),
seasonpickedusers: fetch_seasonpickedusers(fetch),
currentrace: fetch_currentrace(fetch), // Used for countdown
seasonpick: await fetch_currentseasonpick(fetch),
};
};

View File

@ -1 +1,113 @@
<h1>Statistics</h1>
<script lang="ts">
import { Table, type TableColumn } from "$lib/components";
import type { Driver, ScrapedRaceResultAcc } from "$lib/schema";
import {
LineChart,
ScaleTypes,
type ChartTabularData,
type LineChartOptions,
} from "@carbon/charts-svelte";
import "@carbon/charts-svelte/styles.css";
import type { PageData } from "./$types";
import { get_by_value } from "$lib/database";
import { make_chart_options } from "$lib/chart";
let { data }: { data: PageData } = $props();
// Await promises
let drivers: Driver[] | undefined = $state(undefined);
data.drivers.then((d: Driver[]) => (drivers = d));
let scraped_raceresultsacc: ScrapedRaceResultAcc[] | undefined = $state(undefined);
data.scraped_raceresultsacc.then((s: ScrapedRaceResultAcc[]) => (scraped_raceresultsacc = s));
const drivers_columns: TableColumn[] = $derived([
{
data_value_name: "position",
label: "Position",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
{
data_value_name: "driver_code",
label: "Driver",
},
{
data_value_name: "points",
label: "Points",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
]);
const teams_columns: TableColumn[] = $derived([
{
data_value_name: "position",
label: "Position",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
{
data_value_name: "team_fullname",
label: "team",
},
{
data_value_name: "points",
label: "Points",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
]);
const drivers_chart_data: ChartTabularData = $derived.by(() => {
if (!drivers || !scraped_raceresultsacc) return [];
const active_drivers: Driver[] = drivers.filter((driver: Driver) => driver.active);
const active_results: ScrapedRaceResultAcc[] = scraped_raceresultsacc.filter(
(result: ScrapedRaceResultAcc) =>
get_by_value(drivers ?? [], "code", result.driver_code)?.active,
);
return active_drivers
.map((driver: Driver) => {
return {
group: driver.code,
step: "0",
points: 0,
};
})
.concat(
active_results.map((result: ScrapedRaceResultAcc) => {
return {
group: result.driver_code,
step: result.race_step.toString(),
points: result.acc_points,
};
}),
);
});
const drivers_chart_options: LineChartOptions = make_chart_options("Drivers", "step", "points");
</script>
<svelte:head>
<title>Formula 11 - Statistics</title>
</svelte:head>
<div class="card w-full bg-surface-100 p-2 shadow">
<LineChart data={drivers_chart_data} options={drivers_chart_options} />
</div>
<div class="mt-2 grid w-full grid-cols-1 gap-2 lg:grid-cols-2">
<div class="w-full">
{#await data.scraped_driverstandings then driverstandings}
<Table data={driverstandings} columns={drivers_columns} />
{/await}
</div>
<div class="w-full">
{#await data.scraped_teamstandings then teamstandings}
<Table data={teamstandings} columns={teams_columns} />
{/await}
</div>
</div>

View File

@ -0,0 +1,23 @@
import {
fetch_drivers,
fetch_scraped_driverstandings,
fetch_scraped_raceresults,
fetch_scraped_raceresultsacc,
fetch_scraped_startinggrids,
fetch_scraped_teamstandings,
} from "$lib/fetch";
import type { PageLoad } from "../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:drivers", "data:official");
return {
drivers: fetch_drivers(fetch),
scraped_driverstandings: fetch_scraped_driverstandings(fetch),
scraped_teamstandings: fetch_scraped_teamstandings(fetch),
scraped_startinggrids: fetch_scraped_startinggrids(fetch),
scraped_raceresults: fetch_scraped_raceresults(fetch),
scraped_raceresultsacc: fetch_scraped_raceresultsacc(fetch),
};
};

View File

@ -3,6 +3,9 @@ import { defineConfig } from "vite";
export default defineConfig({
plugins: [sveltekit()],
ssr: {
noExternal: process.env.NODE_ENV === "production" ? ["@carbon/charts"] : [],
},
build: {
rollupOptions: {
external: ["sharp"],