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
114 changed files with 13548 additions and 1357 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

View File

@ -1,6 +0,0 @@
{
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"tailwindStylesheet": "./src/app.css",
"tailwindConfig": "./tailwind.config.js",
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }],
}

128
flake.lock generated
View File

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

414
flake.nix
View File

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

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" ]

3202
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

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"]

View File

@ -1,6 +1,6 @@
export default {
plugins: {
tailwindcss: {},
tailwindcss: { config: "./tailwind.config.ts" },
autoprefixer: {},
},
}
};

32
prettier.config.js Normal file
View File

@ -0,0 +1,32 @@
/**
* @see https://prettier.io/docs/en/configuration.html
* @type {import("prettier").Config}
*/
const config = {
// Plugin configs
plugins: ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
tailwindStylesheet: "./src/app.css",
tailwindConfig: "./tailwind.config.ts",
// Global formatting options
printWidth: 100,
tabWidth: 2,
tabs: false,
semi: true,
singleQuote: false,
quoteProps: "as-needed",
trailingComma: "all",
bracketSpacing: true,
bracketSameLine: false,
arrowParens: "always",
// File specific configuration options
overrides: [
{
files: "*.svelte",
options: { parser: "svelte" },
},
],
};
export default config;

View File

@ -1,3 +1,10 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* This class allows to manually simulate the "hover" class */
.btn-hover {
--tw-brightness: brightness(1.15);
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale)
var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
}

14
src/app.d.ts vendored
View File

@ -1,13 +1,23 @@
import type { User } from "$lib/schema";
import type { PocketBase, RecordModel } from "pocketbase";
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface Error {}
// interface PageData {}
// interface PageState {}
// 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

@ -16,8 +16,11 @@
</head>
<!-- Prefetch data specified in "load" functions on link hover -->
<body data-sveltekit-preload-data="hover">
<body data-theme="formula11Theme" data-sveltekit-preload-data="hover">
<!-- 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>
</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,43 +0,0 @@
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 }) => {
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);
// 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,
);
// 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);
// 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,20 +0,0 @@
<script lang="ts">
let {
id = "",
name = "",
formaction = "",
label,
color,
disabled = false,
} = $props();
</script>
<!-- HACK: Set --tw-bg-opacity to 1 so the disabled label/button looks like the disabled input -->
<button
{id}
{name}
{formaction}
{disabled}
class="btn btn-{color}"
style="--tw-bg-opacity: 1">{label}</button
>

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,35 +0,0 @@
<script lang="ts">
let {
id,
name,
label,
accept = "*",
onchange = undefined,
disabled = false,
required = false,
} = $props();
</script>
{#if disabled}
<!-- HACK: Set --tw-bg-opacity to 1 so the disabled label/button looks like the disabled input -->
<label
for={id}
class="btn btn-disabled mt-2 w-full"
style="--tw-bg-opacity: 1">{label}</label
>
{:else}
<label for={id} class="btn btn-ghost input-bordered mt-2 w-full"
>{label}</label
>
{/if}
<input
{id}
{name}
class="file-input file-input-bordered file-input-ghost"
type="file"
hidden
{disabled}
{required}
{onchange}
{accept}
/>

View File

@ -1,29 +0,0 @@
<script lang="ts">
let {
id,
name,
label,
placeholder = "",
type = "text",
value = "",
disabled = false,
required = false,
} = $props();
</script>
<label
for={id}
class="input input-bordered mt-2 flex !cursor-default select-none items-center gap-2"
>
{label}
<input
{id}
{name}
{type}
class={disabled ? "pointer-events-none grow !cursor-default" : "grow"}
{disabled}
{required}
{placeholder}
{value}
/>
</label>

View File

@ -0,0 +1,96 @@
<script lang="ts">
import type { HTMLImgAttributes } from "svelte/elements";
import { lazyload } from "$lib/lazyload";
import { fetch_image_base64 } from "$lib/image";
import { popup, type PopupSettings } from "@skeletonlabs/skeleton";
import { v4 as uuidv4 } from "uuid";
interface LazyImageProps extends HTMLImgAttributes {
/** The URL to the image resource to lazyload */
src: string;
/** 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;
/** 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,
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
let load: boolean = $state(false);
const lazy_visible_handler = () => {
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) => {
setTimeout(() => (node.style.opacity = "1"), 20);
};
// Tooltip handling
const tooltipId: string = uuidv4();
const popupTooltip: PopupSettings = {
event: "hover",
target: tooltipId,
placement: "top",
};
</script>
<!-- Show a correctly sized div so the layout doesn't jump. -->
<div
use:lazyload
onLazyVisible={lazy_visible_handler}
class="overflow-hidden {imgclass}"
style="aspect-ratio: {imgwidth} / {imgheight}; max-width: {imgwidth}px; max-height: {imgheight}px; {containerstyle ??
''}"
>
{#if load}
{#await fetch_image_base64(src) then data}
<img
src={data}
use:img_opacity_handler
class="bg-surface-100 transition-all {imgclass} {hoverzoom ? 'hover:scale-105' : ''}"
style="opacity: 0; transition-duration: 300ms; {imgstyle ?? ''}"
draggable="false"
use:popup={popupTooltip}
{...restProps}
/>
{/await}
{/if}
</div>
{#if tooltip}
<div class="card variant-filled-surface p-2 shadow" data-popup={tooltipId}>
<p>{tooltip}</p>
<div class="variant-filled-surface arrow"></div>
</div>
{/if}

View File

@ -0,0 +1,62 @@
<!-- https://www.sveltelab.dev/dc0nf9id4ust2vw -->
<script lang="ts">
import { navigating } from "$app/stores";
let loading: string = $state("no");
let percentage: number = $state(0);
$effect(() => {
if ($navigating) {
loading = "yes";
} else {
loading = "closing";
setTimeout(() => {
loading = "no";
}, 300);
}
});
$effect(() => {
if (loading === "closing") {
percentage = 1;
}
});
const load = (_node: HTMLElement) => {
let timeout: NodeJS.Timeout;
const handle = () => {
if (percentage < 0.7) {
percentage += Math.random() * 0.3;
// Let's call ourselves recursively to fill the loading bar
timeout = setTimeout(handle, Math.random() * 1000);
}
};
handle();
return {
destroy() {
clearTimeout(timeout);
percentage = 0;
},
};
};
</script>
{#if loading !== "no"}
<div
class="fixed inset-0 bottom-auto z-50 h-1 bg-error-500"
use:load
style:--percentage={percentage}
></div>
{/if}
<style>
div {
transform-origin: left;
transform: scaleX(calc(var(--percentage) * 100%));
transition: transform 250ms;
}
</style>

View File

@ -1,27 +0,0 @@
<script lang="ts">
let { id, name, disabled = false } = $props();
</script>
<label for={id} class="input input-bordered mt-2 flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="h-4 w-4 opacity-70"
>
<path
fill-rule="evenodd"
d="M14 6a4 4 0 0 1-4.899 3.899l-1.955 1.955a.5.5 0 0 1-.353.146H5v1.5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5v-2.293a.5.5 0 0 1 .146-.353l3.955-3.955A4 4 0 1 1 14 6Zm-4-2a.75.75 0 0 0 0 1.5.5.5 0 0 1 .5.5.75.75 0 0 0 1.5 0 2 2 0 0 0-2-2Z"
clip-rule="evenodd"
/>
</svg>
<input
{id}
{name}
type="password"
class="grow"
{disabled}
required
placeholder="Password"
/>
</label>

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,26 +0,0 @@
<script lang="ts">
let { id, name, value = "", disabled = false } = $props();
</script>
<label for={id} class="input input-bordered mt-2 flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="h-4 w-4 opacity-70"
>
<path
d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM12.735 14c.618 0 1.093-.561.872-1.139a6.002 6.002 0 0 0-11.215 0c-.22.578.254 1.139.872 1.139h9.47Z"
/>
</svg>
<input
{id}
{name}
type="text"
class="grow"
{value}
{disabled}
required
placeholder="Username"
/>
</label>

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

@ -0,0 +1,42 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { HTMLInputAttributes } from "svelte/elements";
interface InputProps extends HTMLInputAttributes {
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 type of the input element, e.g. "text". */
type?: string;
/** An optional element at the end of the input group */
tail?: Snippet;
}
let {
children,
labelwidth = "auto",
value = $bindable(),
type = "text",
tail = undefined,
...restProps
}: InputProps = $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 bind:value class="{tail ? '!border-r' : ''} !border-l" {type} {...restProps} />
{#if tail}
{@render tail()}
{/if}
</div>

View File

@ -1,7 +1,66 @@
import FileInput from "./FileInput.svelte";
import Input from "./Input.svelte";
import Password from "./Password.svelte";
import Username from "./Username.svelte";
import Button from "./Button.svelte";
import Countdown from "./Countdown.svelte";
import LazyImage from "./LazyImage.svelte";
import LoadingIndicator from "./LoadingIndicator.svelte";
import Table from "./Table.svelte";
export { FileInput, Input, Username, Password, Button };
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 NameIcon from "./svg/NameIcon.svelte";
import PasswordIcon from "./svg/PasswordIcon.svelte";
import StopwatchIcon from "./svg/StopwatchIcon.svelte";
import UserIcon from "./svg/UserIcon.svelte";
export {
// Components
Countdown,
LazyImage,
LoadingIndicator,
Table,
// Form
Button,
Dropdown,
Input,
// Cards
Card,
DriverCard,
RaceCard,
RacePickCard,
RaceResultCard,
SeasonPickCard,
SubstitutionCard,
TeamCard,
TeamSwitchCard,
// Types
type DropdownOption,
type TableColumn,
// SVG
ChequeredFlagIcon,
EMailIcon,
NameIcon,
MenuDrawerIcon,
PasswordIcon,
StopwatchIcon,
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,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
class="mt-1 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h8m-8 6h16"
/>
</svg>

After

Width:  |  Height:  |  Size: 254 B

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,12 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="h-4 w-4 opacity-70"
>
<path
fill-rule="evenodd"
d="M14 6a4 4 0 0 1-4.899 3.899l-1.955 1.955a.5.5 0 0 1-.353.146H5v1.5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5v-2.293a.5.5 0 0 1 .146-.353l3.955-3.955A4 4 0 1 1 14 6Zm-4-2a.75.75 0 0 0 0 1.5.5.5 0 0 1 .5.5.75.75 0 0 0 1.5 0 2 2 0 0 0-2-2Z"
clip-rule="evenodd"
/>
</svg>

After

Width:  |  Height:  |  Size: 424 B

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

@ -0,0 +1,10 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="h-4 w-4 opacity-70"
>
<path
d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM12.735 14c.618 0 1.093-.561.872-1.139a6.002 6.002 0 0 0-11.215 0c-.22.578.254 1.139.872 1.139h9.47Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 279 B

45
src/lib/config.ts Normal file
View File

@ -0,0 +1,45 @@
// Many aspect ratios are predefined here.
// This is terrible, since they need to be updated if the HTML changes.
// I tried to determine these dynamically by loading a "sample" element
// and measuring its width/height, but this was not reliable:
// When changing the viewport size, measured heights were no longer accurate.
// Image aspect ratios
export const AVATAR_WIDTH: number = 256;
export const AVATAR_HEIGHT: number = 256;
export const TEAM_BANNER_WIDTH: number = 512;
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_HEIGHT: number = 512;
export const RACE_PICTOGRAM_WIDTH: number = 512;
export const RACE_PICTOGRAM_HEIGHT: number = 384;
// Card aspect ratios
// export const TEAM_CARD_ASPECT_WIDTH: number = 413;
// export const TEAM_CARD_ASPECT_HEIGHT: number = 438;
// export const DRIVER_CARD_ASPECT_WIDTH: number = 411;
// export const DRIVER_CARD_ASPECT_HEIGHT: number = 769;
// export const RACE_CARD_ASPECT_WIDTH: number = 497;
// export const RACE_CARD_ASPECT_HEIGHT: number = 879;
// export const SUBSTITUTION_CARD_ASPECT_WIDTH: number = 413;
// 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

25
src/lib/database.ts Normal file
View File

@ -0,0 +1,25 @@
import type { Graphic } from "$lib/schema";
/**
* Select an element from an [objects] array where [key] matches [value].
* Supposed to be used on collections returned by the [PocketBase] client.
*/
export const get_by_value = <T extends object>(
objects: T[],
key: keyof T,
value: string,
): T | undefined => {
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,48 +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 and files from FormData objects.
*/
export const form_data_clean = (data: FormData): FormData => {
for (const [key, value] of data.entries()) {
if (value === "") {
// Remove empty keys
data.delete(key);
} else if (
// Remove empty files
typeof value === "object" &&
value !== null &&
"size" in value &&
value.size === 0
) {
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) => {
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[]) => {
keys.map((key) => form_data_ensure_key(data, key));
};

View File

@ -1,11 +1,20 @@
export const get_image_preview_event_handler = (id: string) => {
const handler = (event) => {
const target = event.target;
const files = target.files;
import { browser } from "$app/environment";
/**
* Obtain an onchange event handler that updates an <Avatar> component
* with a new image uploaded via a file input element.
*/
export const get_avatar_preview_event_handler = (id: string): ((event: Event) => void) => {
const handler = (event: Event): void => {
const target: HTMLInputElement = event.target as HTMLInputElement;
const files: FileList | null = target.files;
if (files && files.length > 0) {
const src: string = URL.createObjectURL(files[0]);
const preview: HTMLImageElement = document.querySelector(
`#${id} > img:first-of-type`,
) as HTMLImageElement;
if (files.length > 0) {
const src = URL.createObjectURL(files[0]);
const preview = document.getElementById(id) as HTMLImageElement;
if (preview) {
preview.src = src;
preview.hidden = false;
@ -15,3 +24,62 @@ export const get_image_preview_event_handler = (id: string) => {
return handler;
};
/**
* Obtain an onchange event handler that updates an <img> element
* with a new image uploaded via a file input element.
*/
export const get_image_preview_event_handler = (id: string): ((event: Event) => void) => {
const handler = (event: Event): void => {
const target: HTMLInputElement = event.target as HTMLInputElement;
const files: FileList | null = target.files;
if (files && files.length > 0) {
const src: string = URL.createObjectURL(files[0]);
const preview: HTMLImageElement = document.getElementById(id) as HTMLImageElement;
if (preview) {
preview.src = src;
preview.hidden = false;
}
}
};
return handler;
};
/**
* Convert a binary [Blob] to base64 string.
* Can only be called clientside from a browser as it depends on FileReader!
*/
export const blob_to_base64 = (blob: Blob): Promise<string> => {
if (!browser) {
console.error("Can't call blob_to_base64 on server (FileReader is not available)!");
}
return new Promise((resolve, _) => {
const reader = new FileReader();
// This is fired once the file read has ended
reader.onloadend = () => resolve(reader.result?.toString() ?? "");
reader.readAsDataURL(blob);
});
};
/**
* Fetch an image from an URL using a fetch function [f] and return as base64 string .
* Can be called client- and server-side.
*/
export const fetch_image_base64 = async (url: string, f: Function = fetch): Promise<string> => {
if (browser) {
return await f(url)
.then((response: Response) => response.blob())
.then((blob: Blob) => blob_to_base64(blob));
}
// On the server
const response: Response = await f(url);
const buffer: Buffer = Buffer.from(await response.arrayBuffer());
return buffer.toString("base64");
};

35
src/lib/lazyload.ts Normal file
View File

@ -0,0 +1,35 @@
// https://www.alexschnabl.com/blog/articles/lazy-loading-images-and-components-in-svelte-and-sveltekit-using-typescript
let observer: IntersectionObserver;
const getObserver = () => {
if (observer) return;
observer = new IntersectionObserver((entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.dispatchEvent(new CustomEvent("LazyVisible"));
}
});
});
};
/**
* Use this as an action on elements that should be only loaded when moved into view.
* Note that if the element's size is 0 on mount, multiple elements could be in-view that
* would be out-of-view with their correct size.
* This happens for <div> elements without content for example.
*/
export const lazyload = (node: HTMLElement) => {
// The observer determines if the element is visible on screen
getObserver();
// If the element is visible, the "LazyVisible" event will be dispatched
observer.observe(node);
return {
destroy() {
observer.unobserve(node);
},
};
};

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("*");
});
};

198
src/lib/schema.ts Normal file
View File

@ -0,0 +1,198 @@
// 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 {
name: string;
file: string;
file_url?: string;
}
export interface User {
id: string;
verified: boolean;
username: string;
firstname: string;
email?: string;
avatar?: string;
avatar_url?: string;
admin: boolean;
}
// Season Data
export interface Team {
id: string;
name: string;
banner: string;
banner_url?: string;
logo: string;
logo_url?: string;
color: string;
}
export interface Driver {
id: string;
code: string;
firstname: string;
lastname: string;
headshot: string;
headshot_url?: string;
team: string;
active: boolean;
started_active: boolean;
}
export interface Race {
id: string;
name: string;
step: number;
pictogram: string;
pictogram_url?: string;
pxx: number;
sprintqualidate: string;
sprintdate: string;
qualidate: string;
racedate: string;
}
export interface Substitution {
id: string;
substitute: string;
for: 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;
}

27
src/lib/server/image.ts Normal file
View File

@ -0,0 +1,27 @@
import sharp from "sharp";
/**
* Convert any [ArrayBuffer] containing image data to an [avif] [Blob].
* Also allows downscaling and lossy compression.
* Set either [width] or [height] to downscale while keeping the aspect ratio.
*/
export const image_to_avif = async (
data: ArrayBuffer,
width?: number,
height?: number,
quality: number = 50,
effort: number = 4,
): Promise<Blob> => {
console.log(
`Compressing ${data.byteLength} Bytes to ${width ?? -1}x${height ?? -1} avif with quality ${quality} and effort ${effort}...`,
);
const compressed: Buffer = await sharp(data)
.resize(width, height)
.avif({ quality: quality, effort: effort })
.toBuffer();
console.log(`Compressed ${data.byteLength} Bytes to ${compressed.length} Bytes`);
return new Blob([compressed]);
};

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

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

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

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

View File

@ -1,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.
// 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,186 +1,645 @@
<script lang="ts">
import "../app.css";
import type { Snippet } from "svelte";
import { onDestroy, onMount, type Snippet } from "svelte";
import type { LayoutData } from "./$types";
import { FileInput, Password, Username } from "$lib/components";
import { get_image_preview_event_handler } from "$lib/image";
import { page } from "$app/state";
import {
Button,
MenuDrawerIcon,
UserIcon,
Input,
PasswordIcon,
LoadingIndicator,
DriverCard,
TeamCard,
RaceCard,
SubstitutionCard,
NameIcon,
RacePickCard,
RaceResultCard,
SeasonPickCard,
EMailIcon,
TeamSwitchCard,
} from "$lib/components";
import { get_avatar_preview_event_handler } from "$lib/image";
import {
AppBar,
storePopup,
initializeStores,
Drawer,
getDrawerStore,
Modal,
Toast,
getModalStore,
type DrawerSettings,
Avatar,
FileDropzone,
type DrawerStore,
type ModalStore,
type ModalComponent,
type ToastStore,
getToastStore,
SlideToggle,
} from "@skeletonlabs/skeleton";
import { computePosition, autoUpdate, offset, shift, flip, arrow } from "@floating-ui/dom";
import { invalidate } from "$app/navigation";
import { get_error_toast, get_info_toast, get_warning_toast } from "$lib/toast";
import { clear_auth, pb, pbUser, refresh_auth, subscribe, unsubscribe } from "$lib/pocketbase";
import { AVATAR_HEIGHT, AVATAR_WIDTH } from "$lib/config";
import { error } from "@sveltejs/kit";
import type { User } from "$lib/schema";
import type { RecordModel } from "pocketbase";
let { data, children }: { data: LayoutData; children: Snippet } = $props();
// Init skeleton stores for drawer + modal
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();
let drawerOpen: boolean = false;
let drawerId: string = "";
drawerStore.subscribe((settings: DrawerSettings) => {
drawerOpen = settings.open ?? false;
drawerId = settings.id ?? "";
});
const toggle_drawer = (settings: DrawerSettings) => {
if (drawerOpen) {
if (drawerId === settings.id) {
// We clicked the same button to close the drawer
drawerStore.close();
} else {
// We clicked another button to open another drawer
drawerStore.close();
setTimeout(() => drawerStore.open(settings), 175);
}
} else {
drawerStore.open(settings);
}
};
const close_drawer = () => drawerStore.close();
const drawer_settings_base: DrawerSettings = {
position: "top",
height: "auto",
padding: "2xl:px-96 pt-14", // pt-14 is 56px, so its missing 4px for the 60px navbar...
bgDrawer: "bg-surface-100",
duration: 150,
};
const menu_drawer = () => {
const drawerSettings: DrawerSettings = {
id: "menu_drawer",
...drawer_settings_base,
};
toggle_drawer(drawerSettings);
};
const data_drawer = () => {
const drawerSettings: DrawerSettings = {
id: "data_drawer",
...drawer_settings_base,
};
toggle_drawer(drawerSettings);
};
const login_drawer = () => {
const drawerSettings: DrawerSettings = {
id: "login_drawer",
...drawer_settings_base,
};
toggle_drawer(drawerSettings);
};
const profile_drawer = () => {
const drawerSettings: DrawerSettings = {
id: "profile_drawer",
...drawer_settings_base,
};
toggle_drawer(drawerSettings);
};
// Popups config
storePopup.set({ computePosition, autoUpdate, offset, shift, flip, arrow });
// Reactive state
let username_value: string = $state($pbUser?.username ?? "");
let firstname_value: string = $state($pbUser?.firstname ?? "");
let email_value: string = $state($pbUser?.email ?? "");
let password_value: string = $state("");
let avatar_value: FileList | undefined = $state();
let registration_mode: boolean = $state(false);
// Add "Enter" event listeners for login/register text inputs
const enter_handler = (event: KeyboardEvent) => {
if (event.key === "Enter") {
// Cancel the default action, if needed
event.preventDefault();
registration_mode ? update_profile(true) : login();
}
};
// Database actions
const login = async (): Promise<void> => {
if (!username_value || username_value.trim() === "") {
toastStore.trigger(get_error_toast("Please enter your username!"));
return;
}
if (!password_value || password_value.trim() === "") {
toastStore.trigger(get_error_toast("Please enter your password!"));
return;
}
try {
await pb.collection("users").authWithPassword(username_value, password_value);
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
await invalidate("data:user");
drawerStore.close();
username_value = $pbUser?.username ?? "";
firstname_value = $pbUser?.firstname ?? "";
email_value = $pbUser?.email ?? "";
password_value = "";
};
const logout = async (): Promise<void> => {
clear_auth();
await invalidate("data:user");
drawerStore.close();
username_value = "";
firstname_value = "";
email_value = "";
password_value = "";
};
const forgot_password = async (): Promise<void> => {
if (!username_value || username_value.trim() === "") {
toastStore.trigger(get_error_toast("Please enter a username!"));
return;
}
try {
const user: RecordModel = await pb
.collection("users")
.getFirstListItem(`username="${username_value}"`);
if (!user.email) {
toastStore.trigger(get_error_toast("You did not set a recovery e-mail address!"));
return;
}
await pb.collection("users").requestPasswordReset(user.email);
toastStore.trigger(get_info_toast("Check your inbox!"));
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
const update_profile = (create?: boolean): (() => Promise<void>) => {
const handler = async (): Promise<void> => {
// Avatar handling
let avatar_avif: Blob | undefined = undefined;
const avatar_file: File | undefined =
avatar_value && avatar_value.length === 1 ? avatar_value[0] : undefined;
if (avatar_file) {
const avatar_formdata: FormData = new FormData();
avatar_formdata.append("image", avatar_file);
avatar_formdata.append("width", AVATAR_WIDTH.toString());
avatar_formdata.append("height", AVATAR_HEIGHT.toString());
try {
const response = await fetch("/api/compress", {
method: "POST",
body: avatar_formdata,
});
if (!response.ok) {
error(500, "Compression failed.");
}
avatar_avif = await response.blob();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
}
try {
if (create) {
if (!username_value || username_value.trim() === "") {
toastStore.trigger(get_error_toast("Please enter a username!"));
return;
}
if (!firstname_value || firstname_value.trim() === "") {
toastStore.trigger(get_error_toast("Please enter your first name!"));
return;
}
if (!email_value || email_value.trim() === "") {
toastStore.trigger(get_error_toast("Please enter your e-mail address!"));
return;
}
if (!password_value || password_value.trim() === "") {
toastStore.trigger(get_error_toast("Please enter a password!"));
return;
}
await pb.collection("users").create({
username: username_value.trim(),
firstname: firstname_value.trim(),
email: email_value.trim(),
emailVisibility: true,
password: password_value.trim(),
passwordConfirm: password_value.trim(), // lol
admin: false,
});
await pb.collection("users").requestVerification(email_value.trim());
toastStore.trigger(get_info_toast("Check your inbox!"));
// Just in case
clear_auth();
await login();
} else {
if (!$pbUser?.id || $pbUser.id === "") {
toastStore.trigger(get_error_toast("Invalid user id!"));
return;
}
await pb.collection("users").update($pbUser.id, {
username: username_value.trim().length > 0 ? username_value.trim() : $pbUser.username,
firstname:
firstname_value.trim().length > 0 ? firstname_value.trim() : $pbUser.firstname,
avatar: avatar_avif,
});
if (email_value && email_value.trim() !== $pbUser.email) {
await pb.collection("users").requestEmailChange(email_value.trim());
// When changing the email address, the auth token is invalidated
await logout();
toastStore.trigger(get_info_toast("Check your inbox!"));
toastStore.trigger(
get_warning_toast("Please login AFTER confirming your e-mail address!", 5000),
);
}
drawerStore.close();
}
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
return handler;
};
// Real-time updates without reloading
onMount(() =>
subscribe([
"users",
"drivers",
"racepicks",
"raceresults",
"races",
"seasonpicks",
"substitutions",
"teams",
"scraped_startinggrids",
"scraped_raceresults",
"scraped_driverstandings",
"scraped_teamstandings",
// The view collections do not receive realtime events
]),
);
onDestroy(() =>
unsubscribe([
"users",
"drivers",
"racepicks",
"raceresults",
"races",
"seasonpicks",
"substitutions",
"teams",
"scraped_startinggrids",
"scraped_raceresults",
"scraped_driverstandings",
"scraped_teamstandings",
]),
);
</script>
<nav>
<!-- TODO: Make this stick to the top somehow. -->
<!-- Fixed breaks the flexbox and sticky doesn't work. -->
<div class="navbar h-16 bg-primary shadow">
<div class="navbar-start">
<!-- Side menu be visible on low width devices -->
<div class="dropdown">
<!-- Side menu open/close icon -->
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
<LoadingIndicator />
<Modal components={modalRegistry} regionBackdrop="!overflow-y-scroll" />
<Toast zIndex="z-[1000]" />
<Drawer zIndex="z-30">
<!-- Use p-3 because the drawer has a 5px overlap with the navbar -->
{#if $drawerStore.id === "menu_drawer"}
<!-- Menu Drawer -->
<!-- Menu Drawer -->
<!-- Menu Drawer -->
<div class="flex flex-col gap-2 p-2 pt-3">
<Button href="/racepicks" onclick={close_drawer} color="surface" width="w-full" shadow>
Race Picks
</Button>
<Button href="/seasonpicks" onclick={close_drawer} color="surface" width="w-full" shadow>
Season Picks
</Button>
<Button href="/leaderboard" onclick={close_drawer} color="surface" width="w-full" shadow>
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
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h8m-8 6h16"
Roadmap
</Button>
</div>
{:else if $drawerStore.id === "data_drawer"}
<!-- Data Drawer -->
<!-- Data Drawer -->
<!-- Data Drawer -->
<div class="flex flex-col gap-2 p-2 pt-3">
<Button href="/data/raceresults" onclick={close_drawer} color="surface" width="w-full" shadow>
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>
</div>
{:else if $drawerStore.id === "login_drawer"}
<!-- Login Drawer -->
<!-- Login Drawer -->
<!-- Login Drawer -->
<div class="flex flex-col gap-2 p-2 pt-3">
<div class="flex">
<h4 class="h4 select-none text-nowrap align-middle font-bold" style="line-height: 32px;">
Login or Register
</h4>
<div class="w-full"></div>
<div class="flex gap-2">
<span class="align-middle" style="line-height: 32px;">Login</span>
<SlideToggle
name="registrationmode"
background="bg-tertiary-500"
active="bg-tertiary-500"
bind:checked={registration_mode}
/>
</svg>
<span class="align-middle" style="line-height: 32px;">Register</span>
</div>
<!-- Side menu navigation items -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<ul
tabindex="0"
class="menu dropdown-content z-[1] mt-4 w-52 rounded-box border bg-base-100 p-2 shadow"
>
<li><a draggable="false" href="/racepicks">Race Picks</a></li>
<li><a draggable="false" href="/seasonpicks">Season Picks</a></li>
<li><a draggable="false" href="/leaderboard">Leaderboard</a></li>
<li><a draggable="false" href="/statistics">Statistics</a></li>
<li><a draggable="false" href="/rules">Rules</a></li>
</ul>
</div>
<!-- Site logo -->
<a href="/" draggable="false" class="btn btn-ghost select-none text-xl"
>Formula 11</a
<Input
bind:value={username_value}
placeholder="Username"
autocomplete="username"
minlength={3}
maxlength={10}
required
onkeypress={enter_handler}
>
</div>
<!-- Centered navigation -->
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1">
<li>
<a draggable="false" class="btn btn-ghost btn-sm" href="/racepicks"
>Race Picks</a
>
</li>
<li>
<a draggable="false" class="btn btn-ghost btn-sm" href="/seasonpicks"
>Season Picks</a
>
</li>
<li>
<a draggable="false" class="btn btn-ghost btn-sm" href="/leaderboard"
>Leaderboard</a
>
</li>
<li>
<a draggable="false" class="btn btn-ghost btn-sm" href="/statistics"
>Statistics</a
>
</li>
<li>
<a draggable="false" class="btn btn-ghost btn-sm" href="/rules"
>Rules</a
>
</li>
</ul>
</div>
<div class="navbar-end">
<!-- Admin button -->
<div class="dropdown dropdown-end mr-2">
<div tabindex="0" role="button" class="btn btn-ghost">Data</div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<ul
tabindex="0"
class="menu dropdown-content z-[1] mt-4 w-52 rounded-box border bg-base-100 p-2 shadow"
>
<li>
<a draggable="false" href="/data/seasondata/teams">Season Data</a>
</li>
<li><a draggable="false" href="/data/userdata">User Data</a></li>
</ul>
</div>
<!-- Login/profile stuff -->
{#if !data.user}
<!-- No user is logged in -->
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost m-1">Login</div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<UserIcon />
</Input>
<div
tabindex="0"
class="menu dropdown-content z-[1] mt-4 w-[150] rounded-box border bg-base-100 p-2 shadow"
class="{registration_mode
? ''
: 'mt-[-8px] h-0'} overflow-hidden transition-all duration-150 ease-out"
>
<h1 class="text-lg">Enter Username and Password</h1>
<form method="POST">
<Username id="signin_username" name="username" />
<Password id="signin_password" name="password" />
<div class="card-actions mt-2 justify-end">
<button
formaction="/user?/create"
type="button"
class="btn btn-accent">Register</button
>
<button
formaction="/user?/login"
type="submit"
class="btn btn-accent">Login</button
<Input
bind:value={firstname_value}
placeholder="First Name"
autocomplete="off"
tabindex={registration_mode ? 0 : -1}
onkeypress={enter_handler}
>
<NameIcon />
</Input>
</div>
</form>
</div>
</div>
{:else}
<!-- The user is logged in -->
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="avatar ml-2 mr-2">
<div class="mask mask-squircle w-10">
<img
id="user_avatar_preview"
src={data.user.avatar_url}
alt="User avatar"
class="select-none"
draggable="false"
/>
</div>
</div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
tabindex="0"
class="menu dropdown-content z-[1] mt-4 w-[150] rounded-box border bg-base-100 p-2 shadow"
class="{registration_mode
? ''
: 'mt-[-8px] h-0'} overflow-hidden transition-all duration-150 ease-out"
>
<h1 class="select-none text-lg">Edit Profile</h1>
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="id" value={data.user.id} />
<Username
id="update_username"
name="username"
value={data.user.username}
/>
<FileInput
id="update_avatar"
<Input
id="login_email"
type="email"
bind:value={email_value}
placeholder="E-Mail"
autocomplete="email"
tabindex={registration_mode ? 0 : -1}
onkeypress={enter_handler}
>
<EMailIcon />
</Input>
</div>
<Input
id="login_password"
bind:value={password_value}
type="password"
placeholder="Password"
autocomplete="off"
required
onkeypress={enter_handler}
>
<PasswordIcon />
</Input>
<div
class="{!registration_mode
? ''
: 'mt-[-8px] h-0'} flex w-full gap-2 overflow-hidden transition-all duration-150 ease-out"
>
<Button onclick={forgot_password} color="primary" width="w-full">Forgot Password</Button>
<Button onclick={login} color="tertiary" width="w-full" shadow>Login</Button>
</div>
<div
class="{registration_mode
? ''
: 'mt-[-8px] h-0'} w-full overflow-hidden transition-all duration-150 ease-out"
>
<Button onclick={update_profile(true)} color="tertiary" width="w-full" shadow>
Register
</Button>
</div>
</div>
{:else if $drawerStore.id === "profile_drawer" && $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"
label="Upload Avatar"
onchange={get_image_preview_event_handler(
"user_avatar_preview",
)}
/>
<div class="card-actions mt-2 justify-end">
<button formaction="/user?/update" class="btn btn-secondary"
>Save Changes</button
bind:files={avatar_value}
onchange={get_avatar_preview_event_handler("user_avatar_preview")}
>
<button formaction="/user?/logout" class="btn btn-primary"
>Logout</button
>
</div>
</form>
<svelte:fragment slot="message">
<span class="font-bold">Upload Avatar</span>
</svelte:fragment>
</FileDropzone>
<div class="flex justify-end gap-2">
<Button onclick={update_profile()} color="secondary" width="w-full" shadow>
Save Changes
</Button>
<Button onclick={logout} color="primary" width="w-full" shadow>Logout</Button>
</div>
</div>
{/if}
</Drawer>
<nav>
<div class="fixed left-0 right-0 top-0 z-40">
<AppBar
slotDefault="place-self-center"
slotTrail="place-content-end"
background="bg-primary-500"
shadow="shadow"
padding="p-2"
>
<svelte:fragment slot="lead">
<div class="flex gap-2">
<!-- Navigation drawer -->
<div class="lg:hidden">
<Button color="primary" onclick={menu_drawer}>
<MenuDrawerIcon />
</Button>
</div>
<!-- Site logo -->
<Button href="/racepicks" color="primary">
<span class="text-xl font-bold">Formula 11</span>
</Button>
</div>
</svelte:fragment>
<!-- Large navigation -->
<div class="hidden gap-2 pr-8 lg:flex">
<Button href="/racepicks" color="primary" activate_href>Race Picks</Button>
<Button href="/seasonpicks" color="primary" activate_href>Season Picks</Button>
<Button href="/leaderboard" color="primary" activate_href>Leaderboard</Button>
<Button href="/statistics" color="primary" activate_href>Statistics</Button>
<Button href="/rules" color="primary" activate_href>Rules</Button>
<Button
href="https://gitea.vps.chriphost.de/christoph/svelte-formula11/projects/1"
color="primary"
activate_href
newtab
>
Roadmap
</Button>
</div>
<svelte:fragment slot="trail">
<div class="flex gap-2">
<!-- Data drawer -->
<Button
color="primary"
onclick={data_drawer}
activate={page.url.pathname.startsWith("/data")}>Data</Button
>
{#if !$pbUser}
<!-- Login drawer -->
<Button color="primary" onclick={login_drawer}>Login</Button>
{:else}
<!-- Profile drawer -->
<Avatar
id="user_avatar_preview"
src={$pbUser?.avatar_url}
rounded="rounded-full"
width="w-10"
background="bg-primary-50"
onclick={profile_drawer}
cursor="cursor-pointer"
/>
{/if}
</div>
</svelte:fragment>
</AppBar>
</div>
</nav>
<!-- Each child's contents will be inserted here -->
<div class="p-2">
<div class="p-2" style="margin-top: 60px;">
{@render children()}
</div>

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

@ -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,35 +0,0 @@
<script lang="ts">
import { page } from "$app/stores";
import type { Snippet } from "svelte";
let { children }: { children: Snippet } = $props();
// This has to be a function, so $page access is deferred to route switches
const get_tab = () => {
return $page.url.pathname.split("/").at(-1);
};
</script>
<h1>Season Data</h1>
<div role="tablist" class="tabs-boxed tabs">
<a
href="teams"
role="tab"
class={get_tab() === "teams" ? "tab tab-active" : "tab"}>Teams</a
>
<a
href="drivers"
role="tab"
class={get_tab() === "drivers" ? "tab tab-active" : "tab"}>Drivers</a
>
<a
href="races"
role="tab"
class={get_tab() === "races" ? "tab tab-active" : "tab"}>Races</a
>
</div>
<div>
{@render children()}
</div>

View File

@ -1,66 +0,0 @@
import type { Actions, PageServerLoad } from "./$types";
import {
form_data_clean,
form_data_ensure_keys,
form_data_get_and_remove_id,
} from "$lib/form";
// These "actions" run serverside only, as they're located inside +page.server.ts
export const actions = {
// We destructure the RequestEvent with ({cookies, request}).
// Alternatively use (event) and event.cookies or event.request to access.
create: async ({ cookies, request, locals }) => {
if (!locals.admin) return { success: false };
const data = form_data_clean(await request.formData());
form_data_ensure_keys(data, ["name", "logo"]);
const record = await locals.pb.collection("teams").create(data);
return { success: true };
},
update: async ({ cookies, request, locals }) => {
if (!locals.admin) return { success: false };
const data = form_data_clean(await request.formData());
const id = form_data_get_and_remove_id(data);
// Destructure the FormData object
const record = await locals.pb.collection("teams").update(id, data);
return { success: true };
},
delete: async ({ cookies, request, locals }) => {
if (!locals.admin) return { success: false };
const data: FormData = form_data_clean(await request.formData());
const id = form_data_get_and_remove_id(data);
await locals.pb.collection("teams").delete(id);
return { success: true };
},
} 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_teams = async () => {
const teams = await locals.pb.collection("teams").getFullList({
sort: "+name",
fetch: fetch,
});
// Fill in the file URLs
teams.map((team) => {
team.logo_url = locals.pb.files.getURL(team, team.logo);
});
return teams;
};
return {
teams: await fetch_teams(),
};
};

View File

@ -1,118 +0,0 @@
<script lang="ts">
import type { PageData } from "./$types";
import { Input, FileInput, Button } from "$lib/components";
import { get_image_preview_event_handler } from "$lib/image";
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>F11 - Teams</title>
</svelte:head>
<div
class="mt-2 grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6"
>
<!-- List all teams inside the database -->
{#each data.teams as team}
<div class="card card-bordered card-compact shadow">
<!-- Logo display -->
<figure>
<img
id="update_team_logo_preview_{team.id}"
src={team.logo_url}
alt="Logo of {team.name} F1 team."
draggable="false"
class="select-none"
/>
</figure>
<form method="POST" enctype="multipart/form-data">
<input name="id" type="hidden" value={team.id} />
<div class="card-body gap-0 !p-2 !pt-0">
<Input
id="team_name_{team.id}"
name="name"
value={team.name}
label="Name:"
disabled={!data.admin}
/>
<!-- Logo upload -->
<FileInput
id="team_logo_{team.id}"
name="logo"
label="Upload Logo"
onchange={get_image_preview_event_handler(
`update_team_logo_preview_${team.id}`,
)}
disabled={!data.admin}
/>
<!-- Buttons -->
<div class="card-actions mt-2 justify-end">
<Button
formaction="?/update"
color="secondary"
label="Save Changes"
disabled={!data.admin}
/>
<Button
formaction="?/delete"
color="primary"
label="Delete"
disabled={!data.admin}
/>
</div>
</div>
</form>
</div>
{/each}
<!-- Add a new team -->
{#if data.admin}
<div class="card card-bordered card-compact shadow">
<!-- Logo preview -->
<figure>
<img
id="create_team_logo_preview"
src=""
alt="Logo preview"
class="select-none"
draggable="false"
hidden
/>
</figure>
<form method="POST" enctype="multipart/form-data">
<div class="card-body">
<h2 class="card-title select-none">Add a New Team</h2>
<!-- Team name input -->
<Input id="team_name_create" name="name" label="Name:" required />
<!-- Logo upload -->
<FileInput
id="team_logo_create"
name="logo"
label="Upload Logo"
onchange={get_image_preview_event_handler(
"create_team_logo_preview",
)}
required
/>
<!-- Buttons -->
<div class="card-actions justify-end">
<!-- By specifying the formaction on the button (instead of action on the form), -->
<!-- we can have multiple buttons with different actions in a single form. -->
<button formaction="?/create" class="btn btn-secondary"
>Create</button
>
</div>
</div>
</form>
</div>
{/if}
</div>

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>

Some files were not shown because too many files have changed in this diff Show More