Compare commits

..

498 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
df993f61bc Racepicks: Set a min width for the guess table columns
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 47s
2025-02-04 09:14:26 +01:00
d33c663c06 Racepicks: Increase race result popup z-index so it doesn't get hidden by navbar 2025-02-04 09:12:23 +01:00
2c0ad9ee21 Rules: Add basic text boxes
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 27s
2025-02-03 23:51:32 +01:00
d0870eb06e Racepicks: Add comment 2025-02-03 23:40:26 +01:00
efc33d6f20 Lib: Fix TeamCard colorpreview 2025-02-03 23:40:18 +01:00
346fdb3b75 Skeleton: Fetch static data (teams/drivers/races/substitutions) in global layout asynchronously
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 29s
2025-02-03 22:34:07 +01:00
3bb2e318b1 Lib: Specify missing HTMLButtonAttributes explicitly 2025-02-03 22:33:22 +01:00
4cdc7713c8 Lib: Force length 7 for color input in TeamCard 2025-02-03 22:32:59 +01:00
8db2b634d3 Lib: Add helpers to obtain certain template graphics 2025-02-03 21:31:21 +01:00
0dc5e63e25 Lib: Use <a> for button component's href mode (fixes preloading) 2025-02-03 21:31:04 +01:00
6dd3f24d32 Lib: Move dropdown options creation into library 2025-02-03 19:39:33 +01:00
49c08eeead Data/Raceresults: Implement entering race results
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 50s
2025-02-03 09:32:53 +01:00
2efd12a28d Lib: Implement RaceResultCard 2025-02-03 09:32:34 +01:00
186e6430e0 Lib: Fix bug in form_data_remove (keys where deleted while iterating) 2025-02-03 09:25:53 +01:00
46124cd9ec Lib: Add comment 2025-02-03 09:06:46 +01:00
09054d2134 Lib: Implement helper to remove formData fields 2025-02-03 09:06:34 +01:00
eb7e3c67a5 Env: Update skeleton + svelte 2025-02-03 09:06:12 +01:00
7bbd38d807 Racepicks: Update guess table styling 2025-02-03 09:05:44 +01:00
c299cd626e Workflow: Update formula11 dockerfile
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 32s
2025-02-02 23:03:05 +01:00
00d3330262 Env: Move sharp back to dependencies (shouldn't get bundled as devDependency) 2025-02-02 23:02:55 +01:00
999081f6d2 Workflow: Update formula11 dockerfile
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 27s
2025-02-02 22:45:03 +01:00
7cb8402b39 Workflow: Update formula11 dockerfile
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 33s
2025-02-02 22:43:03 +01:00
16b9b1343b Env: Remove empty dependencies from package.json 2025-02-02 22:42:54 +01:00
743ca7ad2d Env: Update check command 2025-02-02 22:42:42 +01:00
2a87228654 Hooks: Update pocketbase URL
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 25s
2025-02-02 22:28:00 +01:00
d54d75576d Workflow: Fix formula11 workflow steps
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 43s
2025-02-02 21:36:13 +01:00
44cd03eec4 Workflow: Add formula11 docker workflow
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 9s
2025-02-02 21:34:09 +01:00
53c7f6c669 Env: Move all node dependencies into devDependencies 2025-02-02 21:27:39 +01:00
6ef94372fd Workflow: Lock alpine version for pocketbase image
All checks were successful
Build Pocketbase Docker Image / pocketbase-docker (push) Successful in 21s
2025-02-02 20:56:40 +01:00
463a784c74 Workflow: Remove PB_VERSION variable
Some checks failed
Build Pocketbase Docker Image / pocketbase-docker (push) Failing after 9s
2025-02-02 20:53:29 +01:00
defd20e8f9 Workflow: Rewrite build command
All checks were successful
Build Pocketbase Docker Image / pocketbase-docker (push) Successful in 6s
2025-02-02 20:52:05 +01:00
a2aced39b3 Workflow: Fix multiline run
All checks were successful
Build Pocketbase Docker Image / pocketbase-docker (push) Successful in 7s
2025-02-02 20:49:48 +01:00
964ba6bdce Workflow: Fix pocketbase workflow branch name 2025-02-02 20:49:35 +01:00
02879f0787 Workflow: Update pocketbase workflow steps 2025-02-02 20:49:35 +01:00
8d0ba8bfe4 Workflow: Add pocketbase docker workflow 2025-02-02 20:49:35 +01:00
11935f26f0 Hooks: Use PB host/port from env variables if defined 2025-02-02 20:08:26 +01:00
ca8fb7dc25 Data/Raceresults: Add missing create result handler to button 2025-01-31 23:23:23 +01:00
2add774fef Racepicks: Center result popup driver codes 2025-01-31 23:22:57 +01:00
4c8bfc1133 Lib: Fix typo in DriverCard 2025-01-31 23:18:57 +01:00
45929e5775 Data/Raceresults: Use guess colorings pxx/dnf badges 2025-01-31 23:14:20 +01:00
bc6d9e7612 Lib: Move pxx guess colorings into config.ts 2025-01-31 23:13:44 +01:00
43b0fd9aa2 Data/Raceresults: Implement initial raceresult display table 2025-01-31 22:56:06 +01:00
25e41f7ae4 Lib: Fix disabled_inputs propagation to create team/driver/race/substitution buttons 2025-01-31 22:19:11 +01:00
14001be46c Racepicks: Update table styling 2025-01-31 22:16:27 +01:00
a3bdd8cbbd Racepicks: Center countdown horizontally 2025-01-31 01:00:05 +01:00
c6f2213d5c Racepicks: Update accordion width + breakpoints 2025-01-31 00:59:00 +01:00
30d4693846 Racepicks: Center driver codes in guess table 2025-01-31 00:56:57 +01:00
5c75728163 Lib: Make RacePickCard labelwidth smaller 2025-01-31 00:56:47 +01:00
01d58e3d1e App: Use /racepicks as default page (reroute / + change Formula11 button destination) 2025-01-31 00:49:26 +01:00
0985936c64 Lib: Hide seconds in countdown component 2025-01-31 00:40:53 +01:00
c222b2a63f Racepicks: Add pxx/dnf separator in result popup + format racedate + display race pxx 2025-01-31 00:39:04 +01:00
4330bed94d Racepicks: Highlight logged in users guess column in red 2025-01-31 00:31:52 +01:00
97013e335e Racepicks: Display relevant race results when clicking on race 2025-01-31 00:31:40 +01:00
4425b8eb6b Racepicks: Add points color legend 2025-01-31 00:02:13 +01:00
e5b43ad4f2 Racepicks: Color past picks based on raceresults 2025-01-30 23:35:11 +01:00
53c4462599 Racepicks: Improve overview race step styling + add countdown 2025-01-30 22:36:49 +01:00
f4b89ec839 Lib: Implement countdown component 2025-01-30 22:33:52 +01:00
b4931611b3 Lib: Add stopwatch svg icon component 2025-01-30 22:33:44 +01:00
261b761069 Racepicks: Make margins on small screens more consistent 2025-01-30 21:17:03 +01:00
2dc170561d Users: Display driver headshot template if user doesn't have avatar 2025-01-30 21:08:59 +01:00
6ebe30e949 Racepicks: Fix races + guesses vertical alignment on smaller screens 2025-01-29 00:42:28 +01:00
abb4452d9c Racepicks: Justify overview cards 2025-01-29 00:32:18 +01:00
5f7a693ec1 Racepicks: Overview styling update + initial guess table implementation 2025-01-29 00:30:33 +01:00
7061aa4976 Racepicks: Sort fetched raceresults 2025-01-29 00:30:18 +01:00
5bf47b9523 Lib: Correct schema (removed some "expand" fields) 2025-01-29 00:29:51 +01:00
9925a9b3c4 Lib: Add optional image zoom on mouse hover 2025-01-29 00:29:20 +01:00
3d8597ae7a Data/Season: Close overview accordion initially 2025-01-28 21:01:14 +01:00
de0a918d27 Data/Season: Display avatars of users that did and didn't pick the current race 2025-01-28 20:53:00 +01:00
86f973d6e0 Data/Season: Fetch users that picked the current race 2025-01-28 20:39:59 +01:00
6149d78d27 Data/Season: Add comment 2025-01-27 23:59:25 +01:00
30328ff4bd Lib: Add comment 2025-01-27 23:59:17 +01:00
c35ea78a50 Racepicks: Fix overview box paddings 2025-01-27 23:51:52 +01:00
d2a08ec59c Racepicks: Implement "Make Pick" button 2025-01-27 23:48:46 +01:00
d300f79baa Racepicks: Implement create/update/delete routes 2025-01-27 23:48:30 +01:00
9d38a03e57 Lib: Add CurrentPickedUser to schema 2025-01-27 23:47:43 +01:00
31be7bceae Skeleton: Register RacePickCard as component modal 2025-01-27 23:47:36 +01:00
42c6390d6e Lib: Allow adding extra classes to LazyImage component 2025-01-27 23:47:20 +01:00
aee0da174d Lib: Add RacePickCard component 2025-01-27 23:47:10 +01:00
9ad670ab5e App: Disable user-select 2025-01-27 20:30:33 +01:00
b95a5109e0 Lib: Disable draggable images 2025-01-27 20:30:27 +01:00
9f7361bd4e Racepicks: Fetch graphics 2025-01-27 20:16:43 +01:00
4d9364f945 Data/Season: Add comment 2025-01-27 20:16:27 +01:00
2aa176ed5f Lib: Add ChequeredFlagIcon component 2025-01-27 20:16:17 +01:00
ec75f97ee5 App: Add shadows to buttons (except for the header) 2025-01-27 19:44:03 +01:00
986a49377b Lib: Hide table header if no data is passed 2025-01-27 19:43:45 +01:00
a34cf50ace Env: Disable python dependency in flake 2025-01-27 19:38:28 +01:00
5a506768ab Lib: Add extraclass field to Button component 2025-01-27 19:38:17 +01:00
36c2789304 All: Replace <b> with <span class="font-bold"> 2025-01-27 19:38:08 +01:00
8dc9675d84 Data/Season: Add comment 2025-01-26 15:21:48 +01:00
cb8df4e05e Racepicks: Shitty styling progress on overview boxes 2025-01-26 15:21:22 +01:00
1158efd5f1 Lib: Add shadow option to Button component 2025-01-26 15:18:47 +01:00
46343bf208 Racepicks: Add initial unstyled elements ("Guess" button + Next Race overview) 2025-01-26 14:13:46 +01:00
7bd8c6613b Racepicks: Fetch races and remove expanding of races/drivers (already fetched separately) 2025-01-26 14:13:10 +01:00
341c831670 Racepicks: Fetch racepicks, raceresults, drivers and the current race 2025-01-26 13:55:21 +01:00
70e55feac9 Data/Season: Add badges to substitution step column 2025-01-26 13:53:57 +01:00
9ef669c11a Lib: Add RaceResult to schema 2025-01-26 13:53:34 +01:00
2a124b7201 Data/Season: Sort substitutions table by race step 2025-01-26 13:02:49 +01:00
1d597a431e Data/Season: Add race step to substitutions page 2025-01-26 13:02:39 +01:00
73df15a188 Lib: Add possible "expand" fields to schema 2025-01-26 13:02:26 +01:00
d78f58976f Racepicks: Load racepicks from PB 2025-01-25 19:12:20 +01:00
d2b6bd88ca Lib: Update RacePick schema (expanded values) 2025-01-25 19:11:55 +01:00
e8f1bfca0a Env: Update flake + node packages 2025-01-25 18:54:11 +01:00
8fec70384d Lib: Make table non-interactive (use pointer cursor instead) 2025-01-25 18:35:27 +01:00
c39a10abe5 Lib: Add RacePick schema 2025-01-25 18:29:56 +01:00
6bb065ba6c Data/Users: Rename layout.server.ts to page.server.ts 2025-01-25 18:29:43 +01:00
d007cac4c0 Lib: Make table rows colors alternating 2025-01-25 18:27:01 +01:00
766f09c5b5 Data/Season: Update create teams/drivers/races/substitutions button colors 2025-01-25 18:26:47 +01:00
7ec8411011 Data/Users: Sort users by username 2025-01-25 17:16:23 +01:00
7659e03324 Data/Users: Implement users overview table 2025-01-25 17:15:22 +01:00
3e36bdf27a Data/Users: Rename path 2025-01-25 16:44:12 +01:00
045577ce24 Data/RaceResults: Rename path 2025-01-25 16:44:06 +01:00
b207aa5e29 Profile: Add firstname field to users 2025-01-25 16:43:36 +01:00
a552865b2f Lib: Add svg NameIcon 2025-01-25 16:28:09 +01:00
999cf5bf16 Lib: Remove LazyCard component 2025-01-25 16:27:56 +01:00
aad969fc46 Env: Add svelte-check command to flake 2025-01-25 16:27:45 +01:00
a3805f76a1 Data/Season: Split Teams/Drivers/Races/Substitutions into individual routes 2024-12-27 21:52:03 +01:00
e733ed568a Skeleton: Center navbar buttons 2024-12-27 21:51:41 +01:00
2e814c99be Data/Raceresult: Add dummy page 2024-12-27 21:51:13 +01:00
ea54ea58f1 Lib: Make button pathname matching more relaxed 2024-12-27 21:51:03 +01:00
545488da8a Data/Season: Reorder typescript section 2024-12-27 19:58:05 +01:00
65f60ec6b1 Lib: Remove LazyDropdown
It was identical to "Dropdown" after removing the non-lazy "Image"
component
2024-12-27 19:44:05 +01:00
bcd8f3dfb5 Lib: Close Team/Driver/Race/Substitution cards when clicking on the image 2024-12-27 19:43:36 +01:00
00a577520c Lib: Allow to pass an onclick handler for the card image 2024-12-27 19:43:16 +01:00
c286cea7b5 Data/Season: Add remaining "Create" buttons (drivers/races/substitutions) 2024-12-27 19:36:08 +01:00
80d2fe2297 Lib: Load modal meta data required for "create" driver/race/team/substitution cards 2024-12-27 19:35:47 +01:00
56b1606d1a Data/Season: Fix team dropdown icon dimensions 2024-12-27 19:16:14 +01:00
2d2fd9f622 Lib: Remove non-lazy image
We need the width/height anyways to determine the max size, so don't
bother with non-lazy stuff
2024-12-27 19:16:02 +01:00
e4a4a15367 Data/Season: Add create team button 2024-12-23 17:32:45 +01:00
77f373d593 Data/Season: Set disable_inputs for component modals 2024-12-23 16:48:03 +01:00
29eb59a983 Lib: Read disable_inputs from modalStore meta for driver-/race-/substitution-/team-card 2024-12-23 16:47:46 +01:00
cd3f8f7f71 Data/Season: Add value formatting functions for substitutions page 2024-12-23 16:15:27 +01:00
3e0f17faf0 Lib: Allow table value formatting function to return promise 2024-12-23 16:15:27 +01:00
835146e38a Data/Season: Reorder races columns 2024-12-23 16:15:27 +01:00
a4c2461471 Lib: Make race-/substitution-card buttons fill the card width 2024-12-23 16:15:27 +01:00
4cffa0adc9 Data/Season: Replace card grid for teams/drivers/races/substitutions with tables
This is loading fast and non-lazy, on row-click the card opens
2024-12-23 16:15:27 +01:00
bb108bf6f3 Lib: Remove card aspect ratios
Lazy cards weren't implemented robustly, so use non-lazy cards for now
(maybe find a better solution sometimes)
2024-12-23 16:15:27 +01:00
392e61ca49 Lib: Make driver-/race-/team- and substitution-card non-lazy 2024-12-23 16:15:27 +01:00
04650f624e Lib: Add on-click handler prop to table component 2024-12-23 16:15:27 +01:00
b6a645da6c Lib: Update lazydropdown props name to differentiate from non-lazy dropdown 2024-12-23 16:15:27 +01:00
471163de2f Lib: Update lazycard props name to differentiate from non-lazy card 2024-12-23 16:15:27 +01:00
31d8bb4908 Skeleton: Register component modals for driver-/team-/race- and substitution-card 2024-12-23 16:15:27 +01:00
a23f12b69f Lib: Add non-lazy variants for card, image and dropdown 2024-12-23 16:15:27 +01:00
a1fd50a3f6 Skeleton: Reformat 2024-12-23 16:15:27 +01:00
1a95317a74 Lib: Add table component 2024-12-23 16:15:27 +01:00
05e32b5ffb Lib: Move form stuff into form/ directory 2024-12-23 16:15:27 +01:00
68d9d7e60d Lib: Move teamcard into cards/ + implement new team schema 2024-12-23 16:15:27 +01:00
b3629fbe95 Lib: Move cards into cards/ directory 2024-12-23 16:15:27 +01:00
49112280de Lib: Update team database schema (add logo, color) 2024-12-23 16:15:27 +01:00
c954b0f3b0 App: Add onLazyVisible type definition 2024-12-23 16:15:27 +01:00
b1bea37e20 Skeleton: Fix z-indices so the loading indicator is visible 2024-12-23 16:15:27 +01:00
eedc7f9a85 Lib: Comments 2024-12-23 16:15:27 +01:00
b21e4b9b6e Lib: Fix LazyImage in Dropdown component 2024-12-23 16:15:27 +01:00
bcb5661e06 Lib: Remove debug log 2024-12-23 16:15:27 +01:00
09a53a15a7 Skeleton: Fix data dropdown padding 2024-12-23 16:15:27 +01:00
f0c568b982 Lib: Remove previous lazy loading approach and replace with static aspect ratios
The element size must be valid before it is loaded, this is a problem
for cards, as they adapt to their content's size.
Previously I tried to load the first card non-lazily and measure its
dimensions for the next cards, but that was not stable on viewport
changes (could have measured the aspect ratio instead...).
Now, all the aspect ratios are just measured and defined manually,
stupid but simple.
2024-12-23 16:15:27 +01:00
d398ab67e0 Lib: Implement (slightly broken) lazy loading of cards
Issues arise when the viewport size changes
2024-12-23 16:15:27 +01:00
df0402a318 Lib: Add comment to lazyload.ts 2024-12-23 16:15:27 +01:00
3cffab193b Skeleton: Update drawer switch timeout 2024-12-23 16:15:27 +01:00
8bf96e3380 Lib: Update DriverCard to reflect lib changes (lazy loading) 2024-12-23 16:15:27 +01:00
55e849d908 Data/Season: Update tab bar styling 2024-12-23 16:15:27 +01:00
a02a1e7843 Lib: Make lazyimage fade in the image once loaded 2024-12-23 16:15:27 +01:00
f086e360d4 Skeleton: Position the drawer below the navbar + allow toggling between them 2024-12-23 16:15:27 +01:00
bac2d31d73 Data/Season: Reflect lib updates (lazy stuff) 2024-12-23 16:15:27 +01:00
68a56ece32 Lib: Fix bug in image to base64 conversion (now works client+serverside) 2024-12-23 16:15:27 +01:00
4d41401905 Lib: Update lazy components (dropdown + card now lazy) 2024-12-23 16:15:27 +01:00
ecd566b1fa Lib: Move image fetching out of LazyImage component into lib 2024-12-23 16:15:27 +01:00
83d322f26b Lib: Add function to fetch image as base64 string 2024-12-23 16:15:27 +01:00
3bd960b187 Skeleton: Enable autocomplete on username inputs 2024-12-23 16:15:27 +01:00
27fc2bce49 Lib: Disable autocomplete on card inputs 2024-12-23 16:15:27 +01:00
57b4253d99 Data/Season: Stream drivers, races and substitutes as promises for data/season page 2024-12-23 16:15:27 +01:00
cb51e01e98 Hooks: Log requests 2024-12-23 16:15:27 +01:00
3904201033 Env: Add sveltekit node server adapter 2024-12-23 16:15:27 +01:00
5bfb670347 Lib: Make LazyImage full width 2024-12-23 16:15:27 +01:00
cb5203fbf8 Data/Season: Update imports 2024-12-23 16:15:27 +01:00
7bebc334f3 Data/Season: Implement image compression + downsizing for team/driver/race routes 2024-12-23 16:15:27 +01:00
c1f9c6aa12 Lib: Dispatch CustomEvent instead of Event for DropdownChange 2024-12-23 16:15:27 +01:00
72154656fb Lib: Add imgwidth/imgheight to Card component so layout doesn't jump when lazyloading images 2024-12-23 16:15:27 +01:00
0d25f23818 Lib: Update index.ts 2024-12-23 16:15:27 +01:00
14516133de Lib: Implement LazyImage component (images will be loaded once visible) 2024-12-23 16:15:27 +01:00
57cae4d400 Lib: Define some constant values in lib/config.ts 2024-12-23 16:15:27 +01:00
b7ca0582f4 Skeleton: Add site loading indicator to the main layout 2024-12-23 16:15:27 +01:00
66183d0600 Lib: Implement site loading indicator 2024-12-23 16:15:27 +01:00
1b6082ddfa Profile: Compress user avatars in update_profile route 2024-12-23 16:15:27 +01:00
ef96cd1e2c Lib: Implement image downscaling + avif conversion helper 2024-12-23 16:15:27 +01:00
926e9fac44 Skeleton: Remove unused imports 2024-12-23 16:15:27 +01:00
325563394b Env: Don't bundle sharp (sharp needs node, so serverside only) 2024-12-23 16:15:27 +01:00
c77a1dd6d5 Skeleton: Add race results link to layout 2024-12-23 16:15:27 +01:00
b81f17c230 Data/Season: Add driver/team icons to driver/team dropdowns 2024-12-23 16:15:27 +01:00
35c6f59d4b Data/Season: Fix bug in create_driver action (don't ensure "active") 2024-12-23 16:15:27 +01:00
6d93ca7dab Lib: Allow icons in dropdown component list 2024-12-23 16:15:27 +01:00
2989e8a2ed Data/Season: Add "active" switches on drivers page 2024-12-23 16:15:27 +01:00
4003cb5645 Lib: Fix wrong label in substitution card 2024-12-23 16:15:27 +01:00
655a9377fe Data/Season: Implement substitutions page 2024-12-23 16:15:27 +01:00
dbd423dfd3 Lib: Implement substitution card 2024-12-23 16:15:27 +01:00
69e9ca1400 Lib: Rename field in schema 2024-12-23 16:15:27 +01:00
0ed6e24852 Lib: Remove unused event from clear_spring event handler in racecard component 2024-12-23 16:15:27 +01:00
0baaf51e53 Lib: Add action field to dropdown component 2024-12-23 16:15:27 +01:00
7648886ca4 Lib: Only pass single "team_select_value" into component except of all of them 2024-12-23 16:15:27 +01:00
72abc891e5 Data/Season: Implement races page 2024-12-23 16:15:27 +01:00
ef6554f2c5 Lib: Allow key exceptions in form_data_clean + implement date format conversion for pocketbase 2024-12-23 16:15:27 +01:00
f11b516088 Lib: Implement racecard component 2024-12-23 16:15:27 +01:00
2ae436dbe5 Env: Add date-fns library 2024-12-23 16:15:27 +01:00
3b6c45e86e Lib: Fix bug in form_data_clean (mutating while iterating) 2024-12-23 16:15:27 +01:00
2e0539a9b8 Lib: Add schema definitions for race and substitution 2024-12-23 16:15:27 +01:00
88f3014238 Lib: Fix readonly + required in dropdown component by preventing keypress events 2024-12-23 16:15:27 +01:00
5cbbb89ee1 Data/Season: Use team/driver card components 2024-12-23 16:15:27 +01:00
52a8d8f4ff Lib: Implement team and driver cards 2024-12-23 16:15:27 +01:00
dcd1444ba7 Profile: Redirect to current page instead of "/" when logging in/out or changing/creating profile 2024-12-23 16:15:27 +01:00
218e105815 Hooks: Load template avatar url if user didn't set one 2024-12-23 16:15:27 +01:00
43638f5f40 Env: Add sharp for image conversion to avif 2024-12-23 16:15:27 +01:00
ae8310aeba App: Change Locals interface user type to User schema 2024-12-23 16:15:27 +01:00
758ee8de27 Data/Season: Display template graphics (for new driver/team etc.) 2024-12-23 16:15:27 +01:00
6cc5a1a0fa Data/Season: Load template graphics 2024-12-23 16:15:27 +01:00
95a2d086ee Lib: Replace get_by_id helper with more general get_by_value (key can be chosen) 2024-12-23 16:15:27 +01:00
16a315c1a5 Lib: Add Graphic and User schemas 2024-12-23 16:15:27 +01:00
66d2d9b878 Lib: Disable text input in Dropdown component 2024-12-23 16:15:27 +01:00
b08b73b337 Data/Season: Implement create_driver, update_driver and delete_driver routes 2024-12-23 16:15:27 +01:00
402f067877 Data/Season: Add team select to seasondata drivers page 2024-12-23 16:15:27 +01:00
9f45469f01 Env: Add UUID package 2024-12-23 16:15:27 +01:00
2c5a7a1143 Lib: Update type information 2024-12-23 16:15:27 +01:00
304ffd45ba Lib: Implement dropdown + search/autocomplete components 2024-12-23 16:15:27 +01:00
0abfaff004 App: Add TS type information 2024-12-23 16:15:27 +01:00
04569ea683 Skeleton: Manually ":hover" the button to the current page in navbar 2024-12-23 16:15:27 +01:00
96fc1ed2aa Profile: Move /user routes to /profile 2024-12-23 16:15:27 +01:00
7a8abbe0a9 Lib: Disable input label text wrapping 2024-12-23 16:15:27 +01:00
5d1ec12d95 Lib: Allow to manually enable :hover on Button 2024-12-23 16:15:26 +01:00
8060c4971d Migrate from DaisyUI to SkeletonUI 2024-12-23 16:15:26 +01:00
2f934537a2 Env: Update tailwindcss safelist 2024-12-23 16:15:26 +01:00
ff553c99ec Env: Replace daisyui with skeletonui 2024-12-23 16:15:26 +01:00
97872cc9b9 Env: Update prettier config 2024-12-23 16:15:26 +01:00
4ce40e7145 Skeleton: Disable "draggable" on links and images 2024-12-23 16:15:26 +01:00
b69647cd48 Skeleton: Move seasondata tabs into +layout.svelte 2024-12-23 16:15:26 +01:00
51651b976a Data/Season: Add stub page for drivers/races routes 2024-12-23 16:15:26 +01:00
7623a426c9 Lib: Rename forms.ts to form.ts 2024-12-23 16:15:26 +01:00
f1830d1b1c Skeleton: Make "Login" button the default on enter (instead of "Register") 2024-12-23 16:15:26 +01:00
55bdf0fa18 Data/Season: Implement seasondata/teams page + creation/deletion/updating 2024-12-23 16:15:26 +01:00
1410811167 User: Add login/register/profile form handling 2024-12-23 16:15:26 +01:00
27620fc70e Add stub page for / route 2024-12-23 16:15:26 +01:00
eb8aa01109 Skeleton: Add main page skeleton (navbar) 2024-12-23 16:15:26 +01:00
dd1e6ee6c1 Hooks: Add request event handler for authentication 2024-12-23 16:15:26 +01:00
a7b2bfb56b Static: Add favicon + logo 2024-12-23 16:15:26 +01:00
b3c10147df Env: Update devshell commands 2024-12-23 16:15:26 +01:00
f6b1f1ab58 Env: Update tailwind config + some other plugins 2024-12-23 16:15:26 +01:00
b561225a9b Add stub pages for some routes 2024-12-23 16:15:26 +01:00
36cd9fbe8a Lib: Add image preview helper 2024-12-23 16:15:26 +01:00
4089b58df1 Lib: Add form helpers 2024-12-23 16:15:26 +01:00
278024b91b Lib: Add index.ts for easier importing 2024-12-23 16:15:26 +01:00
487ee2c534 Lib: Add username input 2024-12-23 16:15:26 +01:00
e268f4a0d3 Lib: Add password input 2024-12-23 16:15:26 +01:00
f46a92d00b Lib: Add text input 2024-12-23 16:15:26 +01:00
b2cd5de7b2 Lib: Add file input 2024-12-23 16:15:26 +01:00
0e57957610 Lib: Add button 2024-12-23 16:15:26 +01:00
100 changed files with 11784 additions and 3408 deletions

20
.dockerignore Normal file
View File

@ -0,0 +1,20 @@
.direnv
.git
.gitea
.idea
.svelte-kit
build
node_modules
pb_data
pb_migrations
.dockerignore
.envrc
.gitignore
README.md
flake.lock
flake.nix
formula11.dockerfile
pocketbase.dockerfile
prettier.config.js

2
.envrc
View File

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

View File

@ -0,0 +1,25 @@
name: Build Formula11 Docker Image
on:
push:
branches: [master]
# paths:
# - ".gitea/workflows/pocketbase-docker.yaml"
# - "pocketbase.dockerfile"
jobs:
pocketbase-docker:
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Login to container registry
uses: docker/login-action@v3
with:
registry: gitea.vps.chriphost.de
username: ${{ secrets.CONTAINER_REGISTRY_USER }}
password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
- name: Build Formula11 Docker Image
run: docker build --file formula11.dockerfile --tag gitea.vps.chriphost.de/christoph/formula11:latest .
- name: Push Formula11 Docker Image
run: docker push gitea.vps.chriphost.de/christoph/formula11:latest

View File

@ -0,0 +1,25 @@
name: Build Pocketbase Docker Image
on:
push:
branches: [master]
paths:
- ".gitea/workflows/pocketbase-docker.yaml"
- "pocketbase.dockerfile"
jobs:
pocketbase-docker:
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Login to container registry
uses: docker/login-action@v3
with:
registry: gitea.vps.chriphost.de
username: ${{ secrets.CONTAINER_REGISTRY_USER }}
password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
- name: Build Pocketbase Docker Image
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.33.0

128
flake.lock generated
View File

@ -1,15 +1,40 @@
{ {
"nodes": { "nodes": {
"devshell": { "clj-nix": {
"inputs": { "inputs": {
"nixpkgs": "nixpkgs" "devshell": "devshell",
"nix-fetcher-data": "nix-fetcher-data",
"nixpkgs": [
"nixpkgs"
]
}, },
"locked": { "locked": {
"lastModified": 1728330715, "lastModified": 1763549559,
"narHash": "sha256-xRJ2nPOXb//u1jaBnDP56M7v5ldavjbtR6lfGqSvcKg=", "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", "owner": "numtide",
"repo": "devshell", "repo": "devshell",
"rev": "dd6b80932022cea34a019e2bb32f6fa9e494dfef", "rev": "7c9e793ebe66bcba8292989a68c0419b737a22a0",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -18,6 +43,24 @@
"type": "github" "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": { "flake-utils": {
"inputs": { "inputs": {
"systems": "systems" "systems": "systems"
@ -36,43 +79,80 @@
"type": "github" "type": "github"
} }
}, },
"nixpkgs": { "nix-fetcher-data": {
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": [
"clj-nix",
"nixpkgs"
]
},
"locked": { "locked": {
"lastModified": 1722073938, "lastModified": 1728229178,
"narHash": "sha256-OpX0StkL8vpXyWOGUD6G+MA26wAXK6SpT94kLJXo6B4=", "narHash": "sha256-p5Fx880uBYstIsbaDYN7sECJT11oHxZQKtHgMAVblWA=",
"owner": "NixOS", "owner": "jlesquembre",
"repo": "nixpkgs", "repo": "nix-fetcher-data",
"rev": "e36e9f57337d0ff0cf77aceb58af4c805472bfae", "rev": "f3a73c34d28db49ef90fd7872a142bfe93120e55",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "NixOS", "owner": "jlesquembre",
"ref": "nixpkgs-unstable", "repo": "nix-fetcher-data",
"repo": "nixpkgs",
"type": "github" "type": "github"
} }
}, },
"nixpkgs_2": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1733376361, "lastModified": 1763618868,
"narHash": "sha256-aLJxoTDDSqB+/3orsulE6/qdlX6MzDLIITLZqdgMpqo=", "narHash": "sha256-v5afmLjn/uyD9EQuPBn7nZuaZVV9r+JerayK/4wvdWA=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "929116e316068c7318c54eb4d827f7d9756d5e9c", "rev": "a8d610af3f1a5fb71e23e08434d8d61a466fc942",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "NixOS", "id": "nixpkgs",
"ref": "nixpkgs-unstable", "type": "indirect"
"repo": "nixpkgs", }
"type": "github" },
"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": { "root": {
"inputs": { "inputs": {
"devshell": "devshell", "clj-nix": "clj-nix",
"flake-utils": "flake-utils", "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": { "systems": {

414
flake.nix
View File

@ -1,145 +1,329 @@
{ rec {
description = "Svelte F1 Guessgame"; description = "Formula11";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; inputs = {
inputs.flake-utils.url = "github:numtide/flake-utils"; nixpkgs.url = "nixpkgs"; # Use nixpkgs from system registry
inputs.devshell.url = "github:numtide/devshell"; 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 = { outputs = {
self, self,
nixpkgs, nixpkgs,
flake-utils, 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 flake-utils.lib.eachDefaultSystem (system: let
pkgs = import nixpkgs { pkgs = import nixpkgs {
inherit system; inherit system;
config.allowUnfree = true; config.allowUnfree = true;
overlays = [devshell.overlays.default]; overlays = [
}; rust-overlay.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
]; ];
}; };
inherit (pkgs) lib stdenv;
fastf1 = pkgs.python312Packages.buildPythonPackage rec { # ===========================================================================================
pname = "fastf1"; # Define custom dependencies
version = "3.4.4"; # ===========================================================================================
src = pkgs.python312Packages.fetchPypi { # Python package example
inherit pname version; # typed-ffmpeg = pkgs.python313Packages.buildPythonPackage rec {
hash = "sha256-nELQtvzlLsUYyVaPe1KqvMmzHy5l5W7u1I6m8r8md/4="; # 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; # python = pkgs.python313.withPackages (p:
pyproject = true; # with p; [
# # numpy
# # matplotlib
# # typed-ffmpeg
# # pyside6
# ]);
# Build time deps # rust = pkgs.rust-bin.stable.latest.default.override {
nativeBuildInputs = with pkgs.python312Packages; [ # extensions = ["rust-src"]; # Include the Rust stdlib source (for IntelliJ)
hatchling # };
hatch-vcs
];
# Run time deps # 64 bit C/C++ compilers that don't collide (use the same libc)
dependencies = with pkgs.python312Packages; [ # bintools = pkgs.wrapBintoolsWith {
matplotlib # bintools = pkgs.bintools.bintools; # Unwrapped bintools
numpy # libc = pkgs.glibc;
pandas # };
python-dateutil # gcc = pkgs.hiPrio (pkgs.wrapCCWith {
requests # cc = pkgs.gcc.cc; # Unwrapped gcc
requests-cache # libc = pkgs.glibc;
scipy # bintools = bintools;
rapidfuzz # });
websockets # clang = pkgs.wrapCCWith {
timple # cc = pkgs.clang.cc; # Unwrapped clang
]; # libc = pkgs.glibc;
}; # bintools = bintools;
# };
f1python = pkgs.python312.withPackages (p: # Multilib C/C++ compilers that don't collide (use the same libc)
with p; [ # bintools_multilib = pkgs.wrapBintoolsWith {
# Basic # bintools = pkgs.bintools.bintools; # Unwrapped bintools
rich # 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 # Specify dependencies
flask-sqlalchemy # https://nixos.org/manual/nixpkgs/stable/#ssec-stdenv-dependencies-overview
flask-caching # Just for a "nix develop" shell, buildInputs can be used for everything.
sqlalchemy # ===========================================================================================
# Test # Add dependencies to nativeBuildInputs if they are executed during the build:
pytest # - 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??? sqlite # For sqlite console
matplotlib sqlitebrowser # To check low-level pocketbase data
numpy
pandas
python-dateutil
requests
requests-cache
scipy
rapidfuzz
websockets
timple
fastf1 # Languages:
]); # python
in { # rust
devShell = pkgs.devshell.mkShell { # bintools
name = "Formula11"; # gcc
# clang
# bintools_multilib
# gcc_multilib
# clang_multilib
# clojure
# jdk
packages = with pkgs; [ # C/C++:
f1python # gdb
pocketbase # valgrind
# gnumake
# cmake
# pkg-config
nodejs_23 # Clojure:
# nodePackages.autoprefixer # leiningen
# nodePackages.postcss # clj-nix.packages.${system}.deps-lock
# nodePackages.postcss-cli
# nodePackages.sass
# nodePackages.svelte-check
# nodePackages.tailwindcss
sqlitebrowser # Java:
]; # gradle
# Use $1 for positional args # Python:
commands = [ # hatch
{ # py-spy
name = "db";
help = "Serve PocketBase"; # Qt:
command = "pocketbase serve --http 192.168.86.50:8090"; # qt6.wrapQtAppsHook # For the shellHook
} ];
{
name = "dev"; # Add dependencies to buildInputs if they will end up copied or linked into the final output or otherwise used at runtime:
help = "Serve Formula 11 (Dev)"; # - Libraries used by compilers, for example zlib
command = "npm run dev -- --host --port 5173"; # - Interpreters needed by patchShebangs for scripts which are installed, which can be the case for e.g. perl
} buildInputs = with pkgs; [
{ # C/C++:
name = "prod"; # boost
help = "Serve Formula 11 (Prod)"; # sfml
command = "npm run build && npm run preview -- --host --port 5173";
} # 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}"'"
# ''
];
}; };
}); });
} }

26
formula11.dockerfile Normal file
View File

@ -0,0 +1,26 @@
# Build the node application
FROM node:23-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm prune --production
# Copy the built application to a minimal image
FROM node:23-alpine
WORKDIR /app
COPY --from=builder /app/build build/
COPY --from=builder /app/node_modules node_modules/
COPY package.json .
EXPOSE 5173
ENV NODE_ENV=production
CMD [ "node", "build" ]

3132
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,32 +10,33 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
}, },
"devDependencies": { "devDependencies": {
"@fsouza/prettierd": "^0.25.4", "@carbon/charts-svelte": "^1.22.18",
"@skeletonlabs/skeleton": "^2.10.3", "@floating-ui/dom": "^1.6.13",
"@fsouza/prettierd": "^0.26.1",
"@skeletonlabs/skeleton": "^2.10.4",
"@skeletonlabs/tw-plugin": "^0.4.0", "@skeletonlabs/tw-plugin": "^0.4.0",
"@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/adapter-node": "^5.2.10", "@sveltejs/kit": "^2.19.1",
"@sveltejs/kit": "^2.9.0", "@sveltejs/vite-plugin-svelte": "^5.0.3",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/forms": "^0.5.9", "@types/node": "^22.13.10",
"@types/node": "^22.10.2", "autoprefixer": "^10.4.21",
"@types/uuid": "^10.0.0", "cheerio": "^1.0.0",
"autoprefixer": "^10.4.20", "date-fns": "^4.1.0",
"postcss": "^8.4.49", "pocketbase": "^0.25.2",
"prettier": "^3.4.2", "postcss": "^8.5.3",
"prettier-plugin-svelte": "^3.3.2", "prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.9", "prettier-plugin-svelte": "^3.3.3",
"svelte": "^5.0.0", "prettier-plugin-tailwindcss": "^0.6.11",
"svelte-check": "^4.1.1", "runes2": "^1.1.4",
"tailwindcss": "^3.4.16", "svelte": "^5.23.0",
"typescript": "^5.0.0", "svelte-check": "^4.1.5",
"vite": "^6.0.0" "tailwindcss": "^3.4.17",
"typescript": "^5.8.2",
"uuid": "^11.1.0",
"vite": "^6.2.2"
}, },
"dependencies": { "dependencies": {
"@floating-ui/dom": "^1.6.12", "sharp": "^0.33.5"
"date-fns": "^4.1.0",
"pocketbase": "^0.22.1",
"sharp": "^0.33.5",
"uuid": "^11.0.3"
} }
} }

2948
pb_schema.json Normal file

File diff suppressed because it is too large Load Diff

24
pocketbase.dockerfile Normal file
View File

@ -0,0 +1,24 @@
# Lock the version because I had to pull the image manually,
# because fucking Docker thinks "toomanyrequests". Fuckers
FROM docker.io/library/alpine:3.21.2
ARG PB_VERSION=0.33.0
RUN apk add --no-cache \
unzip \
ca-certificates
# Download and unzip PocketBase
ADD https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip /tmp/pb.zip
RUN unzip /tmp/pb.zip -d /pb/
# uncomment to copy the local pb_migrations dir into the image
# COPY ./pb_migrations /pb/pb_migrations
# uncomment to copy the local pb_hooks dir into the image
# COPY ./pb_hooks /pb/pb_hooks
EXPOSE 8080
# start PocketBase
CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8080"]

14
src/app.d.ts vendored
View File

@ -5,17 +5,19 @@ import type { PocketBase, RecordModel } from "pocketbase";
// for information about these interfaces // for information about these interfaces
declare global { declare global {
namespace App { namespace App {
interface Locals { // interface Locals {}
pb: PocketBase;
user: User | undefined;
admin: boolean;
}
// interface Error {} // interface Error {}
// interface PageData {} // interface PageData {}
// interface PageState {} // interface PageState {}
// interface Platform {} // interface Platform {}
} }
declare namespace svelteHTML {
interface HTMLAttributes<T> {
/** This element will be dispatched once an element with [use:lazyload] starts intersecting with the viewport. */
onLazyVisible?: (event: CustomEvent) => void;
}
}
} }
export {}; export {};

View File

@ -18,6 +18,9 @@
<!-- Prefetch data specified in "load" functions on link hover --> <!-- Prefetch data specified in "load" functions on link hover -->
<body data-theme="formula11Theme" data-sveltekit-preload-data="hover"> <body data-theme="formula11Theme" data-sveltekit-preload-data="hover">
<!-- SvelteKit inserts the body contents here --> <!-- SvelteKit inserts the body contents here -->
<div style="display: contents">%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> </body>
</html> </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,63 +0,0 @@
import type { Graphic, User } from "$lib/schema";
import type { Handle } from "@sveltejs/kit";
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();
event.locals.pb = new PocketBase("http://192.168.86.50:8090");
// 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_template URL if no avatar chosen
const driver_template: Graphic = await event.locals.pb
.collection("graphics")
.getFirstListItem('name="driver_template"');
event.locals.user.avatar_url = event.locals.pb.files.getURL(
driver_template,
driver_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;
};

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,75 +0,0 @@
<script lang="ts">
import { page } from "$app/stores";
import type { Snippet } from "svelte";
import type { HTMLButtonAttributes } from "svelte/elements";
import { popup, type PopupSettings } from "@skeletonlabs/skeleton";
const is_at_path = (path: string): boolean => {
const pathname: string = $page.url.pathname;
// console.log(pathname);
return pathname === path;
};
interface ButtonProps extends HTMLButtonAttributes {
children: Snippet;
/** The main color variant, e.g. "primary" or "secondary". */
color?: string | undefined;
/** Set the button type to "submit" (otherwise "button"). Only if "href" is undefined. */
submit?: boolean;
/** Make the button act as a link. */
href?: string | undefined;
/** Add the "w-full" class to the button. */
fullwidth?: boolean;
/** Enable the button's ":hover" state manually. */
activate?: boolean;
/** Enable the button's ":hover" state if the current URL matches the "href". Only if "href" is defined. */
activate_href?: boolean;
/** The PopupSettings to trigger on click. Only if "href" is undefined. */
trigger_popup?: PopupSettings;
}
let {
children,
color = undefined,
submit = false,
href = undefined,
fullwidth = false,
activate = false,
activate_href = false,
trigger_popup = { event: "click", target: "invalid" },
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<!-- HACK: Make the button act as a link using a form -->
<form action={href} class="contents">
<button
type="submit"
class="btn m-0 select-none px-2 py-2 {color ? `variant-filled-${color}` : ''} {fullwidth
? 'w-full'
: 'w-auto'} {activate ? 'btn-hover' : ''} {activate_href && is_at_path(href)
? 'btn-hover'
: ''}"
draggable="false"
{...restProps}>{@render children()}</button
>
</form>
{:else}
<button
type={submit ? "submit" : "button"}
class="btn select-none px-2 py-2 {color ? `variant-filled-${color}` : ''} {fullwidth
? 'w-full'
: 'w-auto'} {activate ? 'btn-hover' : ''}"
draggable="false"
use:popup={trigger_popup}
{...restProps}>{@render children()}</button
>
{/if}

View File

@ -0,0 +1,41 @@
<script lang="ts">
interface CountdownProps {
date: string;
gotext?: string;
extraclass?: string;
}
let { date, gotext = "Go Go Go", extraclass = "" }: CountdownProps = $props();
// Set the date we're counting down to
const countDownDate = new Date(date).getTime();
let distance: number = $state(0);
let days: number = $state(0);
let hours: number = $state(0);
let minutes: number = $state(0);
let seconds: number = $state(0);
// Update the countdown every 1 second
setInterval(function () {
// Get today's date and time
const now = new Date().getTime();
// Find the distance between now and the countdown date
distance = countDownDate - now;
// Time calculations for days, hours, minutes and seconds
days = Math.floor(distance / (1000 * 60 * 60 * 24));
hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
seconds = Math.floor((distance % (1000 * 60)) / 1000);
}, 1000);
</script>
<span class={extraclass}>
{#if distance > 0}
{days + "d " + hours + "h " + minutes + "m "}
{:else}
{gotext}
{/if}
</span>

View File

@ -1,148 +0,0 @@
<script lang="ts">
import { get_image_preview_event_handler } from "$lib/image";
import { FileDropzone, SlideToggle } from "@skeletonlabs/skeleton";
import LazyCard from "./LazyCard.svelte";
import Button from "./Button.svelte";
import type { Driver } from "$lib/schema";
import Input from "./Input.svelte";
import LazyDropdown, { type LazyDropdownOption } from "./LazyDropdown.svelte";
import {
DRIVER_CARD_ASPECT_HEIGHT,
DRIVER_CARD_ASPECT_WIDTH,
DRIVER_HEADSHOT_HEIGHT,
DRIVER_HEADSHOT_WIDTH,
} from "$lib/config";
interface DriverCardProps {
/** The [Driver] object used to prefill values. */
driver?: Driver | undefined;
/** 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 */
team_select_value: string;
/** The options this component's team select dropdown will display */
team_select_options: LazyDropdownOption[];
/** The value this component's active switch will bind to */
active_value: boolean;
}
let {
driver = undefined,
disable_inputs = false,
require_inputs = false,
headshot_template = undefined,
team_select_value,
team_select_options,
active_value,
}: DriverCardProps = $props();
</script>
<LazyCard
cardwidth={DRIVER_CARD_ASPECT_WIDTH}
cardheight={DRIVER_CARD_ASPECT_HEIGHT}
imgsrc={driver?.headshot_url ?? headshot_template}
imgwidth={DRIVER_HEADSHOT_WIDTH}
imgheight={DRIVER_HEADSHOT_HEIGHT}
imgid="update_driver_headshot_preview_{driver?.id ?? 'create'}"
>
<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
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 -->
<LazyDropdown
name="team"
input_variable={team_select_value}
options={team_select_options}
labelwidth="120px"
disabled={disable_inputs}
required={require_inputs}
>Team
</LazyDropdown>
<!-- 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"><b>Upload Headshot</b> or Drag and Drop</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
>Save Changes</Button
>
<Button color="primary" submit disabled={disable_inputs} formaction="?/delete_driver"
>Delete</Button
>
{:else}
<Button formaction="?/create_driver" color="tertiary" submit>Create Driver</Button>
{/if}
</div>
</div>
</form>
</LazyCard>

View File

@ -1,80 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
import LazyImage from "./LazyImage.svelte";
import { lazyload } from "$lib/lazyload";
interface CardProps {
children: Snippet;
/** The URL for a possible header image. Leave undefined for no header image. Set to empty string for an image not yet loaded. */
imgsrc?: string | undefined;
/** The id of the header image element for JS access. */
imgid?: string | undefined;
/** The aspect ratio width used to reserve image space (while its loading) */
imgwidth: number;
/** The aspect ratio height used to reserve image space (while its loading) */
imgheight: number;
/** Hide the header image element. It can be shown by removing the "hidden" property using JS and the imgid. */
imghidden?: boolean;
/** The aspect ratio width used to reserve card space (while its loading) */
cardwidth: number;
/** The aspect ratio height used to reserve card space (while its loading) */
cardheight: number;
}
let {
children,
imgsrc = undefined,
imgid = undefined,
imgwidth,
imgheight,
imghidden = false,
cardwidth,
cardheight,
...restProps
}: CardProps = $props();
let load: boolean = $state(false);
const lazy_visible_handler = () => {
console.log("Hi");
load = true;
};
</script>
<!-- TODO: This component needs to know its own height, otherwise the intersection observer doesn't work -->
<!-- (all elements are visible at once, so no lazy loading...) -->
<div
use:lazyload
onLazyVisible={lazy_visible_handler}
style="width: 100%; aspect-ratio: {cardwidth} / {cardheight};"
>
<div class="card w-full overflow-hidden bg-white shadow">
<!-- Allow empty strings for images that only appear after user action -->
{#if imgsrc !== undefined}
<LazyImage
id={imgid}
src={imgsrc}
{imgwidth}
{imgheight}
alt="Card header"
draggable="false"
class="select-none shadow"
hidden={imghidden}
/>
{/if}
<!-- Only lazyload children, as the image is already lazy (also the image fade would break) -->
{#if load}
<div class="p-2" {...restProps}>
{@render children()}
</div>
{/if}
</div>
</div>

View File

@ -1,164 +0,0 @@
<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 LazyImage from "./LazyImage.svelte";
export interface LazyDropdownOption {
/** The label displayed in the list of options. */
label: string;
/** The value assigned to the dropdown value variable */
value: string;
/** An optional icon displayed left to the label */
icon_url?: string;
/** The aspect ratio width of the optional icon */
icon_width?: number;
/** The aspect ratio height of the optional icon */
icon_height?: number;
}
interface LazyDropdownProps extends HTMLInputAttributes {
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;
/** The options this autocomplete component allows to choose from.
* Example: [[{ label: "Aston", value: "0" }, { label: "VCARB", value: "1" }]].
*/
options: LazyDropdownOption[];
}
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",
},
options,
...restProps
}: LazyDropdownProps = $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"));
});
let load: boolean = $state(false);
const lazy_click_handler = () => {
load = true;
};
</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>
<!-- 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
onmousedown={lazy_click_handler}
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
onmousedown={lazy_click_handler}
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;"
>
{#if load}
<ListBox>
{#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=""
imgwidth={option.icon_width ?? 1}
imgheight={option.icon_height ?? 1}
class="mr-2 rounded"
style="height: 24px; max-width: 64px;"
/>
{/if}
{option.label}
</div>
</ListBoxItem>
{/each}
</ListBox>
{/if}
</div>

View File

@ -2,6 +2,8 @@
import type { HTMLImgAttributes } from "svelte/elements"; import type { HTMLImgAttributes } from "svelte/elements";
import { lazyload } from "$lib/lazyload"; import { lazyload } from "$lib/lazyload";
import { fetch_image_base64 } from "$lib/image"; import { fetch_image_base64 } from "$lib/image";
import { popup, type PopupSettings } from "@skeletonlabs/skeleton";
import { v4 as uuidv4 } from "uuid";
interface LazyImageProps extends HTMLImgAttributes { interface LazyImageProps extends HTMLImgAttributes {
/** The URL to the image resource to lazyload */ /** The URL to the image resource to lazyload */
@ -12,9 +14,34 @@
/** The aspect ratio height used to reserve image space (while its loading) */ /** The aspect ratio height used to reserve image space (while its loading) */
imgheight: number; imgheight: number;
/** Optional extra style for the <img> element */
imgstyle?: string;
/** Optional extra style for the lazy <div> container */
containerstyle?: string;
/** Additional classes to insert for container + image */
imgclass?: string;
/** Slightly zoom the image on mouse-hover */
hoverzoom?: boolean;
/** Optional tooltip text */
tooltip?: string;
} }
let { src, imgwidth, imgheight, ...restProps }: LazyImageProps = $props(); let {
src,
imgwidth,
imgheight,
imgstyle = undefined,
containerstyle = undefined,
imgclass = "",
hoverzoom = false,
tooltip = undefined,
...restProps
}: LazyImageProps = $props();
// Once the image is visible, this will be set to true, triggering the loading // Once the image is visible, this will be set to true, triggering the loading
let load: boolean = $state(false); let load: boolean = $state(false);
@ -23,26 +50,47 @@
load = true; load = true;
}; };
// Once the image component is mounted (e.g. when the image has loaded),
// transition the opacity to fade-in the image
const img_opacity_handler = (node: HTMLElement) => { const img_opacity_handler = (node: HTMLElement) => {
setTimeout(() => (node.style.opacity = "1"), 20); setTimeout(() => (node.style.opacity = "1"), 20);
}; };
// Tooltip handling
const tooltipId: string = uuidv4();
const popupTooltip: PopupSettings = {
event: "hover",
target: tooltipId,
placement: "top",
};
</script> </script>
<!-- Show a correctly sized div so the layout doesn't jump. --> <!-- Show a correctly sized div so the layout doesn't jump. -->
<div <div
use:lazyload use:lazyload
onLazyVisible={lazy_visible_handler} onLazyVisible={lazy_visible_handler}
style="width: 100%; aspect-ratio: {imgwidth} / {imgheight};" class="overflow-hidden {imgclass}"
style="aspect-ratio: {imgwidth} / {imgheight}; max-width: {imgwidth}px; max-height: {imgheight}px; {containerstyle ??
''}"
> >
{#if load} {#if load}
{#await fetch_image_base64(src) then data} {#await fetch_image_base64(src) then data}
<img <img
src={data} src={data}
use:img_opacity_handler use:img_opacity_handler
class="bg-surface-100 transition-opacity" class="bg-surface-100 transition-all {imgclass} {hoverzoom ? 'hover:scale-105' : ''}"
style="width: 100%; opacity: 0; transition-duration: 500ms;" style="opacity: 0; transition-duration: 300ms; {imgstyle ?? ''}"
draggable="false"
use:popup={popupTooltip}
{...restProps} {...restProps}
/> />
{/await} {/await}
{/if} {/if}
</div> </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

@ -1,185 +0,0 @@
<script lang="ts">
import { get_image_preview_event_handler } from "$lib/image";
import { FileDropzone } from "@skeletonlabs/skeleton";
import LazyCard from "./LazyCard.svelte";
import Button from "./Button.svelte";
import type { Race } from "$lib/schema";
import Input from "./Input.svelte";
import { format } from "date-fns";
import {
RACE_CARD_ASPECT_HEIGHT,
RACE_CARD_ASPECT_WIDTH,
RACE_PICTOGRAM_HEIGHT,
RACE_PICTOGRAM_WIDTH,
} from "$lib/config";
interface RaceCardProps {
/** 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;
}
let {
race = undefined,
disable_inputs = false,
require_inputs = false,
pictogram_template = "",
}: RaceCardProps = $props();
// 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 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;
sprintquali.value = "";
sprint.value = "";
};
</script>
<LazyCard
cardwidth={RACE_CARD_ASPECT_WIDTH}
cardheight={RACE_CARD_ASPECT_HEIGHT}
imgsrc={race?.pictogram_url ?? pictogram_template}
imgwidth={RACE_PICTOGRAM_WIDTH}
imgheight={RACE_PICTOGRAM_HEIGHT}
imgid="update_race_pictogram_preview_{race?.id ?? 'create'}"
>
<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">
<!-- Driver name input -->
<Input
id="race_name_{race?.id ?? 'create'}"
name="name"
value={race?.name ?? ""}
autocomplete="off"
labelwidth="120px"
disabled={disable_inputs}
required={require_inputs}>Name</Input
>
<Input
id="race_step_{race?.id ?? 'create'}"
name="step"
value={race?.step ?? ""}
autocomplete="off"
labelwidth="120px"
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="120px"
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="race_sprintqualidate_{race?.id ?? 'create'}"
name="sprintqualidate"
value={sprintqualidate ?? ""}
autocomplete="off"
labelwidth="120px"
type="datetime-local"
disabled={disable_inputs}>Sprint Quali</Input
>
<Input
id="race_sprintdate_{race?.id ?? 'create'}"
name="sprintdate"
value={sprintdate ?? ""}
autocomplete="off"
labelwidth="120px"
type="datetime-local"
disabled={disable_inputs}>Sprint</Input
>
<Input
id="race_qualidate_{race?.id ?? 'create'}"
name="qualidate"
value={qualidate ?? ""}
autocomplete="off"
labelwidth="120px"
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="120px"
type="datetime-local"
disabled={disable_inputs}
required={require_inputs}>Sprint Quali</Input
>
<!-- 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"><b>Upload Pictogram</b> or Drag and Drop</svelte:fragment>
</FileDropzone>
<!-- Save/Delete buttons -->
<div class="flex justify-end gap-2">
<Button onclick={clear_sprint} color="secondary" disabled={disable_inputs}
>Remove Sprint</Button
>
{#if race}
<Button formaction="?/update_race" color="secondary" disabled={disable_inputs} submit
>Save Changes</Button
>
<Button color="primary" submit disabled={disable_inputs} formaction="?/delete_race"
>Delete</Button
>
{:else}
<Button formaction="?/create_race" color="tertiary" submit>Create Race</Button>
{/if}
</div>
</div>
</form>
</LazyCard>

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

@ -1,159 +0,0 @@
<script lang="ts">
import LazyCard from "./LazyCard.svelte";
import Button from "./Button.svelte";
import type { Driver, Substitution } from "$lib/schema";
import { get_by_value } from "$lib/database";
import LazyDropdown, { type LazyDropdownOption } from "./LazyDropdown.svelte";
import type { Action } from "svelte/action";
import {
DRIVER_HEADSHOT_HEIGHT,
DRIVER_HEADSHOT_WIDTH,
SUBSTITUTION_CARD_ASPECT_HEIGHT,
SUBSTITUTION_CARD_ASPECT_WIDTH,
} from "$lib/config";
interface SubstitutionCardProps {
/** The [Substitution] object used to prefill values. */
substitution?: Substitution | undefined;
/** The drivers (to display the headshot) */
drivers: Driver[];
/** 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;
/** The options this component's substitute/driver select dropdowns will display */
driver_select_options: LazyDropdownOption[];
/** The options this component's race select dropdown will display */
race_select_options: LazyDropdownOption[];
}
let {
substitution = undefined,
drivers,
disable_inputs = false,
require_inputs = false,
headshot_template = "",
substitute_select_value,
driver_select_value,
race_select_value,
driver_select_options,
race_select_options,
}: SubstitutionCardProps = $props();
// 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);
};
// 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;
// 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;
}
};
</script>
<LazyCard
cardwidth={SUBSTITUTION_CARD_ASPECT_WIDTH}
cardheight={SUBSTITUTION_CARD_ASPECT_HEIGHT}
imgsrc={get_by_value(drivers, "id", substitution?.substitute ?? "")?.headshot_url ??
headshot_template}
imgwidth={DRIVER_HEADSHOT_WIDTH}
imgheight={DRIVER_HEADSHOT_HEIGHT}
imgid="update_substitution_headshot_preview_{substitution?.id ?? 'create'}"
>
<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}
<div class="flex flex-col gap-2">
<!-- Substitute select -->
<LazyDropdown
name="substitute"
input_variable={substitute_select_value}
action={register_substitute_preview_handler}
options={driver_select_options}
labelwidth="120px"
disabled={disable_inputs}
required={require_inputs}
>
Substitute
</LazyDropdown>
<!-- Driver select -->
<LazyDropdown
name="for"
input_variable={driver_select_value}
options={driver_select_options}
labelwidth="120px"
disabled={disable_inputs}
required={require_inputs}
>For
</LazyDropdown>
<!-- Race select -->
<LazyDropdown
name="race"
input_variable={race_select_value}
options={race_select_options}
labelwidth="120px"
disabled={disable_inputs}
required={require_inputs}
>Race
</LazyDropdown>
<!-- Save/Delete buttons -->
<div class="flex justify-end gap-2">
{#if substitution}
<Button
formaction="?/update_substitution"
color="secondary"
disabled={disable_inputs}
submit>Save Changes</Button
>
<Button
color="primary"
submit
disabled={disable_inputs}
formaction="?/delete_substitution">Delete</Button
>
{:else}
<Button formaction="?/create_substitution" color="tertiary" submit
>Create Substitution</Button
>
{/if}
</div>
</div>
</form>
</LazyCard>

View File

@ -0,0 +1,54 @@
<script lang="ts">
import { type TableColumn } from "$lib/components";
interface TableProps {
/** The data that is displayed inside the table. Any array of arbitrary key-value objects. */
data: any[];
/** 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, height = "", handler = undefined }: TableProps = $props();
</script>
{#if data.length > 0}
<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>
{/each}
</tr>
</thead>
<tbody>
{#each data as row}
<tr
class="cursor-pointer bg-surface-300"
onclick={async (event: Event) => {
if (handler) await handler(event, row.id);
}}
>
{#each columns as col}
{#if col.valuefun}
<td class="!align-middle">
{#await col.valuefun(row[col.data_value_name]) then value}{@html value}{/await}
</td>
{:else}
<td class="!align-middle">{row[col.data_value_name]}</td>
{/if}
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
{/if}

View File

@ -0,0 +1,10 @@
export interface TableColumn {
/** The name of the property containing the value. */
data_value_name: string;
/** The columnname for this property. */
label: string;
/** Any function to further customize the displayed value. May return HTML. */
valuefun?: (value: any) => Promise<string>;
}

View File

@ -1,93 +0,0 @@
<script lang="ts">
import { get_image_preview_event_handler } from "$lib/image";
import { FileDropzone } from "@skeletonlabs/skeleton";
import Button from "./Button.svelte";
import type { Team } from "$lib/schema";
import Input from "./Input.svelte";
import {
TEAM_CARD_ASPECT_HEIGHT,
TEAM_CARD_ASPECT_WIDTH,
TEAM_LOGO_HEIGHT,
TEAM_LOGO_WIDTH,
} from "$lib/config";
import LazyCard from "./LazyCard.svelte";
interface TeamCardProps {
/** 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 logo template preview */
logo_template?: string;
}
let {
team = undefined,
disable_inputs = false,
require_inputs = false,
logo_template = "",
}: TeamCardProps = $props();
</script>
<LazyCard
cardwidth={TEAM_CARD_ASPECT_WIDTH}
cardheight={TEAM_CARD_ASPECT_HEIGHT}
imgsrc={team?.logo_url ?? logo_template}
imgwidth={TEAM_LOGO_WIDTH}
imgheight={TEAM_LOGO_HEIGHT}
imgid="update_team_logo_preview_{team?.id ?? 'create'}"
>
<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
id="team_name_{team?.id ?? 'create'}"
name="name"
value={team?.name ?? ""}
autocomplete="off"
disabled={disable_inputs}
required={require_inputs}
>
Name
</Input>
<!-- 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"><b>Upload Logo</b> or Drag and Drop</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>
Save Changes
</Button>
<Button color="primary" submit disabled={disable_inputs} formaction="?/delete_team">
Delete
</Button>
{:else}
<Button formaction="?/create_team" color="tertiary" submit>Create Team</Button>
{/if}
</div>
</div>
</form>
</LazyCard>

View File

@ -0,0 +1,67 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { LazyImage } from "$lib/components";
import { error } from "@sveltejs/kit";
interface CardProps {
children: Snippet;
/** The URL for a possible header image. Leave undefined for no header image. Set to empty string for an image not yet loaded. */
imgsrc?: string;
/** The width of the image. Required if imgsrc is set */
imgwidth?: number;
/** The height of the image. Required if imgsrc is set */
imgheight?: number;
/** The id of the header image element for JS access. */
imgid?: string;
/** Hide the header image element. It can be shown by removing the "hidden" property using JS and the imgid. */
imghidden?: boolean;
/** The width class for the card, defaults to [w-auto] */
width?: string;
/** An optional event handler for clicking the image */
imgonclick?: (event: Event) => void;
}
let {
children,
imgsrc = undefined,
imgwidth = undefined,
imgheight = undefined,
imgid = undefined,
imghidden = false,
width = "w-auto",
imgonclick = undefined,
...restProps
}: CardProps = $props();
if (imgsrc && (!imgwidth || !imgheight)) {
error(400, "imgwidth and imgheight need to be specified when setting an imgsrc!");
}
</script>
<div class="card {width} overflow-hidden bg-white shadow">
<!-- Allow empty strings for images that only appear after user action -->
{#if imgsrc !== undefined}
<LazyImage
id={imgid}
src={imgsrc}
alt="Card header"
draggable="false"
class="select-none shadow"
hidden={imghidden}
imgwidth={imgwidth ?? 0}
imgheight={imgheight ?? 0}
onclick={imgonclick}
/>
{/if}
<div class="p-2" {...restProps}>
{@render children()}
</div>
</div>

View File

@ -0,0 +1,233 @@
<script lang="ts">
import { get_image_preview_event_handler } from "$lib/image";
import {
FileDropzone,
getModalStore,
getToastStore,
SlideToggle,
type ModalStore,
type ToastStore,
} from "@skeletonlabs/skeleton";
import { Button, Input, Card, Dropdown } from "$lib/components";
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;
}
let { data, driver = undefined }: DriverCardProps = $props();
const modalStore: ModalStore = getModalStore();
if ($modalStore[0].meta) {
const meta = $modalStore[0].meta;
data = meta.data;
driver = meta.driver;
}
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 ?? 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 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>
<!-- Driver team input -->
{#await data.teams then teams}
<Dropdown
bind:value={team_select_value}
options={team_dropdown_options(teams)}
{labelwidth}
{disabled}
{required}
>
Team
</Dropdown>
{/await}
<!-- 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}
/>
</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>
</div>
</Card>

View File

@ -0,0 +1,300 @@
<script lang="ts">
import { get_image_preview_event_handler } from "$lib/image";
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 { 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;
}
let { data, race = undefined }: RaceCardProps = $props();
const modalStore: ModalStore = getModalStore();
if ($modalStore[0].meta) {
const meta = $modalStore[0].meta;
data = meta.data;
race = meta.race;
}
const toastStore: ToastStore = getToastStore();
// Constants
const labelwidth = "85px";
const clear_sprint = () => {
(document.getElementById("sqdate") as HTMLInputElement).value = "";
(document.getElementById("sdate") as HTMLInputElement).value = "";
sprintqualidate_value = "";
sprintdate_value = "";
};
// 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 ?? 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()}
>
<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>
<!-- 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>
<!-- 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>
<!-- 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>
<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>
</div>
</Card>

View File

@ -0,0 +1,209 @@
<script lang="ts">
import { Card, Button, Dropdown } from "$lib/components";
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;
}
let { data, racepick = undefined }: RacePickCardProps = $props();
const modalStore: ModalStore = getModalStore();
if ($modalStore[0].meta) {
const meta = $modalStore[0].meta;
data = meta.data;
racepick = meta.racepick;
}
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;
};
// 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;
}
const racepick_data = {
user: $pbUser.id,
race: data.currentrace.id,
pxx: pxx_select_value,
dnf: dnf_select_value,
};
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>
{#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
bind:value={pxx_select_value}
options={driver_dropdown_options(active_drivers_and_substitutes)}
{labelwidth}
{disabled}
{required}
>
P{data.currentrace?.pxx ?? "XX"}
</Dropdown>
<!-- DNF select -->
<Dropdown
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 onclick={update_racepick()} color="secondary" {disabled} width="w-1/2">
Save Changes
</Button>
<Button onclick={delete_racepick} color="primary" {disabled} width="w-1/2">Delete</Button>
{:else}
<Button onclick={update_racepick(true)} color="tertiary" {disabled} width="w-full">
Make Pick
</Button>
{/if}
</div>
</div>
</Card>
{/await}

View File

@ -0,0 +1,309 @@
<script lang="ts">
import {
Autocomplete,
getModalStore,
getToastStore,
InputChip,
type AutocompleteOption,
type ModalStore,
type ToastStore,
} from "@skeletonlabs/skeleton";
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;
}
let { data, result = undefined }: RaceResultCardProps = $props();
const modalStore: ModalStore = getModalStore();
if ($modalStore[0].meta) {
const meta = $modalStore[0].meta;
data = meta.data;
result = meta.result;
}
const toastStore: ToastStore = getToastStore();
// Await promises
let races: Race[] | undefined = $state(undefined);
data.races.then((r: Race[]) => (races = r));
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([]);
let dnfs_input: string = $state("");
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(result?.pxxs ?? []);
let dnfs_ids: string[] = $state(result?.dnfs ?? []);
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,
};
});
});
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 (disabled || !drivers) return;
// Can only select 7 drivers
if (pxxs_chips.length >= 7) return;
// Can only select a driver once
if (pxxs_chips.includes(event.detail.value)) return;
// 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);
}
};
const on_pxxs_chip_remove = (event: CustomEvent): void => {
pxxs_ids.splice(event.detail.chipIndex, 1);
};
const on_dnfs_chip_select = (event: CustomEvent<AutocompleteOption<string>>): void => {
if (disabled || !drivers) return;
// Can only select a driver once
if (dnfs_chips.includes(event.detail.value)) return;
// 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);
}
};
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]">
<div class="flex flex-col gap-2">
<!-- Race select input -->
{#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}
{#if race_select_value}
<!-- PXXs autocomplete chips -->
<InputChip
bind:input={pxxs_input}
bind:value={pxxs_chips}
whitelist={pxxs_whitelist}
allowUpperCase
placeholder={pxxs_placeholder}
name="pxxs_codes"
{disabled}
{required}
on:remove={on_pxxs_chip_remove}
/>
<div class="card max-h-48 w-full overflow-y-auto p-2" tabindex="-1">
<Autocomplete
bind:input={pxxs_input}
options={pxxs_options}
denylist={pxxs_chips}
on:selection={on_pxxs_chip_select}
/>
</div>
<!-- DNFs autocomplete chips -->
<InputChip
bind:input={dnfs_input}
bind:value={dnfs_chips}
whitelist={pxxs_whitelist}
allowUpperCase
placeholder="Select DNFs..."
name="dnfs_codes"
{disabled}
on:remove={on_dnfs_chip_remove}
/>
<div class="card max-h-48 w-full overflow-y-auto p-2" tabindex="-1">
<Autocomplete
bind:input={dnfs_input}
options={pxxs_options}
denylist={dnfs_chips}
on:selection={on_dnfs_chip_select}
/>
</div>
<!-- Save/Delete buttons -->
<div class="flex items-center justify-end gap-2">
{#if result}
<Button onclick={update_raceresult()} color="secondary" {disabled} width="w-1/2"
>Save</Button
>
<Button onclick={delete_raceresult} color="primary" {disabled} width="w-1/2"
>Delete</Button
>
{:else}
<Button onclick={update_raceresult(true)} color="tertiary" {disabled} width="w-full">
Create Result
</Button>
{/if}
</div>
{/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

@ -0,0 +1,179 @@
<script lang="ts">
import { Card, Button, Dropdown } from "$lib/components";
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;
}
let { data, substitution = undefined }: SubstitutionCardProps = $props();
const modalStore: ModalStore = getModalStore();
if ($modalStore[0].meta) {
const meta = $modalStore[0].meta;
data = meta.data;
substitution = meta.substitution;
}
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;
};
const delete_substitution = async (): Promise<void> => {
if (!substitution?.id) {
toastStore.trigger(get_error_toast("Invalid substitution id!"));
return;
}
try {
await pb.collection("substitutions").delete(substitution.id);
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
</script>
{#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
bind:value={substitute_value}
options={driver_dropdown_options(inactive_drivers)}
{labelwidth}
{disabled}
{required}
>
Substitute
</Dropdown>
<!-- Driver select -->
<Dropdown
bind:value={driver_value}
options={driver_dropdown_options(active_drivers)}
{labelwidth}
{disabled}
{required}
>
For
</Dropdown>
<!-- Race select -->
{#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 onclick={update_substitution()} color="secondary" {disabled} width="w-1/2">
Save Changes
</Button>
<Button onclick={delete_substitution} color="primary" {disabled} width="w-1/2">
Delete
</Button>
{:else}
<Button onclick={update_substitution(true)} color="tertiary" {disabled} width="w-full">
Create Substitution
</Button>
{/if}
</div>
</div>
</Card>
{/await}

View File

@ -0,0 +1,256 @@
<script lang="ts">
import { get_image_preview_event_handler } from "$lib/image";
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,
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;
}
let { data, team = undefined }: TeamCardProps = $props();
const modalStore: ModalStore = getModalStore();
if ($modalStore[0].meta) {
const meta = $modalStore[0].meta;
data = meta.data;
team = meta.team;
}
const toastStore: ToastStore = getToastStore();
// Constants
const labelwidth: string = "110px";
// 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 ?? 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()}
>
<div class="flex flex-col gap-2">
<!-- Team name input -->
<Input bind:value={name_value} autocomplete="off" {labelwidth} {disabled} {required}>
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>
<!-- 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>
<!-- 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>
<!-- 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>
</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

@ -0,0 +1,105 @@
<script lang="ts">
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;
// return pathname === path;
return pathname.endsWith(path);
};
interface ButtonProps {
children: Snippet;
/** The main color variant, e.g. "primary" or "secondary". */
color?: string;
/** Set the button type to "submit" (otherwise "button"). Only if "href" is undefined. */
submit?: boolean;
/** 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;
/** Enable the button's ":hover" state manually. */
activate?: boolean;
/** Enable the button's ":hover" state if the current URL matches the "href". Only if "href" is defined. */
activate_href?: boolean;
/** The PopupSettings to trigger on click. Only if "href" is undefined. */
trigger_popup?: PopupSettings;
/** Should the button have a shadow? */
shadow?: boolean;
/** Additional classes to insert */
extraclass?: string;
/** An optional onclick event for the button */
onclick?: (event: Event) => void;
/** An optional formaction for the button */
formaction?: string;
/** Optionally disable the button */
disabled?: boolean;
}
let {
children,
color = undefined,
submit = false,
href = undefined,
newtab = false,
width = "w-auto",
activate = false,
activate_href = false,
trigger_popup = { event: "click", target: "invalid" },
shadow = false,
extraclass = "",
onclick = () => {},
formaction = undefined,
disabled = false,
...restProps
}: ButtonProps = $props();
</script>
{#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
? 'shadow'
: ''} {extraclass}"
{onclick}
{...restProps}
draggable="false"
>
{@render children()}
</a>
{:else}
<button
type={submit ? "submit" : "button"}
class="btn select-none px-2 py-2 {color ? `variant-filled-${color}` : ''} {width} {activate
? 'btn-hover'
: ''} {shadow ? 'shadow' : ''} {extraclass}"
draggable="false"
use:popup={trigger_popup}
{onclick}
{formaction}
{disabled}
{...restProps}
>
{@render children()}
</button>
{/if}

View File

@ -0,0 +1,44 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { HTMLSelectAttributes } from "svelte/elements";
import { type DropdownOption } from "$lib/components";
interface DropdownProps extends HTMLSelectAttributes {
children: Snippet;
/** 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 options this autocomplete component allows to choose from.
* Example: [[{ label: "Aston", value: "0" }, { label: "VCARB", value: "1" }]].
*/
options: DropdownOption[];
}
let {
children,
labelwidth = "auto",
value = $bindable(),
options,
...restProps
}: DropdownProps = $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>
<select bind:value class="!outline-none" {...restProps}>
{#each options as option}
<option value={option.value} selected={value === option.value}>
{option.label}
</option>
{/each}
</select>
</div>

View File

@ -0,0 +1,16 @@
export interface DropdownOption {
/** The label displayed in the list of options. */
label: string;
/** The value assigned to the dropdown value variable */
value: string;
/** An optional icon displayed left to the label */
icon_url?: string;
/** The icon width. Required if icon_url is set */
icon_width?: number;
/** The icon height. Required if icon_url is set */
icon_height?: number;
}

View File

@ -8,11 +8,24 @@
/** Manually set the label width, to align multiple inputs vertically. Supply value in CSS units. */ /** Manually set the label width, to align multiple inputs vertically. Supply value in CSS units. */
labelwidth?: string; 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". */ /** The type of the input element, e.g. "text". */
type?: string; 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> </script>
<div class="input-group input-group-divider grid-cols-[auto_1fr_auto]"> <div class="input-group input-group-divider grid-cols-[auto_1fr_auto]">
@ -22,5 +35,8 @@
> >
{@render children()} {@render children()}
</div> </div>
<input {type} {...restProps} /> <input bind:value class="{tail ? '!border-r' : ''} !border-l" {type} {...restProps} />
{#if tail}
{@render tail()}
{/if}
</div> </div>

View File

@ -1,35 +1,66 @@
import Button from "./Button.svelte"; import Countdown from "./Countdown.svelte";
import DriverCard from "./DriverCard.svelte";
import Input from "./Input.svelte";
import LazyCard from "./LazyCard.svelte";
import LazyDropdown from "./LazyDropdown.svelte";
import LazyImage from "./LazyImage.svelte"; import LazyImage from "./LazyImage.svelte";
import LoadingIndicator from "./LoadingIndicator.svelte"; import LoadingIndicator from "./LoadingIndicator.svelte";
import RaceCard from "./RaceCard.svelte"; import Table from "./Table.svelte";
import Search from "./Search.svelte";
import SubstitutionCard from "./SubstitutionCard.svelte";
import TeamCard from "./TeamCard.svelte";
import Button from "./form/Button.svelte";
import Dropdown from "./form/Dropdown.svelte";
import Input from "./form/Input.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 MenuDrawerIcon from "./svg/MenuDrawerIcon.svelte";
import NameIcon from "./svg/NameIcon.svelte";
import PasswordIcon from "./svg/PasswordIcon.svelte"; import PasswordIcon from "./svg/PasswordIcon.svelte";
import StopwatchIcon from "./svg/StopwatchIcon.svelte";
import UserIcon from "./svg/UserIcon.svelte"; import UserIcon from "./svg/UserIcon.svelte";
export { export {
// Components // Components
Button, Countdown,
DriverCard,
Input,
LazyCard,
LazyDropdown,
LazyImage, LazyImage,
LoadingIndicator, LoadingIndicator,
Table,
// Form
Button,
Dropdown,
Input,
// Cards
Card,
DriverCard,
RaceCard, RaceCard,
Search, RacePickCard,
RaceResultCard,
SeasonPickCard,
SubstitutionCard, SubstitutionCard,
TeamCard, TeamCard,
TeamSwitchCard,
// Types
type DropdownOption,
type TableColumn,
// SVG // SVG
ChequeredFlagIcon,
EMailIcon,
NameIcon,
MenuDrawerIcon, MenuDrawerIcon,
PasswordIcon, PasswordIcon,
StopwatchIcon,
UserIcon, UserIcon,
}; };

View File

@ -0,0 +1,24 @@
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
viewBox="0 0 2400 2304"
stroke="currentColor"
fill="currentColor"
>
<g id="g4197">
<path
id="path4145"
d="m335.75 1345.9c-184.43-526.94-335.52-959.07-335.75-960.29-0.38434-2.01 102.58-49.62 107.32-49.61 0.96168 0.001 743.15 1889.5 744.16 1894.5 0.3655 1.8202-174.51 73.476-179.32 73.476-0.59033 0-151.97-431.14-336.4-958.09z"
/>
<path
id="path4141"
fill="#f2f2f2"
d="m479.24 1092.4c-221.54-563.11-287.61-732.01-286.84-733.27 7.92-12.81 103.37-85.21 156.29-118.55 226.19-142.47 522.47-212.74 817.01-193.76l10.476 0.67518 237.44 534.25c130.59 293.84 237.59 534.1 237.77 533.92 0.1795-0.1795-52.581-196.04-117.25-435.25-64.665-239.21-117.8-436.29-118.09-437.96l-0.516-3.0317 17.345-3.7869c222.72-48.624 369.79-113.98 489.63-217.57 7.5438-6.5212 15.038-13.257 16.653-14.968 1.7-1.7002 3.5-3.1 4.2-3.1 1.0301 0 455.19 1145.4 456.25 1150.7 0.7419 3.6924-13.803 26.955-29.267 46.807-137.48 176.49-482.03 362.83-734.1 397-156.36 21.198-262.46-15.13-311.29-106.58-6.8919-12.907-7.3571-11.514 9.2052-27.574 83.584-81.054 212.39-148.52 266.24-139.44 49.089 8.2692 39.627 66.512-28.664 176.44l-6.9354 11.164 5.7665-6.1213c212.42-225.49 188.76-339.31-50.521-242.99-248.98 100.22-584.66 349.95-741.13 551.36-5.6613 7.2875-10.616 13.25-11.01 13.25-0.39424 0-130.25-329.23-288.57-731.62z"
/>
<path
id="path4"
d="m668.98 99.711c-185.2 52.799-345.52 142.32-477.02 260.67l179.35 455.84 60.609-47.91-0.004-0.0117c119.97-95.98 239.95-167.97 407.92-239.95l-120.96-302.41-0.47656-0.0273-21.637-55.258-0.89258-2.2305 0.0176-0.006zm170.86 428.64 191.96 443.91c112.73-48.314 271.77-103.22 403.12-122.47l0.7969-0.7793 91.461-9.9082-181.5-408.37-73.953 1.5723 0.024 0.0527c-155.9 12.01-311.89 48.01-431.86 96zm191.96 443.91c-140.47 70.235-280.93 149.62-410.26 255.96-22.925 18.287-46.909 36.51-67.477 52.494l213.79 543.38c106.09-141.46 281.67-288.69 452.78-401.9l-40.387-94.037 0.457-0.3204zm533.38-769.13c-48.416 14.131-98.297 26.178-149.45 37.271l122.38 453.51c62.388-11.998 112.78-26.395 187.16-55.189l-76.094-207.9-0.3125 0.23242zm160.09 435.59 146.37 405.52c142.16-65.251 229.99-125.98 316.27-208.56l-0.023-0.36719 61.557-62.297-141.23-356.8-71.734 55.709-0.8008-0.19922c-109.06 84.355-199.77 130.11-310.4 166.99zm146.37 405.52c-55.189 28.794-146.37 64.788-215.96 83.984l-0.1152-0.2597-5.2402 1.5449 41.934 101.34c64.01 29.859-6.8158 155.86-132.56 283.7 215.96-326.34-119.98-170.37-239.95-38.392 89.139 200.56 426.63 129.77 704.94-15.887l-25.26-66.021c-0.2045 0.1064-0.4109 0.2179-0.6153 0.3242l-13.688-37.705-3.164-8.2695 0.1035-0.1582z"
fill="#343434"
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

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

View File

@ -0,0 +1,10 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 411 511.71"
fill="currentColor"
class="h-4 w-4 opacity-70"
>
<path
d="M69.04 126.32h70.44L76.02 0h40.4l63.25 126.32h49.54L292.22.01h40.16l-62.87 126.31h72.46c37.9 0 69.03 31.13 69.03 69.03v247.33c0 37.9-31.13 69.03-69.03 69.03H69.04C31.07 511.71 0 480.64 0 442.68V195.35c0-37.96 31.08-69.03 69.04-69.03zm36.57 231.81L89.13 334.2c-.58-.79-.94-2.51-1.08-5.17h-.43v29.1H66.06v-67.37h20.27l16.48 23.94c.58.78.94 2.5 1.08 5.17h.43v-29.11h21.57v67.37h-20.28zm49.53 0H132.4l17.46-67.37h33.31l17.46 67.37h-22.75l-2.48-10.67h-17.77l-2.49 10.67zm9.53-46.68-4.42 18.87h12.42l-4.31-18.87h-3.69zm62.31 46.68h-22.53l4.1-67.37h28.13l8.41 34.28h.75l8.41-34.28h28.13l4.1 67.37h-22.52l-1.31-32.66h-.75l-8.19 32.66h-16.49l-8.3-32.66h-.64l-1.3 32.66zm113.11-25.44h-21.55v10.71h26.41v14.73h-47.97v-67.37h47.42l-2.68 14.74h-23.18v11.57h21.55v15.62zM154.5 437.39h102v17.27h-102v-17.27zm-53.72-44.49h209.43v17.27H100.78V392.9zm104.07-217.74 12.62-25.3H69.04c-25.03 0-45.5 20.47-45.5 45.49v247.33c0 24.96 20.53 45.49 45.5 45.49h272.93c24.97 0 45.49-20.53 45.49-45.49V195.35c0-25.02-20.47-45.49-45.49-45.49h-84.2l-20.65 41.39c5.41 7 8.62 15.77 8.62 25.29 0 22.86-18.53 41.39-41.39 41.39-22.85 0-41.39-18.53-41.39-41.39s18.54-41.39 41.39-41.39l.5.01z"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,27 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 488.7 488.7"
fill="currentColor"
class="h-4 w-4 opacity-70"
>
<path
d="M145.512,284.7c0,7-5.6,12.8-12.7,12.8c-3.5,0-6.7-1.4-9-3.7c-2.3-2.3-3.7-5.5-3.7-9c0-7,5.6-12.8,12.7-12.8
C139.712,272,145.512,277.7,145.512,284.7z M154.012,348.2c-5,5-4.9,13,0,18l0,0c5,4.9,13.1,4.9,18-0.1c5-5,4.9-13.1-0.1-18
C167.012,343.2,158.913,343.2,154.012,348.2z M235.313,194.5c7,0,12.8-5.7,12.8-12.8s-5.7-12.8-12.8-12.8s-12.8,5.7-12.8,12.8
c0,3.5,1.4,6.7,3.7,9C228.613,193.1,231.813,194.5,235.313,194.5z M153.512,221.2c5,4.9,13.1,4.9,18-0.1l0.1-0.1
c0.1-0.1,0.1-0.1,0.2-0.2c5-5,5-13,0-18s-13.1-5-18,0c-0.1,0.1-0.1,0.1-0.2,0.2c-0.1,0.1-0.1,0.1-0.2,0.2
C148.512,208.1,148.512,216.2,153.512,221.2L153.512,221.2z M235.613,374.3c-7.1,0-12.7,5.7-12.7,12.8c0,3.5,1.4,6.7,3.7,9
s5.5,3.7,9.1,3.7c7,0,12.7-5.7,12.7-12.8C248.413,380,242.712,374.3,235.613,374.3z M299.112,347.8c-5,5-5,13.1,0,18
c5,5,13.1,5,18,0c5-5,4.9-13.1,0-18.1C312.112,342.8,304.013,342.8,299.112,347.8z M338.013,271.5c-7.1,0-12.8,5.7-12.8,12.8
c0,3.5,1.4,6.7,3.7,9c2.3,2.3,5.5,3.8,9,3.7c7.1,0,12.7-5.7,12.8-12.8C350.813,277.2,345.112,271.5,338.013,271.5z M235.913,488.7
c-112.5,0-204.1-91.6-204.1-204.1c0-104.4,78.9-190.7,180.2-202.6V51.1h-12.7c-6.4,0-11.5-5.2-11.5-11.5V11.5
c0-6.4,5.2-11.5,11.5-11.5h73.2c6.4,0,11.5,5.2,11.5,11.5v28.1c0,6.4-5.2,11.5-11.5,11.5h-12.7v30.8
c38.5,4.5,73.7,19.8,102.6,42.7l16.6-16.6l-1.5-1.5c-4.5-4.5-4.5-11.8,0-16.3l22.8-22.8c4.5-4.5,11.8-4.5,16.3,0l36.9,36.9
c4.5,4.5,4.5,11.8,0,16.3l-22.8,22.8c-4.5,4.5-11.8,4.5-16.3,0l-1.5-1.5l-16.6,16.6c27.4,34.7,43.7,78.5,43.7,126
C440.013,397.1,348.413,488.7,235.913,488.7z M392.612,284.6c0-86.4-70.3-156.7-156.7-156.7s-156.7,70.3-156.7,156.7
s70.2,156.7,156.7,156.7C322.313,441.3,392.612,371,392.612,284.6z M317.913,201.8c4.7,4.7,5,12.3,0.7,17.3l-52,60.9
c1.3,9.5-1.6,19.4-8.9,26.7c-12.3,12.3-32.3,12.3-44.7,0c-12.3-12.3-12.3-32.3,0-44.7c7.3-7.3,17.2-10.2,26.7-8.9l60.9-52
C305.712,196.8,313.212,197.1,317.913,201.8L317.913,201.8z M244.413,275.4c-5-5-13.1-5-18,0c-5,5-5,13.1,0,18c5,5,13.1,5,18,0
C249.413,288.4,249.413,280.4,244.413,275.4z"
/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -8,8 +8,11 @@
export const AVATAR_WIDTH: number = 256; export const AVATAR_WIDTH: number = 256;
export const AVATAR_HEIGHT: number = 256; export const AVATAR_HEIGHT: number = 256;
export const TEAM_LOGO_WIDTH: number = 512; export const TEAM_BANNER_WIDTH: number = 512;
export const TEAM_LOGO_HEIGHT: number = 288; export const TEAM_BANNER_HEIGHT: number = 288;
export const TEAM_LOGO_WIDTH: number = 96;
export const TEAM_LOGO_HEIGHT: number = 96;
export const DRIVER_HEADSHOT_WIDTH: number = 512; export const DRIVER_HEADSHOT_WIDTH: number = 512;
export const DRIVER_HEADSHOT_HEIGHT: number = 512; export const DRIVER_HEADSHOT_HEIGHT: number = 512;
@ -18,14 +21,25 @@ export const RACE_PICTOGRAM_WIDTH: number = 512;
export const RACE_PICTOGRAM_HEIGHT: number = 384; export const RACE_PICTOGRAM_HEIGHT: number = 384;
// Card aspect ratios // Card aspect ratios
export const TEAM_CARD_ASPECT_WIDTH: number = 413; // export const TEAM_CARD_ASPECT_WIDTH: number = 413;
export const TEAM_CARD_ASPECT_HEIGHT: number = 438; // export const TEAM_CARD_ASPECT_HEIGHT: number = 438;
export const DRIVER_CARD_ASPECT_WIDTH: number = 411; // export const DRIVER_CARD_ASPECT_WIDTH: number = 411;
export const DRIVER_CARD_ASPECT_HEIGHT: number = 769; // export const DRIVER_CARD_ASPECT_HEIGHT: number = 769;
export const RACE_CARD_ASPECT_WIDTH: number = 497; // export const RACE_CARD_ASPECT_WIDTH: number = 497;
export const RACE_CARD_ASPECT_HEIGHT: number = 879; // export const RACE_CARD_ASPECT_HEIGHT: number = 879;
export const SUBSTITUTION_CARD_ASPECT_WIDTH: number = 413; // export const SUBSTITUTION_CARD_ASPECT_WIDTH: number = 413;
export const SUBSTITUTION_CARD_ASPECT_HEIGHT: number = 625; // export const SUBSTITUTION_CARD_ASPECT_HEIGHT: number = 625;
// Define the background colors the picks will have depending on the raceresult
export const PXX_COLORS: string[] = [];
PXX_COLORS[-1] = "auto";
PXX_COLORS[0] = "#C2FBCC"; // 1 Point
PXX_COLORS[6] = "#C2FBCC";
PXX_COLORS[1] = "#6CDB7E"; // 3 Points
PXX_COLORS[5] = "#6CDB7E";
PXX_COLORS[2] = "#07B725"; // 6 Points
PXX_COLORS[4] = "#07B725";
PXX_COLORS[3] = "#EFBF04"; // 10 Points

View File

@ -1,3 +1,5 @@
import type { Graphic } from "$lib/schema";
/** /**
* Select an element from an [objects] array where [key] matches [value]. * Select an element from an [objects] array where [key] matches [value].
* Supposed to be used on collections returned by the [PocketBase] client. * Supposed to be used on collections returned by the [PocketBase] client.
@ -9,3 +11,15 @@ export const get_by_value = <T extends object>(
): T | undefined => { ): T | undefined => {
return objects.find((o: T) => (key in o ? o[key] === value : false)); return objects.find((o: T) => (key in o ? o[key] === value : false));
}; };
export const get_team_banner_template = (graphics: Graphic[]) =>
get_by_value(graphics, "name", "team_banner_template")?.file_url ?? "Invalid";
export const get_team_logo_template = (graphics: Graphic[]) =>
get_by_value(graphics, "name", "team_logo_template")?.file_url ?? "Invalid";
export const get_driver_headshot_template = (graphics: Graphic[]) =>
get_by_value(graphics, "name", "driver_headshot_template")?.file_url ?? "Invalid";
export const get_race_pictogram_template = (graphics: Graphic[]) =>
get_by_value(graphics, "name", "race_pictogram_template")?.file_url ?? "Invalid";

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);
};

58
src/lib/dropdown.ts Normal file
View File

@ -0,0 +1,58 @@
import type { DropdownOption } from "$lib/components";
import type { Driver, Race, Team } from "$lib/schema";
import {
DRIVER_HEADSHOT_HEIGHT,
DRIVER_HEADSHOT_WIDTH,
RACE_PICTOGRAM_HEIGHT,
RACE_PICTOGRAM_WIDTH,
TEAM_BANNER_HEIGHT,
TEAM_BANNER_WIDTH,
} from "$lib/config";
/**
* Generates a list of [DropdownOptions] for a <Dropdown> component.
*/
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,
icon_url: team.banner_url,
icon_width: TEAM_BANNER_WIDTH,
icon_height: TEAM_BANNER_HEIGHT,
};
});
/**
* Generates a list of [DropdownOptions] for a <Dropdown> component.
*/
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.firstname} ${driver.lastname}`,
value: driver.id,
icon_url: driver.headshot_url,
icon_width: DRIVER_HEADSHOT_WIDTH,
icon_height: DRIVER_HEADSHOT_HEIGHT,
};
});
/**
* Generates a list of [DropdownOptions] for a <Dropdown> component.
*/
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,
icon_url: race.pictogram_url,
icon_width: RACE_PICTOGRAM_WIDTH,
icon_height: RACE_PICTOGRAM_HEIGHT,
};
});

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,70 +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;
};
/**
* 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

@ -1,3 +1,8 @@
// NOTE: The "expand" fields might be undefined.
// I'm not using "expand?" because I won't check for undefined anyways.
// Application Data
export interface Graphic { export interface Graphic {
name: string; name: string;
file: string; file: string;
@ -6,28 +11,37 @@ export interface Graphic {
export interface User { export interface User {
id: string; id: string;
verified: boolean;
username: string; username: string;
avatar: string; firstname: string;
email?: string;
avatar?: string;
avatar_url?: string; avatar_url?: string;
admin: boolean; admin: boolean;
} }
// Season Data
export interface Team { export interface Team {
id: string; id: string;
name: string; name: string;
banner: string;
banner_url?: string;
logo: string; logo: string;
logo_url?: string; logo_url?: string;
color: string;
} }
export interface Driver { export interface Driver {
id: string; id: string;
code: string;
firstname: string; firstname: string;
lastname: string; lastname: string;
code: string;
headshot: string; headshot: string;
headshot_url?: string; headshot_url?: string;
team: string; team: string;
active: boolean; active: boolean;
started_active: boolean;
} }
export interface Race { export interface Race {
@ -48,4 +62,137 @@ export interface Substitution {
substitute: string; substitute: string;
for: string; for: string;
race: string; race: string;
expand: {
race: Race;
};
}
// User Data
export interface RacePick {
id: string;
user: string;
race: string;
pxx?: string;
dnf?: string;
expand: {
user: User;
};
}
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;
pxxs: string[];
dnfs: string[];
}
export interface CurrentPickedUser {
id: string;
username: string;
firstname: string;
avatar: string;
avatar_url?: string;
admin: 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 ( export const image_to_avif = async (
data: ArrayBuffer, data: ArrayBuffer,
width: number | undefined = undefined, width?: number,
height: number | undefined = undefined, height?: number,
quality: number = 50, quality: number = 50,
effort: number = 4, effort: number = 4,
): Promise<Blob> => { ): 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) const compressed: Buffer = await sharp(data)
.resize(width, height) .resize(width, height)
.avif({ quality: quality, effort: effort }) .avif({ quality: quality, effort: effort })
.toBuffer(); .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]); 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,19 +0,0 @@
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 }) => {
if (locals.user) {
return {
user: locals.user,
admin: locals.user.admin,
};
}
return {
user: undefined,
};
};

View File

@ -1,10 +1,8 @@
<script lang="ts"> <script lang="ts">
import "../app.css"; import "../app.css";
import { onDestroy, onMount, type Snippet } from "svelte";
import type { Snippet } from "svelte";
import type { LayoutData } from "./$types"; import type { LayoutData } from "./$types";
import { page } from "$app/stores"; import { page } from "$app/state";
import { import {
Button, Button,
MenuDrawerIcon, MenuDrawerIcon,
@ -12,26 +10,69 @@
Input, Input,
PasswordIcon, PasswordIcon,
LoadingIndicator, LoadingIndicator,
DriverCard,
TeamCard,
RaceCard,
SubstitutionCard,
NameIcon,
RacePickCard,
RaceResultCard,
SeasonPickCard,
EMailIcon,
TeamSwitchCard,
} from "$lib/components"; } from "$lib/components";
import { get_avatar_preview_event_handler } from "$lib/image"; import { get_avatar_preview_event_handler } from "$lib/image";
import { import {
AppBar, AppBar,
storePopup, storePopup,
initializeStores, initializeStores,
Drawer, Drawer,
getDrawerStore, getDrawerStore,
Modal,
Toast,
getModalStore,
type DrawerSettings, type DrawerSettings,
Avatar, Avatar,
FileDropzone, FileDropzone,
type DrawerStore, type DrawerStore,
type ModalStore,
type ModalComponent,
type ToastStore,
getToastStore,
SlideToggle,
} from "@skeletonlabs/skeleton"; } from "@skeletonlabs/skeleton";
import { computePosition, autoUpdate, offset, shift, flip, arrow } from "@floating-ui/dom"; 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(); let { data, children }: { data: LayoutData; children: Snippet } = $props();
// Drawer config // Init skeleton stores for drawer + modal
initializeStores(); initializeStores();
// Modal config
const modalStore: ModalStore = getModalStore();
const modalRegistry: Record<string, ModalComponent> = {
// Card data (e.g. team, driver etc.) is passed using $modalStore[0].meta
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(); const drawerStore: DrawerStore = getDrawerStore();
let drawerOpen: boolean = false; let drawerOpen: boolean = false;
let drawerId: string = ""; let drawerId: string = "";
@ -60,7 +101,7 @@
const drawer_settings_base: DrawerSettings = { const drawer_settings_base: DrawerSettings = {
position: "top", position: "top",
height: "auto", 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", bgDrawer: "bg-surface-100",
duration: 150, duration: 150,
}; };
@ -100,123 +141,434 @@
// Popups config // Popups config
storePopup.set({ computePosition, autoUpdate, offset, shift, flip, arrow }); storePopup.set({ computePosition, autoUpdate, offset, shift, flip, arrow });
// Example: https://www.skeleton.dev/utilities/popups // Reactive state
// const data_popup_settings: PopupSettings = { let username_value: string = $state($pbUser?.username ?? "");
// event: "click", let firstname_value: string = $state($pbUser?.firstname ?? "");
// target: "data_popup", let email_value: string = $state($pbUser?.email ?? "");
// placement: "bottom", let password_value: string = $state("");
// middleware: { let avatar_value: FileList | undefined = $state();
// offset: { mainAxis: 22, crossAxis: 0 },
// // shift: { mainAxis: true, crossAxis: false }, 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> </script>
<LoadingIndicator /> <LoadingIndicator />
<Drawer> <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 --> <!-- Use p-3 because the drawer has a 5px overlap with the navbar -->
{#if $drawerStore.id === "menu_drawer"} {#if $drawerStore.id === "menu_drawer"}
<!-- Menu Drawer --> <!-- Menu Drawer -->
<!-- Menu Drawer --> <!-- Menu Drawer -->
<!-- Menu Drawer --> <!-- Menu Drawer -->
<div class="flex flex-col gap-2 p-3"> <div class="flex flex-col gap-2 p-2 pt-3">
<Button href="/racepicks" onclick={close_drawer} color="surface" fullwidth>Race Picks</Button> <Button href="/racepicks" onclick={close_drawer} color="surface" width="w-full" shadow>
<Button href="/seasonpicks" onclick={close_drawer} color="surface" fullwidth Race Picks
>Season Picks
</Button> </Button>
<Button href="/leaderboard" onclick={close_drawer} color="surface" fullwidth <Button href="/seasonpicks" onclick={close_drawer} color="surface" width="w-full" shadow>
>Leaderboard Season Picks
</Button> </Button>
<Button href="/statistics" onclick={close_drawer} color="surface" fullwidth <Button href="/leaderboard" onclick={close_drawer} color="surface" width="w-full" shadow>
>Statistics Leaderboard
</Button>
<Button href="/statistics" onclick={close_drawer} color="surface" width="w-full" shadow>
Statistics
</Button>
<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> </Button>
<Button href="/rules" onclick={close_drawer} color="surface" fullwidth>Rules</Button>
</div> </div>
{:else if $drawerStore.id === "data_drawer"} {:else if $drawerStore.id === "data_drawer"}
<!-- Data Drawer --> <!-- Data Drawer -->
<!-- Data Drawer --> <!-- Data Drawer -->
<!-- Data Drawer --> <!-- Data Drawer -->
<div class="flex flex-col gap-2 p-3"> <div class="flex flex-col gap-2 p-2 pt-3">
<Button href="/data/raceresult" onclick={close_drawer} color="surface" fullwidth <Button href="/data/raceresults" onclick={close_drawer} color="surface" width="w-full" shadow>
>Race Results Race Results
</Button>
<Button
href="/data/season/teams"
onclick={close_drawer}
color="surface"
width="w-full"
shadow
>
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> </Button>
<Button href="/data/season" onclick={close_drawer} color="surface" fullwidth>Season</Button>
<Button href="/data/user" onclick={close_drawer} color="surface" fullwidth>Users</Button>
</div> </div>
{:else if $drawerStore.id === "login_drawer"} {:else if $drawerStore.id === "login_drawer"}
<!-- Login Drawer --> <!-- Login Drawer -->
<!-- Login Drawer --> <!-- Login Drawer -->
<!-- Login Drawer --> <!-- Login Drawer -->
<div class="flex flex-col gap-2 p-3"> <div class="flex flex-col gap-2 p-2 pt-3">
<h4 class="h4 select-none">Enter Username and Password</h4> <div class="flex">
<form method="POST" class="contents"> <h4 class="h4 select-none text-nowrap align-middle font-bold" style="line-height: 32px;">
<!-- Supply the pathname so the form can redirect to the current page. --> Login or Register
<input type="hidden" name="redirect_url" value={$page.url.pathname} /> </h4>
<Input name="username" placeholder="Username" autocomplete="username" required <div class="w-full"></div>
><UserIcon /> <div class="flex gap-2">
</Input> <span class="align-middle" style="line-height: 32px;">Login</span>
<Input name="password" type="password" placeholder="Password" autocomplete="off" required <SlideToggle
><PasswordIcon /> name="registrationmode"
</Input> background="bg-tertiary-500"
<div class="flex justify-end gap-2"> active="bg-tertiary-500"
<Button formaction="/profile?/login" onclick={close_drawer} color="tertiary" submit bind:checked={registration_mode}
>Login />
</Button> <span class="align-middle" style="line-height: 32px;">Register</span>
<Button
formaction="/profile?/create_profile"
onclick={close_drawer}
color="tertiary"
submit
>Register
</Button>
</div> </div>
</form> </div>
</div> <Input
{:else if $drawerStore.id === "profile_drawer" && data.user} bind:value={username_value}
<!-- Profile Drawer --> placeholder="Username"
<!-- Profile Drawer --> autocomplete="username"
<!-- Profile Drawer --> minlength={3}
<div class="flex flex-col gap-2 p-3"> maxlength={10}
<h4 class="h4 select-none">Edit Profile</h4> required
<form method="POST" enctype="multipart/form-data" class="contents"> onkeypress={enter_handler}
<!-- Supply the pathname so the form can redirect to the current page. --> >
<input type="hidden" name="redirect_url" value={$page.url.pathname} /> <UserIcon />
<input type="hidden" name="id" value={data.user.id} /> </Input>
<div
class="{registration_mode
? ''
: 'mt-[-8px] h-0'} overflow-hidden transition-all duration-150 ease-out"
>
<Input <Input
name="username" bind:value={firstname_value}
value={data.user.username} placeholder="First Name"
placeholder="Username" autocomplete="off"
autocomplete="username" tabindex={registration_mode ? 0 : -1}
onkeypress={enter_handler}
> >
<UserIcon /> <NameIcon />
</Input> </Input>
<FileDropzone </div>
name="avatar" <div
onchange={get_avatar_preview_event_handler("user_avatar_preview")} 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}
> >
<svelte:fragment slot="message"><b>Upload Avatar</b> or Drag and Drop</svelte:fragment> <EMailIcon />
</FileDropzone> </Input>
<div class="flex justify-end gap-2"> </div>
<Button <Input
formaction="/profile?/update_profile" id="login_password"
onclick={close_drawer} bind:value={password_value}
color="secondary" type="password"
submit placeholder="Password"
> autocomplete="off"
Save Changes required
</Button> onkeypress={enter_handler}
<Button formaction="/profile?/logout" onclick={close_drawer} color="primary" submit> >
Logout <PasswordIcon />
</Button> </Input>
</div> <div
</form> 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" && $pbUser}
<!-- Profile Drawer -->
<!-- Profile Drawer -->
<!-- Profile Drawer -->
<div class="flex flex-col gap-2 p-2 pt-3">
<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> </div>
{/if} {/if}
</Drawer> </Drawer>
<nav> <nav>
<div class="fixed left-0 right-0 top-0 z-50"> <div class="fixed left-0 right-0 top-0 z-40">
<AppBar <AppBar
slotDefault="place-self-center" slotDefault="place-self-center"
slotTrail="place-content-end" slotTrail="place-content-end"
@ -234,18 +586,27 @@
</div> </div>
<!-- Site logo --> <!-- Site logo -->
<Button href="/" color="primary"><span class="text-xl font-bold">Formula 11</span></Button <Button href="/racepicks" color="primary">
> <span class="text-xl font-bold">Formula 11</span>
</Button>
</div> </div>
</svelte:fragment> </svelte:fragment>
<!-- Large navigation --> <!-- Large navigation -->
<div class="hidden gap-2 lg:flex"> <div class="hidden gap-2 pr-8 lg:flex">
<Button href="/racepicks" color="primary" activate_href>Race Picks</Button> <Button href="/racepicks" color="primary" activate_href>Race Picks</Button>
<Button href="/seasonpicks" color="primary" activate_href>Season Picks</Button> <Button href="/seasonpicks" color="primary" activate_href>Season Picks</Button>
<Button href="/leaderboard" color="primary" activate_href>Leaderboard</Button> <Button href="/leaderboard" color="primary" activate_href>Leaderboard</Button>
<Button href="/statistics" color="primary" activate_href>Statistics</Button> <Button href="/statistics" color="primary" activate_href>Statistics</Button>
<Button href="/rules" color="primary" activate_href>Rules</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> </div>
<svelte:fragment slot="trail"> <svelte:fragment slot="trail">
@ -254,17 +615,17 @@
<Button <Button
color="primary" color="primary"
onclick={data_drawer} 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 --> <!-- Login drawer -->
<Button color="primary" onclick={login_drawer}>Login</Button> <Button color="primary" onclick={login_drawer}>Login</Button>
{:else} {:else}
<!-- Profile drawer --> <!-- Profile drawer -->
<Avatar <Avatar
id="user_avatar_preview" id="user_avatar_preview"
src={data.user.avatar_url} src={$pbUser?.avatar_url}
rounded="rounded-full" rounded="rounded-full"
width="w-10" width="w-10"
background="bg-primary-50" 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

@ -1,5 +0,0 @@
<svelte:head>
<title>F11 - Formula 11</title>
</svelte:head>
<h1>Formula 11</h1>

5
src/routes/+page.ts Normal file
View File

@ -0,0 +1,5 @@
import { redirect } from "@sveltejs/kit";
export function load() {
redirect(302, "/racepicks");
}

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

@ -0,0 +1,106 @@
<script lang="ts">
import { getModalStore, type ModalSettings, type ModalStore } from "@skeletonlabs/skeleton";
import type { PageData } from "./$types";
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 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",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${get_by_value(await data.races, "id", value)?.step}</span>`,
},
{
data_value_name: "race",
label: "Race",
valuefun: async (value: string): Promise<string> =>
`<span>${get_by_value(await data.races, "id", value)?.name}</span>`,
},
{
data_value_name: "race",
label: "Guessed",
valuefun: async (value: string): Promise<string> =>
`<span>P${get_by_value(await data.races, "id", value)?.pxx}</span>`,
},
{
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(
async (id: string, index: number) =>
`<span class='w-10 badge mr-2 text-center' style='background: ${PXX_COLORS[index]};'>${get_by_value(await data.drivers, "id", id)?.code ?? "Invalid"}</span>`,
),
);
return pxxs_codes.join("");
},
},
{
data_value_name: "dnfs",
label: "DNFs",
valuefun: async (value: string): Promise<string> => {
if (value.length === 0 || value === "") return "";
const dnfs_array: string[] = value.toString().split(",");
const dnfs_codes: string[] = await Promise.all(
dnfs_array.map(
async (id: string) =>
`<span class='w-10 text-center badge mr-2' style='background: ${PXX_COLORS[3]}'>${get_by_value(await data.drivers, "id", id)?.code ?? "Invalid"}</span>`,
),
);
return dnfs_codes.join("");
},
},
]);
</script>
<svelte:head>
<title>Formula 11 - Race Results</title>
</svelte:head>
<div class="pb-2">
<Button width="w-full" color="tertiary" onclick={result_handler} shadow>
<span class="font-bold">Create Race Result</span>
</Button>
</div>
{#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

@ -0,0 +1,22 @@
<script lang="ts">
import { Button } from "$lib/components";
import type { Snippet } from "svelte";
let { children }: { children: Snippet } = $props();
</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="teams" color="primary" activate_href>Teams</Button>
<Button href="drivers" color="primary" activate_href>Drivers</Button>
<Button href="races" color="primary" activate_href>Races</Button>
<Button href="substitutions" color="primary" activate_href>Substitutions</Button>
</div>
</div>
<!-- Each child's contents will be inserted here -->
<div style="margin-top: 56px;">
{@render children()}
</div>

View File

@ -1,298 +0,0 @@
import type { ActionData, Actions, PageServerLoad } from "./$types";
import {
form_data_clean,
form_data_ensure_keys,
form_data_fix_dates,
form_data_get_and_remove_id,
} from "$lib/form";
import type { Team, Driver, Race, Substitution, Graphic } from "$lib/schema";
import { image_to_avif } from "$lib/server/image";
import {
DRIVER_HEADSHOT_HEIGHT,
DRIVER_HEADSHOT_WIDTH,
RACE_PICTOGRAM_HEIGHT,
RACE_PICTOGRAM_WIDTH,
TEAM_LOGO_HEIGHT,
TEAM_LOGO_WIDTH,
} from "$lib/config";
// These "actions" run serverside only, as they're located inside +page.server.ts
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", "logo"]);
// Compress logo
const compressed: Blob = await image_to_avif(
await (data.get("logo") as File).arrayBuffer(),
TEAM_LOGO_WIDTH,
TEAM_LOGO_HEIGHT,
);
data.set("logo", compressed);
await locals.pb.collection("teams").create(data);
return { tab: 0 };
},
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("logo")) {
// Compress logo
const compressed: Blob = await image_to_avif(
await (data.get("logo") as File).arrayBuffer(),
TEAM_LOGO_WIDTH,
TEAM_LOGO_HEIGHT,
);
data.set("logo", compressed);
}
await locals.pb.collection("teams").update(id, data);
return { tab: 0 };
},
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);
return { tab: 0 };
},
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 compressed: Blob = await image_to_avif(
await (data.get("headshot") as File).arrayBuffer(),
DRIVER_HEADSHOT_WIDTH,
DRIVER_HEADSHOT_HEIGHT,
);
data.set("headshot", compressed);
await locals.pb.collection("drivers").create(data);
return { tab: 1 };
},
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 compressed: Blob = await image_to_avif(
await (data.get("headshot") as File).arrayBuffer(),
DRIVER_HEADSHOT_WIDTH,
DRIVER_HEADSHOT_HEIGHT,
);
data.set("headshot", compressed);
}
await locals.pb.collection("drivers").update(id, data);
return { tab: 1 };
},
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);
return { tab: 1 };
},
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 compressed: Blob = await image_to_avif(
await (data.get("pictogram") as File).arrayBuffer(),
RACE_PICTOGRAM_WIDTH,
RACE_PICTOGRAM_HEIGHT,
);
data.set("pictogram", compressed);
await locals.pb.collection("races").create(data);
return { tab: 2 };
},
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 compressed: Blob = await image_to_avif(
await (data.get("pictogram") as File).arrayBuffer(),
RACE_PICTOGRAM_WIDTH,
RACE_PICTOGRAM_HEIGHT,
);
data.set("pictogram", compressed);
}
await locals.pb.collection("races").update(id, data);
return { tab: 2 };
},
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);
return { tab: 2 };
},
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);
return { tab: 3 };
},
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);
return { tab: 3 };
},
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);
return { tab: 3 };
},
} satisfies Actions;
// This "load" function runs serverside only, as it's located inside +page.server.ts
export const load: PageServerLoad = async ({ fetch, 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.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: "+lastname",
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[]> => {
// TODO: Sort by race step (does the race need to be expanded for this?)
const substitutions: Substitution[] = await locals.pb.collection("substitutions").getFullList({
fetch: fetch,
});
return substitutions;
};
return {
// Graphics and teams are awaited, since those are visible on page load.
graphics: await fetch_graphics(),
teams: await fetch_teams(),
// The rest is streamed gradually, since the user has to switch tabs to need them.
drivers: fetch_drivers(),
races: fetch_races(),
substitutions: fetch_substitutions(),
};
};

View File

@ -1,219 +0,0 @@
<script lang="ts">
import type { Driver, Race, Substitution, Team } from "$lib/schema";
import { type PageData, type ActionData } from "./$types";
import { Tab, TabGroup } from "@skeletonlabs/skeleton";
// TODO: Why does this work but import { type DropdownOption } from "$lib/components" does not?
import type { LazyDropdownOption } from "$lib/components/LazyDropdown.svelte";
import { TeamCard, DriverCard, RaceCard, SubstitutionCard } from "$lib/components";
import { get_by_value } from "$lib/database";
import {
DRIVER_HEADSHOT_HEIGHT,
DRIVER_HEADSHOT_WIDTH,
RACE_PICTOGRAM_HEIGHT,
RACE_PICTOGRAM_WIDTH,
TEAM_LOGO_HEIGHT,
TEAM_LOGO_WIDTH,
} from "$lib/config";
let { data, form }: { data: PageData; form: ActionData } = $props();
let current_tab: number = $state(0);
if (form?.tab) {
// console.log(`Form returned current_tab=${form.current_tab}`);
current_tab = form.tab;
}
// Values for driver cards
let update_driver_team_select_values: { [key: string]: string } = $state({}); // <driver.id, team.id>
let 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;
// Values for substitution cards
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"] = "";
// All options to create a <Dropdown> component for the teams
const team_dropdown_options: LazyDropdownOption[] = [];
data.teams.forEach((team: Team) => {
team_dropdown_options.push({
label: team.name,
value: team.id,
icon_url: team.logo_url,
icon_width: TEAM_LOGO_WIDTH,
icon_height: TEAM_LOGO_HEIGHT,
});
});
// All options to create a <Dropdown> component for the drivers
const driver_dropdown_options: LazyDropdownOption[] = [];
data.drivers.then((drivers: Driver[]) =>
drivers.forEach((driver: Driver) => {
driver_dropdown_options.push({
label: driver.code,
value: driver.id,
icon_url: driver.headshot_url,
icon_width: DRIVER_HEADSHOT_WIDTH,
icon_height: DRIVER_HEADSHOT_HEIGHT,
});
}),
);
// All options to create a <Dropdown> component for the races
const race_dropdown_options: LazyDropdownOption[] = [];
data.races.then((races: Race[]) =>
races.forEach((race: Race) => {
race_dropdown_options.push({
label: race.name,
value: race.id,
icon_url: race.pictogram_url,
icon_width: RACE_PICTOGRAM_WIDTH,
icon_height: RACE_PICTOGRAM_HEIGHT,
});
}),
);
</script>
<svelte:head>
<title>F11 - Season Data</title>
</svelte:head>
<TabGroup
justify="justify-center"
active="variant-filled-primary"
hover="hover:variant-filled-primary"
regionList="gap-2 shadow rounded-bl-container-token rounded-br-container-token p-2 pt-3 bg-white fixed left-2 right-2 top-14 z-10"
regionPanel="!mt-14"
rounded="rounded-container-token"
border="border-none"
padding="p-2"
>
<Tab bind:group={current_tab} name="teams" value={0}>Teams</Tab>
<Tab bind:group={current_tab} name="drivers" value={1}>Drivers</Tab>
<Tab bind:group={current_tab} name="races" value={2}>Races</Tab>
<Tab bind:group={current_tab} name="substitutions" value={3}>Substitutions</Tab>
<svelte:fragment slot="panel">
{#if current_tab === 0}
<!-- Teams Tab -->
<!-- Teams Tab -->
<!-- Teams Tab -->
<div class="mt-2 grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6">
<!-- Add a new team -->
{#if data.admin}
<TeamCard
logo_template={get_by_value(data.graphics, "name", "team_template")?.file_url}
require_inputs
/>
{/if}
<!-- List all teams inside the database -->
{#each data.teams as team}
<TeamCard {team} disable_inputs={!data.admin} />
{/each}
</div>
{:else if current_tab === 1}
<!-- Drivers Tab -->
<!-- Drivers Tab -->
<!-- Drivers Tab -->
<div class="mt-2 grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6">
<!-- Add a new driver -->
{#if data.admin}
<DriverCard
headshot_template={get_by_value(data.graphics, "name", "driver_template")?.file_url}
team_select_value={update_driver_team_select_values["create"]}
team_select_options={team_dropdown_options}
active_value={update_driver_active_values["create"]}
require_inputs
/>
{/if}
<!-- List all drivers inside the database -->
{#await data.drivers then drivers}
{#each drivers as driver}
<DriverCard
{driver}
disable_inputs={!data.admin}
team_select_value={update_driver_team_select_values[driver.id]}
team_select_options={team_dropdown_options}
active_value={update_driver_active_values[driver.id]}
/>
{/each}
{/await}
</div>
{:else if current_tab === 2}
<!-- Races Tab -->
<!-- Races Tab -->
<!-- Races Tab -->
<div class="mt-2 grid grid-cols-1 gap-2 md:grid-cols-3 2xl:grid-cols-5">
{#if data.admin}
<RaceCard
pictogram_template={get_by_value(data.graphics, "name", "race_template")?.file_url}
require_inputs
/>
{/if}
{#await data.races then races}
{#each races as race}
<RaceCard {race} disable_inputs={!data.admin} />
{/each}
{/await}
</div>
{:else if current_tab === 3}
<!-- Substitutions Tab -->
<!-- Substitutions Tab -->
<!-- Substitutions Tab -->
<div class="mt-2 grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6">
{#await data.drivers then drivers}
{#if data.admin}
<SubstitutionCard
{drivers}
substitute_select_value={update_substitution_substitute_select_values["create"]}
driver_select_value={update_substitution_for_select_values["create"]}
race_select_value={update_substitution_race_select_values["create"]}
driver_select_options={driver_dropdown_options}
race_select_options={race_dropdown_options}
headshot_template={get_by_value(data.graphics, "name", "driver_template")?.file_url}
require_inputs
/>
{/if}
{#await data.substitutions then substitutions}
{#each substitutions as substitution}
<SubstitutionCard
{substitution}
{drivers}
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]}
driver_select_options={driver_dropdown_options}
race_select_options={race_dropdown_options}
disable_inputs={!data.admin}
/>
{/each}
{/await}
{/await}
</div>
{/if}
</svelte:fragment>
</TabGroup>

View File

@ -0,0 +1,90 @@
<script lang="ts">
import { Button, type TableColumn, Table } from "$lib/components";
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 modalStore: ModalStore = getModalStore();
const driver_handler = async (event: Event, id?: string) => {
const driver: Driver | undefined = get_by_value(await data.drivers, "id", id ?? "Invalid");
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",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
{ data_value_name: "firstname", label: "First Name" },
{ data_value_name: "lastname", label: "Last Name" },
{
data_value_name: "team",
label: "Team",
valuefun: async (value: string): Promise<string> => {
const team: Team | undefined = get_by_value(await data.teams, "id", value);
return `<span class='badge mr-2' style='color: ${team?.color ?? "#FFFFFF"}; background: ${team?.color ?? "#FFFFFF"};'>C</span>${team?.name ?? "Invalid"}`;
},
},
{
data_value_name: "active",
label: "Active",
valuefun: async (value: boolean): Promise<string> =>
`<span class='badge variant-filled-${value ? "tertiary" : "primary"} text-center' style='width: 36px;'>${value ? "Yes" : "No"}</span>`,
},
{
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>
<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={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

@ -0,0 +1,78 @@
<script lang="ts">
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 } from "$lib/database";
import type { Race } from "$lib/schema";
import { format_date, shortdatetimeformat } from "$lib/date";
let { data }: { data: PageData } = $props();
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",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
{ data_value_name: "step", label: "Step" },
{
data_value_name: "sprintqualidate",
label: "Sprint Quali",
valuefun: async (value: string): Promise<string> => format_date(value, shortdatetimeformat),
},
{
data_value_name: "sprintdate",
label: "Sprint Race",
valuefun: async (value: string): Promise<string> => format_date(value, shortdatetimeformat),
},
{
data_value_name: "qualidate",
label: "Quali",
valuefun: async (value: string): Promise<string> => format_date(value, shortdatetimeformat),
},
{
data_value_name: "racedate",
label: "Race",
valuefun: async (value: string): Promise<string> => format_date(value, shortdatetimeformat),
},
]);
</script>
<svelte:head>
<title>Formula 11 - Races</title>
</svelte:head>
<div class="pb-2">
<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={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

@ -0,0 +1,78 @@
<script lang="ts">
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";
import { Button, Table, type TableColumn } from "$lib/components";
let { data }: { data: PageData } = $props();
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",
);
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",
valuefun: async (value: { race: Race }): Promise<string> =>
`<span class='badge variant-filled-surface'>${value.race.step.toString()}</span>`,
},
{
data_value_name: "substitute",
label: "Substitute",
valuefun: async (value: string): Promise<string> => {
const substitute = get_by_value(await data.drivers, "id", value)?.code ?? "Invalid";
return `<span class='badge variant-filled-surface'>${substitute}</span>`;
},
},
{
data_value_name: "for",
label: "For",
valuefun: async (value: string): Promise<string> =>
get_by_value(await data.drivers, "id", value)?.code ?? "Invalid",
},
{
data_value_name: "race",
label: "Race",
valuefun: async (value: string): Promise<string> =>
get_by_value(await data.races, "id", value)?.name ?? "Invalid",
},
]);
</script>
<svelte:head>
<title>Formula 11 - Substitutions</title>
</svelte:head>
<div class="pb-2">
<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={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

@ -0,0 +1,61 @@
<script lang="ts">
import { Button, Table, type TableColumn } from "$lib/components";
import type { Team } from "$lib/schema";
import { getModalStore, type ModalSettings, type ModalStore } from "@skeletonlabs/skeleton";
import type { PageData } from "./$types";
import { get_by_value } from "$lib/database";
let { data }: { data: PageData } = $props();
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",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
{
data_value_name: "color",
label: "Color",
valuefun: async (value: string): Promise<string> =>
`<span class='badge mr-2' style='color: ${value}; background: ${value};'>C</span>`,
},
]);
</script>
<svelte:head>
<title>Formula 11 - Teams</title>
</svelte:head>
<div class="pb-2">
<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={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 +0,0 @@
<h1>User Data</h1>

View File

@ -0,0 +1,51 @@
<script lang="ts">
import { Table, type TableColumn } from "$lib/components";
import { getModalStore, type ModalStore } from "@skeletonlabs/skeleton";
import type { PageData } from "./$types";
import { get_by_value } from "$lib/database";
let { data }: { data: PageData } = $props();
const users_columns: TableColumn[] = [
// 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",
},
{
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(data.graphics, "name", "driver_headshot_template")?.file_url}'/>`,
},
{
data_value_name: "admin",
label: "Admin",
valuefun: async (value: boolean): Promise<string> =>
`<span class='badge variant-filled-${value ? "tertiary" : "primary"} text-center'>${value ? "Yes" : "No"}</span>`,
},
];
const modalStore: ModalStore = getModalStore();
const users_handler = async (event: Event, id: string) => {
// Should an admin be able to do anything here?
// Or can users only change themselves?
};
</script>
<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,87 +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", "password", "redirect_url"]);
// Confirm password lol
await locals.pb.collection("users").create({
username: data.get("username")?.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());
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 +1,388 @@
<h1>Race Picks</h1> <script lang="ts">
import { ChequeredFlagIcon, Countdown, LazyImage, StopwatchIcon } from "$lib/components";
import {
Accordion,
AccordionItem,
getModalStore,
popup,
type ModalSettings,
type ModalStore,
type PopupSettings,
} from "@skeletonlabs/skeleton";
import type { PageData } from "./$types";
import {
AVATAR_HEIGHT,
AVATAR_WIDTH,
DRIVER_HEADSHOT_HEIGHT,
DRIVER_HEADSHOT_WIDTH,
PXX_COLORS,
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 { pbUser } from "$lib/pocketbase";
let { data }: { data: PageData } = $props();
const modalStore: ModalStore = getModalStore();
const racepick_handler = async (event: Event) => {
const modalSettings: ModalSettings = {
type: "component",
component: "racePickCard",
meta: {
data,
racepick: data.racepick,
},
};
modalStore.trigger(modalSettings);
};
// Users that have already picked the current race
let pickedusers: Promise<CurrentPickedUser[]> = $derived.by(async () =>
(await data.currentpickedusers).filter(
(currentpickeduser: CurrentPickedUser) => currentpickeduser.picked,
),
);
// 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-start",
};
};
</script>
<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">
{data.currentrace.name}
</span>
{#if data.currentrace.sprintdate}
<div class="flex gap-2">
<span class="w-12">SQuali:</span>
<span>{format_date(data.currentrace.sprintqualidate, shortdatetimeformat)}</span>
</div>
<div class="flex gap-2">
<span class="w-12">SRace:</span>
<span>{format_date(data.currentrace.sprintdate, shortdatetimeformat)}</span>
</div>
{/if}
<div class="flex gap-2">
<span class="w-12">Quali:</span>
<span>{format_date(data.currentrace.qualidate, shortdatetimeformat)}</span>
</div>
<div class="flex gap-2">
<span class="w-12">Race:</span>
<span>{format_date(data.currentrace.racedate, shortdatetimeformat)}</span>
</div>
<div class="m-auto flex">
<div class="mr-1 mt-1">
<StopwatchIcon />
</div>
<Countdown date={data.currentrace.racedate} extraclass="font-bold" />
</div>
</div>
<!-- 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: 105px; margin: auto;"
imgstyle="background: transparent;"
/>
</div>
<!-- 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>
<!-- 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">
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}
<!-- The fookin table -->
<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="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 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
class="hidden h-full w-full text-center align-middle text-sm lg:block"
style="background: {PXX_COLORS[3]}; line-height: 20px;"
>
10
</span>
<span
class="hidden h-full w-full text-center align-middle text-sm lg:block"
style="background: {PXX_COLORS[4]}; line-height: 20px;"
>
6
</span>
<span
class="hidden h-full w-full text-center align-middle text-sm lg:block"
style="background: {PXX_COLORS[5]}; line-height: 20px;"
>
3
</span>
<span
class="hidden h-full w-full text-center align-middle text-sm lg:block"
style="background: {PXX_COLORS[6]}; line-height: 20px;"
>
1
</span>
<!-- Small Screens: -->
<span class="block h-full w-full lg:hidden" style="background: {PXX_COLORS[3]};"></span>
<span class="block h-full w-full lg:hidden" style="background: {PXX_COLORS[4]};"></span>
<span class="block h-full w-full lg:hidden" style="background: {PXX_COLORS[5]};"></span>
<span class="block h-full w-full lg:hidden" style="background: {PXX_COLORS[6]};"></span>
</div>
</div>
<!-- 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 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)}
<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">
<span class="w-8">P{(race?.pxx ?? -100) - 3 + index}:</span>
<span class="badge w-10 p-1 text-center" style="background: {PXX_COLORS[index]};">
{driver?.code}
</span>
</div>
{/each}
{#if result.dnfs.length > 0}
<hr class="border-black" style="border-style: inset;" />
{/if}
{#each result.dnfs as dnf}
{@const driver = get_by_value(drivers, "id", dnf)}
<div class="flex gap-2">
<span class="w-8">DNF:</span>
<span class="badge w-10 p-1 text-center" style="background: {PXX_COLORS[3]};">
{driver?.code}
</span>
</div>
{/each}
</div>
</div>
{/each}
{/await}
</div>
<!-- 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="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")]}
{@const dnfcolor =
result.dnfs.indexOf(pick?.dnf ?? "Invalid") >= 0 ? PXX_COLORS[3] : PXX_COLORS[-1]}
{#if pick}
<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="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="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}
</span>
</div>
</div>
{:else}
<div class="mt-1 h-16 w-full px-1 py-2 lg:h-20 lg:px-2"></div>
{/if}
{/each}
</div>
{/each}
{/await}
</div>
</div>
</div>

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

@ -1 +1,75 @@
<h1>Rules</h1> <script>
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>
<p>
Das Tippspiel wird dieses Jahr in zwei Teile aufgespalten: Renntips (für jedes Rennen) und
Bonustipps (für die ganze Saison). Renntipps müssen bis Rennstart, Bonustipps bis 1. März
abgegeben werden. Tipps können entweder selbst eingetragen werden, oder in die Gruppe
geschrieben werden, um gültig zu sein.
</p>
</Card>
<Card>
<h1 class="text-lg font-bold">Renntipps</h1>
<p>
Jedes Rennen besteht aus zwei Tipps. Einem Platzierungstipp (PX-Tipp) und einem Tipp welcher
Fahrer als Erstes das Rennen abbricht (DNF-Tipp). Anders als beim P10-Tipp letztes Jahr wird
dieses Mal jedes Rennen eine andere Platzierung getippt. Die zu tippende Platzierung wird auf
der Tabellen-Übersichtsseite und im Fahrer-Auswahlmenü angezeigt. DNS/DSQ zählt hierbei nicht
als DNF, außerdem besteht die Möglichkeit zu tippen, dass kein Fahrer DNF't oder das Rennen
auf PX beendet.
</p>
</Card>
<Card>
<h1 class="text-lg font-bold">Punkte</h1>
<p>
Für einen korrekten Tipp werden 10 Punkte vergeben. Beim PX-Tipp werden 6 Punkte für einen
Platz Abweichung, 3 Punkte für zwei plätze Abweichung und 1 Punkt für 3 Plätze Abweichung
vergeben. Beim DNF-Tipp werden keine Abweichungspunkte vergeben, ebensowenig wie bei der
Auswahl "Keiner" für den PX-Tipp.
</p>
</Card>
<Card>
<h1 class="text-lg font-bold">Bonustipps</h1>
<p>
Zusätzlich gibt es dieses Jahr auch Tipps, die sich auf die ganze Saison beziehen und
verschieden bepunktet werden.
</p>
<ul class="ml-6 list-disc">
<li>
Welches Team wird P2 in der constructors championship? (10 Punkte für die richtige Antwort /
0 Punkte für eine falsche Antwort)
</li>
<li>
Welcher Fahrer verbessert am meisten seine Platzierung in der drivers championship im
Vergleich zum Vorjahr? (10/0)
</li>
<li>
Welcher Fahrer verschlechtert am meisten seine Platzierung in der drivers championship im
Vergleich zum Vorjahr? (10/0)
</li>
<li>Welcher Fahrer hat die meisten DNFs? (10/0)</li>
<li>Welcher Fahrer hat die meisten Überholungen? (10/0)</li>
<li>
Nenne alle Fahrer, die mindestens einmal auf dem Podium stehen werden. (3/-2, nicht-gegebene
Antworten zählen als falsch)
</li>
<li>Nenne für alle Teams den Sieger des teaminternen Duells. (3/-3)</li>
<li>Nenne einen hot-take für die Saison (10/0, "hotness" wird vorher abgesegnet)</li>
</ul>
</Card>
</div>

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({ export default defineConfig({
plugins: [sveltekit()], plugins: [sveltekit()],
ssr: {
noExternal: process.env.NODE_ENV === "production" ? ["@carbon/charts"] : [],
},
build: { build: {
rollupOptions: { rollupOptions: {
external: ["sharp"], external: ["sharp"],