Compare commits

...

101 Commits

Author SHA1 Message Date
ef46342384 Lib: Fix LazyImage in Dropdown component 2024-12-16 21:11:52 +01:00
1a51b000ac Lib: Remove debug log 2024-12-16 21:11:37 +01:00
ebfcca559a Fix data dropdown padding 2024-12-16 21:11:08 +01:00
4de9cf9b0b 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-16 20:06:56 +01:00
fdf08d7b86 Lib: Implement (slightly broken) lazy loading of cards
Issues arise when the viewport size changes
2024-12-16 19:52:46 +01:00
060575ed12 Lib: Add comment to lazyload.ts 2024-12-16 18:17:54 +01:00
9fa0a341a8 Update drawer switch timeout in main layout 2024-12-16 18:17:43 +01:00
ac359d86b8 Lib: Update DriverCard to reflect lib changes (lazy loading) 2024-12-16 18:17:22 +01:00
762b2a0839 Update data/season tab bar styling 2024-12-16 17:34:01 +01:00
59ba9a1c88 Lib: Make lazyimage fade in the image once loaded 2024-12-16 17:15:52 +01:00
6178312fde Position the drawer below the navbar + allow toggling between them 2024-12-16 16:46:26 +01:00
7131aebf86 Update data/season page to reflect lib updates (lazy stuff) 2024-12-16 16:45:58 +01:00
379c58d0c2 Lib: Fix bug in image to base64 conversion (now works client+serverside) 2024-12-16 16:45:11 +01:00
6b87d05de6 Lib: Update lazy components (dropdown + card now lazy) 2024-12-16 16:44:46 +01:00
0b3184ba56 Move image fetching out of LazyImage component into lib 2024-12-16 13:36:01 +01:00
c09237e112 Lib: Add function to fetch image as base64 string 2024-12-16 13:35:45 +01:00
ebb6159b93 Enable autocomplete on username inputs 2024-12-16 12:42:26 +01:00
98e7219636 Lib: Disable autocomplete on card inputs 2024-12-16 12:42:16 +01:00
2bd3aa0417 Stream drivers, races and substitutes as promises for data/season page 2024-12-16 04:03:37 +01:00
5767603e87 Hooks: Log requests 2024-12-16 04:03:05 +01:00
88abbadf48 Env: Add sveltekit node server adapter 2024-12-16 04:02:59 +01:00
272996ff68 Lib: Make LazyImage full width 2024-12-16 02:58:30 +01:00
79c97ce232 Update imports 2024-12-16 02:29:33 +01:00
68eeae18e2 Implement image compression + downsizing for team/driver/race routes 2024-12-16 02:29:27 +01:00
b694a10609 Lib: Dispatch CustomEvent instead of Event for DropdownChange 2024-12-16 02:29:06 +01:00
262ac50356 Lib: Add imgwidth/imgheight to Card component so layout doesn't jump when lazyloading images 2024-12-16 02:28:31 +01:00
fde45eb37c Lib: Update index.ts 2024-12-16 02:27:50 +01:00
c45a24066d Lib: Implement LazyImage component (images will be loaded once visible) 2024-12-16 02:27:45 +01:00
5f16b55593 Lib: Define some constant values in lib/config.ts 2024-12-16 02:27:13 +01:00
ea0320e063 Add site loading indicator to the main layout 2024-12-16 02:26:45 +01:00
c939655a4f Lib: Implement site loading indicator 2024-12-16 02:26:35 +01:00
00a4019ae5 Compress user avatars in update_profile route 2024-12-15 23:53:23 +01:00
5136946053 Lib: Implement image downscaling + avif conversion helper 2024-12-15 23:53:00 +01:00
65b5a84379 Remove unused imports 2024-12-15 23:52:21 +01:00
1f8945235c Env: Don't bundle sharp (sharp needs node, so serverside only) 2024-12-15 23:52:07 +01:00
fcf0ad4ad0 Add race results link to layout 2024-12-15 22:45:13 +01:00
af87b5010a Add driver/team icons to driver/team dropdowns 2024-12-15 22:45:02 +01:00
58b5fa0773 Fix bug in data/season create_driver action (don't ensure "active") 2024-12-15 22:44:43 +01:00
9db8a946ce Lib: Allow icons in dropdown component list 2024-12-15 22:44:15 +01:00
ee24f0fd99 Add "active" switches on data/season drivers page 2024-12-15 21:44:27 +01:00
38aa6e8326 Lib: Fix wrong label in substitution card 2024-12-15 21:44:02 +01:00
03c3deb32e Implement data/season substitutions page 2024-12-15 20:58:00 +01:00
e0bb592021 Lib: Implement substitution card 2024-12-15 20:57:48 +01:00
54adeca546 Lib: Rename field in schema 2024-12-15 20:57:39 +01:00
4d635bd536 Lib: Remove unused event from clear_spring event handler in racecard component 2024-12-15 20:57:31 +01:00
e7ba5607eb Lib: Add action field to dropdown component 2024-12-15 20:57:14 +01:00
20803c5663 Lib: Only pass single "team_select_value" into component except of all of them 2024-12-15 20:56:49 +01:00
46059bcfb5 Implement data/season races page 2024-12-15 00:55:37 +01:00
7b495b21b8 Lib: Allow key exceptions in form_data_clean + implement date format conversion for pocketbase 2024-12-15 00:55:25 +01:00
ea7eba11d9 Lib: Implement racecard component 2024-12-15 00:54:43 +01:00
34a9954e5c Env: Add date-fns library 2024-12-15 00:11:52 +01:00
6592cf8172 Lib: Fix bug in form_data_clean (mutating while iterating) 2024-12-15 00:11:42 +01:00
ca406503cf Lib: Add schema definitions for race and substitution 2024-12-15 00:11:23 +01:00
439f87fa9d Lib: Fix readonly + required in dropdown component by preventing keypress events 2024-12-14 17:14:27 +01:00
9ad1028ac0 Use team/driver card components in data/season 2024-12-14 17:14:00 +01:00
b1f6865ad0 Lib: Implement team and driver cards 2024-12-14 17:13:45 +01:00
cbc5d32c54 Redirect to current page instead of "/" when logging in/out or changing/creating profile 2024-12-14 15:55:17 +01:00
a33a84825e Hooks: Load template avatar url if user didn't set one 2024-12-14 15:54:21 +01:00
a6c98e42ed Env: Add sharp for image conversion to avif 2024-12-14 15:53:43 +01:00
cda5ea7af7 Change Locals interface user type to User schema 2024-12-14 15:53:29 +01:00
a1e65c06c0 Display template graphics in data/season (for new driver/team etc.) 2024-12-14 15:52:59 +01:00
c88f26cc57 Load template graphics for data/season 2024-12-14 15:52:43 +01:00
555914b5c1 Lib: Replace get_by_id helper with more general get_by_value (key can be chosen) 2024-12-14 15:52:20 +01:00
833a7fe51b Lib: Add Graphic and User schemas 2024-12-14 15:51:58 +01:00
23ae4c03e5 Lib: Disable text input in Dropdown component 2024-12-14 15:51:48 +01:00
0fe4e22c4b Implement create_driver, update_driver and delete_driver routes 2024-12-14 03:35:28 +01:00
e9d1e9a319 Add team select to seasondata drivers page 2024-12-14 03:35:06 +01:00
ff8f375955 Env: Add UUID package 2024-12-14 03:34:39 +01:00
f731a7fce4 Lib: Update type information 2024-12-14 03:34:21 +01:00
f3b5dbbeee Lib: Implement dropdown + search/autocomplete components 2024-12-14 03:34:08 +01:00
c4b635b702 Add TS type information 2024-12-13 19:58:11 +01:00
f3a0b53ce6 Manually :hover the button to the current page in navbar 2024-12-13 14:47:03 +01:00
53351519a4 Move /user routes to /profile 2024-12-13 14:46:36 +01:00
e522785801 Lib: Disable input label text wrapping 2024-12-13 14:46:10 +01:00
a47fad1a4d Lib: Allow to manually enable :hover on Button 2024-12-13 14:46:02 +01:00
70edd0182d Migrate from DaisyUI to SkeletonUI 2024-12-12 23:29:24 +01:00
4f342e198a Env: Update tailwindcss safelist 2024-12-12 23:29:13 +01:00
4d928dc1c0 Env: Replace daisyui with skeletonui 2024-12-12 18:21:06 +01:00
377839ba7a Env: Update prettier config 2024-12-12 18:20:45 +01:00
ceb9cded9a Disable "draggable" on links and images 2024-12-12 12:49:49 +01:00
9ccf0422ec Move seasondata tabs into +layout.svelte 2024-12-12 12:10:56 +01:00
d9c8098fe2 Add stub page for drivers/races routes 2024-12-12 12:10:42 +01:00
e28ba36ab9 Lib: Rename forms.ts to form.ts 2024-12-12 12:09:53 +01:00
8d51f07699 Make "Login" button the default on enter (instead of "Register") 2024-12-12 11:43:36 +01:00
45b740c628 Teams: Implement seasondata/teams page + creation/deletion/updating 2024-12-12 11:43:03 +01:00
77ec3dee21 User: Add login/register/profile form handling 2024-12-12 11:43:03 +01:00
9df154b039 Add stub page for / route 2024-12-12 11:43:03 +01:00
2acc1ec585 Add main page skeleton (navbar) 2024-12-12 11:43:03 +01:00
4d7498cb85 Add request event handler for authentication 2024-12-12 11:43:03 +01:00
4cbba4b1ef Static: Add favicon + logo 2024-12-12 04:39:27 +01:00
615e79255c Env: Update devshell commands 2024-12-12 04:39:01 +01:00
036e17b7d5 Env: Update tailwind config + some other plugins 2024-12-12 04:38:52 +01:00
fabdb6a8e8 Add stub pages for some routes 2024-12-12 04:38:32 +01:00
00bbf83cb5 Lib: Add image preview helper 2024-12-12 04:38:00 +01:00
f715959af9 Lib: Add form helpers 2024-12-12 04:37:54 +01:00
c0c3e3d792 Components: Add index.ts for easier importing 2024-12-12 04:37:34 +01:00
6d812805ed Components: Add username input 2024-12-12 04:37:22 +01:00
5d375554af Components: Add password input 2024-12-12 04:37:17 +01:00
32667d1baf Components: Add text input 2024-12-12 04:37:11 +01:00
20d66cab5f Components: Add file input 2024-12-12 04:37:02 +01:00
4ab7bde49e Components: Add button 2024-12-12 04:36:54 +01:00
53 changed files with 4766 additions and 55 deletions

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

View File

@ -125,14 +125,19 @@
# Use $1 for positional args # Use $1 for positional args
commands = [ commands = [
{ {
name = "pycharm"; name = "db";
help = "Launch PyCharm Professional"; help = "Serve PocketBase";
command = "pycharm-professional . &>/dev/null &"; command = "pocketbase serve --http 192.168.86.50:8090";
} }
{ {
name = "db"; name = "dev";
help = "Launch SQLiteBrowser"; help = "Serve Formula 11 (Dev)";
command = "sqlitebrowser ./instance/formula10.db &>/dev/null &"; command = "npm run dev -- --host --port 5173";
}
{
name = "prod";
help = "Serve Formula 11 (Prod)";
command = "npm run build && npm run preview -- --host --port 5173";
} }
]; ];
}; };

2003
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,21 +10,32 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
}, },
"devDependencies": { "devDependencies": {
"@fsouza/prettierd": "^0.25.4",
"@skeletonlabs/skeleton": "^2.10.3",
"@skeletonlabs/tw-plugin": "^0.4.0",
"@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-node": "^5.2.10",
"@sveltejs/kit": "^2.9.0", "@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"svelte": "^5.0.0", "@tailwindcss/forms": "^0.5.9",
"svelte-check": "^4.1.1", "@types/node": "^22.10.2",
"typescript": "^5.0.0", "@types/uuid": "^10.0.0",
"vite": "^6.0.0"
},
"dependencies": {
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"pocketbase": "^0.22.1",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.2", "prettier-plugin-svelte": "^3.3.2",
"prettier-plugin-tailwindcss": "^0.6.9", "prettier-plugin-tailwindcss": "^0.6.9",
"tailwindcss": "^3.4.16" "svelte": "^5.0.0",
"svelte-check": "^4.1.1",
"tailwindcss": "^3.4.16",
"typescript": "^5.0.0",
"vite": "^6.0.0"
},
"dependencies": {
"@floating-ui/dom": "^1.6.12",
"date-fns": "^4.1.0",
"pocketbase": "^0.22.1",
"sharp": "^0.33.5",
"uuid": "^11.0.3"
} }
} }

View File

@ -1,6 +1,6 @@
export default { export default {
plugins: { plugins: {
tailwindcss: {}, tailwindcss: { config: "./tailwind.config.ts" },
autoprefixer: {}, 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 base;
@tailwind components; @tailwind components;
@tailwind utilities; @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);
}

22
src/app.d.ts vendored
View File

@ -1,13 +1,21 @@
import type { User } from "$lib/schema";
import type { PocketBase, RecordModel } from "pocketbase";
// See https://svelte.dev/docs/kit/types#app.d.ts // See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces // for information about these interfaces
declare global { declare global {
namespace App { namespace App {
// interface Error {} interface Locals {
// interface Locals {} pb: PocketBase;
// interface PageData {} user: User | undefined;
// interface PageState {} admin: boolean;
// interface Platform {}
} }
// interface Error {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
} }
export { }; export {};

View File

@ -2,11 +2,22 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<!-- Scale the viewport for mobile devices -->
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Set the iOS Safari header/tab bar color -->
<meta name="theme-color" content="#F8D7DA" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<!-- SvelteKit inserts the header contents here -->
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover">
<!-- Prefetch data specified in "load" functions on link hover -->
<body data-theme="formula11Theme" data-sveltekit-preload-data="hover">
<!-- SvelteKit inserts the body contents here -->
<div style="display: contents">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>
</body> </body>
</html> </html>

63
src/hooks.server.ts Normal file
View File

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

View File

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

View File

@ -0,0 +1,148 @@
<script lang="ts">
import { get_image_preview_event_handler } from "$lib/image";
import { FileDropzone, SlideToggle } from "@skeletonlabs/skeleton";
import LazyCard from "./LazyCard.svelte";
import Button from "./Button.svelte";
import type { Driver } from "$lib/schema";
import Input from "./Input.svelte";
import LazyDropdown, { type LazyDropdownOption } from "./LazyDropdown.svelte";
import {
DRIVER_CARD_ASPECT_HEIGHT,
DRIVER_CARD_ASPECT_WIDTH,
DRIVER_HEADSHOT_HEIGHT,
DRIVER_HEADSHOT_WIDTH,
} from "$lib/config";
interface DriverCardProps {
/** The [Driver] object used to prefill values. */
driver?: Driver | undefined;
/** Disable all inputs if [true] */
disable_inputs?: boolean;
/** Require all inputs if [true] */
require_inputs?: boolean;
/** The [src] of the driver headshot template preview */
headshot_template?: string;
/** The value this component's team select dropdown will bind to */
team_select_value: string;
/** The options this component's team select dropdown will display */
team_select_options: LazyDropdownOption[];
/** The value this component's active switch will bind to */
active_value: boolean;
}
let {
driver = undefined,
disable_inputs = false,
require_inputs = false,
headshot_template = undefined,
team_select_value,
team_select_options,
active_value,
}: DriverCardProps = $props();
</script>
<LazyCard
cardwidth={DRIVER_CARD_ASPECT_WIDTH}
cardheight={DRIVER_CARD_ASPECT_HEIGHT}
imgsrc={driver?.headshot_url ?? headshot_template}
imgwidth={DRIVER_HEADSHOT_WIDTH}
imgheight={DRIVER_HEADSHOT_HEIGHT}
imgid="update_driver_headshot_preview_{driver?.id ?? 'create'}"
>
<form method="POST" enctype="multipart/form-data">
<!-- This is also disabled, because the ID should only be -->
<!-- "leaked" to users that are allowed to use the inputs -->
{#if driver && !disable_inputs}
<input name="id" type="hidden" value={driver.id} />
{/if}
<div class="flex flex-col gap-2">
<!-- Driver name input -->
<Input
id="driver_first_name_{driver?.id ?? 'create'}"
name="firstname"
value={driver?.firstname ?? ""}
autocomplete="off"
labelwidth="120px"
disabled={disable_inputs}
required={require_inputs}
>First Name
</Input>
<Input
id="driver_last_name_{driver?.id ?? 'create'}"
name="lastname"
value={driver?.lastname ?? ""}
autocomplete="off"
labelwidth="120px"
disabled={disable_inputs}
required={require_inputs}
>Last Name
</Input>
<Input
id="driver_code_{driver?.id ?? 'create'}"
name="code"
value={driver?.code ?? ""}
autocomplete="off"
minlength={3}
maxlength={3}
labelwidth="120px"
disabled={disable_inputs}
required={require_inputs}
>Driver Code
</Input>
<!-- Driver team input -->
<LazyDropdown
name="team"
input_variable={team_select_value}
options={team_select_options}
labelwidth="120px"
disabled={disable_inputs}
required={require_inputs}
>Team
</LazyDropdown>
<!-- Headshot upload -->
<FileDropzone
name="headshot"
id="driver_headshot_{driver?.id ?? 'create'}"
onchange={get_image_preview_event_handler(
`update_driver_headshot_preview_${driver?.id ?? "create"}`,
)}
disabled={disable_inputs}
required={require_inputs}
>
<svelte:fragment slot="message"><b>Upload Headshot</b> or Drag and Drop</svelte:fragment>
</FileDropzone>
<!-- Save/Delete buttons -->
<div class="flex items-center justify-end gap-2">
<div class="mr-auto">
<SlideToggle
name="active"
background="bg-primary-500"
active="bg-tertiary-500"
bind:checked={active_value}
disabled={disable_inputs}
/>
</div>
{#if driver}
<Button formaction="?/update_driver" color="secondary" disabled={disable_inputs} submit
>Save Changes</Button
>
<Button color="primary" submit disabled={disable_inputs} formaction="?/delete_driver"
>Delete</Button
>
{:else}
<Button formaction="?/create_driver" color="tertiary" submit>Create Driver</Button>
{/if}
</div>
</div>
</form>
</LazyCard>

View File

@ -0,0 +1,26 @@
<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 type of the input element, e.g. "text". */
type?: string;
}
let { children, labelwidth = "auto", type = "text", ...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 {type} {...restProps} />
</div>

View File

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

View File

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

View File

@ -0,0 +1,61 @@
<script lang="ts">
import type { HTMLImgAttributes } from "svelte/elements";
import { lazyload } from "$lib/lazyload";
import { fetch_image_base64 } from "$lib/image";
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;
}
let {
src,
imgwidth,
imgheight,
imgstyle = undefined,
containerstyle = 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;
};
const img_opacity_handler = (node: HTMLElement) => {
setTimeout(() => (node.style.opacity = "1"), 20);
};
</script>
<!-- Show a correctly sized div so the layout doesn't jump. -->
<div
use:lazyload
onLazyVisible={lazy_visible_handler}
style="aspect-ratio: {imgwidth} / {imgheight}; {containerstyle ?? ''}"
>
{#if load}
{#await fetch_image_base64(src) then data}
<img
src={data}
use:img_opacity_handler
class="bg-surface-100 transition-opacity"
style="opacity: 0; transition-duration: 300ms; {imgstyle ?? ''}"
{...restProps}
/>
{/await}
{/if}
</div>

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

@ -0,0 +1,185 @@
<script lang="ts">
import { get_image_preview_event_handler } from "$lib/image";
import { FileDropzone } from "@skeletonlabs/skeleton";
import LazyCard from "./LazyCard.svelte";
import Button from "./Button.svelte";
import type { Race } from "$lib/schema";
import Input from "./Input.svelte";
import { format } from "date-fns";
import {
RACE_CARD_ASPECT_HEIGHT,
RACE_CARD_ASPECT_WIDTH,
RACE_PICTOGRAM_HEIGHT,
RACE_PICTOGRAM_WIDTH,
} from "$lib/config";
interface RaceCardProps {
/** The [Race] object used to prefill values. */
race?: Race | undefined;
/** Disable all inputs if [true] */
disable_inputs?: boolean;
/** Require all inputs if [true] */
require_inputs?: boolean;
/** The [src] of the race pictogram template preview */
pictogram_template?: string;
}
let {
race = undefined,
disable_inputs = false,
require_inputs = false,
pictogram_template = "",
}: RaceCardProps = $props();
// Dates have to be formatted to datetime-local format
const dateformat: string = "yyyy-MM-dd'T'HH:mm";
const sprintqualidate: string | undefined =
race && race.sprintqualidate ? format(new Date(race.sprintqualidate), dateformat) : undefined;
const sprintdate: string | undefined =
race && race.sprintdate ? format(new Date(race.sprintdate), dateformat) : undefined;
const qualidate: string | undefined = race
? format(new Date(race.qualidate), dateformat)
: undefined;
const racedate: string | undefined = race
? format(new Date(race.racedate), dateformat)
: undefined;
const clear_sprint = () => {
const sprintquali: HTMLInputElement = document.getElementById(
`race_sprintqualidate_${race?.id ?? "create"}`,
) as HTMLInputElement;
const sprint: HTMLInputElement = document.getElementById(
`race_sprintdate_${race?.id ?? "create"}`,
) as HTMLInputElement;
sprintquali.value = "";
sprint.value = "";
};
</script>
<LazyCard
cardwidth={RACE_CARD_ASPECT_WIDTH}
cardheight={RACE_CARD_ASPECT_HEIGHT}
imgsrc={race?.pictogram_url ?? pictogram_template}
imgwidth={RACE_PICTOGRAM_WIDTH}
imgheight={RACE_PICTOGRAM_HEIGHT}
imgid="update_race_pictogram_preview_{race?.id ?? 'create'}"
>
<form method="POST" enctype="multipart/form-data">
<!-- This is also disabled, because the ID should only be -->
<!-- "leaked" to users that are allowed to use the inputs -->
{#if race && !disable_inputs}
<input name="id" type="hidden" value={race.id} />
{/if}
<div class="flex flex-col gap-2">
<!-- Driver name input -->
<Input
id="race_name_{race?.id ?? 'create'}"
name="name"
value={race?.name ?? ""}
autocomplete="off"
labelwidth="120px"
disabled={disable_inputs}
required={require_inputs}>Name</Input
>
<Input
id="race_step_{race?.id ?? 'create'}"
name="step"
value={race?.step ?? ""}
autocomplete="off"
labelwidth="120px"
type="number"
min={1}
max={24}
disabled={disable_inputs}
required={require_inputs}>Step</Input
>
<Input
id="race_pxx_{race?.id ?? 'create'}"
name="pxx"
value={race?.pxx ?? ""}
autocomplete="off"
labelwidth="120px"
type="number"
min={1}
max={20}
disabled={disable_inputs}
required={require_inputs}>PXX</Input
>
<!-- NOTE: Input datetime-local accepts YYYY-mm-ddTHH:MM format -->
<Input
id="race_sprintqualidate_{race?.id ?? 'create'}"
name="sprintqualidate"
value={sprintqualidate ?? ""}
autocomplete="off"
labelwidth="120px"
type="datetime-local"
disabled={disable_inputs}>Sprint Quali</Input
>
<Input
id="race_sprintdate_{race?.id ?? 'create'}"
name="sprintdate"
value={sprintdate ?? ""}
autocomplete="off"
labelwidth="120px"
type="datetime-local"
disabled={disable_inputs}>Sprint</Input
>
<Input
id="race_qualidate_{race?.id ?? 'create'}"
name="qualidate"
value={qualidate ?? ""}
autocomplete="off"
labelwidth="120px"
type="datetime-local"
disabled={disable_inputs}
required={require_inputs}>Quali</Input
>
<Input
id="race_racedate_{race?.id ?? 'create'}"
name="racedate"
value={racedate ?? ""}
autocomplete="off"
labelwidth="120px"
type="datetime-local"
disabled={disable_inputs}
required={require_inputs}>Sprint Quali</Input
>
<!-- Headshot upload -->
<FileDropzone
name="pictogram"
id="race_pictogram_{race?.id ?? 'create'}"
onchange={get_image_preview_event_handler(
`update_race_pictogram_preview_${race?.id ?? "create"}`,
)}
disabled={disable_inputs}
required={require_inputs}
>
<svelte:fragment slot="message"><b>Upload Pictogram</b> or Drag and Drop</svelte:fragment>
</FileDropzone>
<!-- Save/Delete buttons -->
<div class="flex justify-end gap-2">
<Button onclick={clear_sprint} color="secondary" disabled={disable_inputs}
>Remove Sprint</Button
>
{#if race}
<Button formaction="?/update_race" color="secondary" disabled={disable_inputs} submit
>Save Changes</Button
>
<Button color="primary" submit disabled={disable_inputs} formaction="?/delete_race"
>Delete</Button
>
{:else}
<Button formaction="?/create_race" color="tertiary" submit>Create Race</Button>
{/if}
</div>
</div>
</form>
</LazyCard>

View File

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

View File

@ -0,0 +1,159 @@
<script lang="ts">
import LazyCard from "./LazyCard.svelte";
import Button from "./Button.svelte";
import type { Driver, Substitution } from "$lib/schema";
import { get_by_value } from "$lib/database";
import LazyDropdown, { type LazyDropdownOption } from "./LazyDropdown.svelte";
import type { Action } from "svelte/action";
import {
DRIVER_HEADSHOT_HEIGHT,
DRIVER_HEADSHOT_WIDTH,
SUBSTITUTION_CARD_ASPECT_HEIGHT,
SUBSTITUTION_CARD_ASPECT_WIDTH,
} from "$lib/config";
interface SubstitutionCardProps {
/** The [Substitution] object used to prefill values. */
substitution?: Substitution | undefined;
/** The drivers (to display the headshot) */
drivers: Driver[];
/** Disable all inputs if [true] */
disable_inputs?: boolean;
/** Require all inputs if [true] */
require_inputs?: boolean;
/** The [src] of the driver headshot template preview */
headshot_template?: string;
/** The value this component's substitute select dropdown will bind to */
substitute_select_value: string;
/** The value this component's driver select dropdown will bind to */
driver_select_value: string;
/** The value this component's race select dropdown will bind to */
race_select_value: string;
/** The options this component's substitute/driver select dropdowns will display */
driver_select_options: LazyDropdownOption[];
/** The options this component's race select dropdown will display */
race_select_options: LazyDropdownOption[];
}
let {
substitution = undefined,
drivers,
disable_inputs = false,
require_inputs = false,
headshot_template = "",
substitute_select_value,
driver_select_value,
race_select_value,
driver_select_options,
race_select_options,
}: SubstitutionCardProps = $props();
// This action is used on the <Dropdown> element.
// It will trigger once the Dropdown's <input> elements is mounted.
// This way we'll receive a reference to the object so we can register our event handler.
const register_substitute_preview_handler: Action = (node: HTMLElement) => {
node.addEventListener("DropdownChange", update_substitute_preview);
};
// This event handler is registered to the Dropdown's <input> element through the action above.
const update_substitute_preview = (event: Event) => {
const target: HTMLInputElement = event.target as HTMLInputElement;
// The option "label" gets put into the Dropdown's input value,
// so we need to lookup the driver by "code".
const src: string = get_by_value(drivers, "code", target.value)?.headshot_url || "";
if (src) {
const preview: HTMLImageElement = document.getElementById(
`update_substitution_headshot_preview_${substitution?.id ?? "create"}`,
) as HTMLImageElement;
if (preview) preview.src = src;
}
};
</script>
<LazyCard
cardwidth={SUBSTITUTION_CARD_ASPECT_WIDTH}
cardheight={SUBSTITUTION_CARD_ASPECT_HEIGHT}
imgsrc={get_by_value(drivers, "id", substitution?.substitute ?? "")?.headshot_url ??
headshot_template}
imgwidth={DRIVER_HEADSHOT_WIDTH}
imgheight={DRIVER_HEADSHOT_HEIGHT}
imgid="update_substitution_headshot_preview_{substitution?.id ?? 'create'}"
>
<form method="POST" enctype="multipart/form-data">
<!-- This is also disabled, because the ID should only be -->
<!-- "leaked" to users that are allowed to use the inputs -->
{#if substitution && !disable_inputs}
<input name="id" type="hidden" value={substitution.id} />
{/if}
<div class="flex flex-col gap-2">
<!-- Substitute select -->
<LazyDropdown
name="substitute"
input_variable={substitute_select_value}
action={register_substitute_preview_handler}
options={driver_select_options}
labelwidth="120px"
disabled={disable_inputs}
required={require_inputs}
>
Substitute
</LazyDropdown>
<!-- Driver select -->
<LazyDropdown
name="for"
input_variable={driver_select_value}
options={driver_select_options}
labelwidth="120px"
disabled={disable_inputs}
required={require_inputs}
>For
</LazyDropdown>
<!-- Race select -->
<LazyDropdown
name="race"
input_variable={race_select_value}
options={race_select_options}
labelwidth="120px"
disabled={disable_inputs}
required={require_inputs}
>Race
</LazyDropdown>
<!-- Save/Delete buttons -->
<div class="flex justify-end gap-2">
{#if substitution}
<Button
formaction="?/update_substitution"
color="secondary"
disabled={disable_inputs}
submit>Save Changes</Button
>
<Button
color="primary"
submit
disabled={disable_inputs}
formaction="?/delete_substitution">Delete</Button
>
{:else}
<Button formaction="?/create_substitution" color="tertiary" submit
>Create Substitution</Button
>
{/if}
</div>
</div>
</form>
</LazyCard>

View File

@ -0,0 +1,93 @@
<script lang="ts">
import { get_image_preview_event_handler } from "$lib/image";
import { FileDropzone } from "@skeletonlabs/skeleton";
import Button from "./Button.svelte";
import type { Team } from "$lib/schema";
import Input from "./Input.svelte";
import {
TEAM_CARD_ASPECT_HEIGHT,
TEAM_CARD_ASPECT_WIDTH,
TEAM_LOGO_HEIGHT,
TEAM_LOGO_WIDTH,
} from "$lib/config";
import LazyCard from "./LazyCard.svelte";
interface TeamCardProps {
/** The [Team] object used to prefill values. */
team?: Team | undefined;
/** Disable all inputs if [true] */
disable_inputs?: boolean;
/** Require all inputs if [true] */
require_inputs?: boolean;
/** The [src] of the team logo template preview */
logo_template?: string;
}
let {
team = undefined,
disable_inputs = false,
require_inputs = false,
logo_template = "",
}: TeamCardProps = $props();
</script>
<LazyCard
cardwidth={TEAM_CARD_ASPECT_WIDTH}
cardheight={TEAM_CARD_ASPECT_HEIGHT}
imgsrc={team?.logo_url ?? logo_template}
imgwidth={TEAM_LOGO_WIDTH}
imgheight={TEAM_LOGO_HEIGHT}
imgid="update_team_logo_preview_{team?.id ?? 'create'}"
>
<form method="POST" enctype="multipart/form-data">
<!-- This is also disabled, because the ID should only be -->
<!-- "leaked" to users that are allowed to use the inputs -->
{#if team && !disable_inputs}
<input name="id" type="hidden" value={team.id} />
{/if}
<div class="flex flex-col gap-2">
<!-- Team name input -->
<Input
id="team_name_{team?.id ?? 'create'}"
name="name"
value={team?.name ?? ""}
autocomplete="off"
disabled={disable_inputs}
required={require_inputs}
>
Name
</Input>
<!-- Logo upload -->
<FileDropzone
name="logo"
id="team_logo_{team?.id ?? 'create'}"
onchange={get_image_preview_event_handler(
`update_team_logo_preview_${team?.id ?? "create"}`,
)}
disabled={disable_inputs}
required={require_inputs}
>
<svelte:fragment slot="message"><b>Upload Logo</b> or Drag and Drop</svelte:fragment>
</FileDropzone>
<!-- Save/Delete buttons -->
<div class="flex justify-end gap-2">
{#if team}
<Button formaction="?/update_team" color="secondary" disabled={disable_inputs} submit>
Save Changes
</Button>
<Button color="primary" submit disabled={disable_inputs} formaction="?/delete_team">
Delete
</Button>
{:else}
<Button formaction="?/create_team" color="tertiary" submit>Create Team</Button>
{/if}
</div>
</div>
</form>
</LazyCard>

View File

@ -0,0 +1,35 @@
import Button from "./Button.svelte";
import DriverCard from "./DriverCard.svelte";
import Input from "./Input.svelte";
import LazyCard from "./LazyCard.svelte";
import LazyDropdown from "./LazyDropdown.svelte";
import LazyImage from "./LazyImage.svelte";
import LoadingIndicator from "./LoadingIndicator.svelte";
import RaceCard from "./RaceCard.svelte";
import Search from "./Search.svelte";
import SubstitutionCard from "./SubstitutionCard.svelte";
import TeamCard from "./TeamCard.svelte";
import MenuDrawerIcon from "./svg/MenuDrawerIcon.svelte";
import PasswordIcon from "./svg/PasswordIcon.svelte";
import UserIcon from "./svg/UserIcon.svelte";
export {
// Components
Button,
DriverCard,
Input,
LazyCard,
LazyDropdown,
LazyImage,
LoadingIndicator,
RaceCard,
Search,
SubstitutionCard,
TeamCard,
// SVG
MenuDrawerIcon,
PasswordIcon,
UserIcon,
};

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,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,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

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

@ -0,0 +1,31 @@
// 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_LOGO_WIDTH: number = 512;
export const TEAM_LOGO_HEIGHT: number = 288;
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;

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

@ -0,0 +1,11 @@
/**
* 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));
};

70
src/lib/form.ts Normal file
View File

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

85
src/lib/image.ts Normal file
View File

@ -0,0 +1,85 @@
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 (preview) {
preview.src = src;
preview.hidden = false;
}
}
};
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");
};

View File

@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.

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

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

@ -0,0 +1,51 @@
export interface Graphic {
name: string;
file: string;
file_url?: string;
}
export interface User {
id: string;
username: string;
avatar: string;
avatar_url?: string;
admin: boolean;
}
export interface Team {
id: string;
name: string;
logo: string;
logo_url?: string;
}
export interface Driver {
id: string;
firstname: string;
lastname: string;
code: string;
headshot: string;
headshot_url?: string;
team: string;
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;
}

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

@ -0,0 +1,23 @@
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 | undefined = undefined,
height: number | undefined = undefined,
quality: number = 50,
effort: number = 4,
): Promise<Blob> => {
const compressed: Buffer = await sharp(data)
.resize(width, height)
.avif({ quality: quality, effort: effort })
.toBuffer();
console.log(`image_to_avif: ${data.byteLength} Bytes -> ${compressed.length} Bytes`);
return new Blob([compressed]);
};

View File

@ -0,0 +1,19 @@
import type { LayoutServerLoad } from "./$types";
// On each page load (every route), this function runs serverside.
// The "locals.user" object is only available on the server,
// since it's populated inside hooks.server.ts per request.
// It will populate the "user" attribute of each page's "data" object,
// so each page has access to the current user (or knows if no one is signed in).
export const load: LayoutServerLoad = ({ locals }) => {
if (locals.user) {
return {
user: locals.user,
admin: locals.user.admin,
};
}
return {
user: undefined,
};
};

284
src/routes/+layout.svelte Normal file
View File

@ -0,0 +1,284 @@
<script lang="ts">
import "../app.css";
import type { Snippet } from "svelte";
import type { LayoutData } from "./$types";
import { page } from "$app/stores";
import {
Button,
MenuDrawerIcon,
UserIcon,
Input,
PasswordIcon,
LoadingIndicator,
} from "$lib/components";
import { get_avatar_preview_event_handler } from "$lib/image";
import {
AppBar,
storePopup,
initializeStores,
Drawer,
getDrawerStore,
type DrawerSettings,
Avatar,
FileDropzone,
type DrawerStore,
} from "@skeletonlabs/skeleton";
import { computePosition, autoUpdate, offset, shift, flip, arrow } from "@floating-ui/dom";
let { data, children }: { data: LayoutData; children: Snippet } = $props();
// Drawer config
initializeStores();
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: "lg: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 });
// Example: https://www.skeleton.dev/utilities/popups
// const data_popup_settings: PopupSettings = {
// event: "click",
// target: "data_popup",
// placement: "bottom",
// middleware: {
// offset: { mainAxis: 22, crossAxis: 0 },
// // shift: { mainAxis: true, crossAxis: false },
// },
// };
</script>
<LoadingIndicator />
<Drawer>
<!-- 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" fullwidth>Race Picks</Button>
<Button href="/seasonpicks" onclick={close_drawer} color="surface" fullwidth
>Season Picks
</Button>
<Button href="/leaderboard" onclick={close_drawer} color="surface" fullwidth
>Leaderboard
</Button>
<Button href="/statistics" onclick={close_drawer} color="surface" fullwidth
>Statistics
</Button>
<Button href="/rules" onclick={close_drawer} color="surface" fullwidth>Rules</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/raceresult" onclick={close_drawer} color="surface" fullwidth
>Race Results
</Button>
<Button href="/data/season" onclick={close_drawer} color="surface" fullwidth>Season</Button>
<Button href="/data/user" onclick={close_drawer} color="surface" fullwidth>Users</Button>
</div>
{:else if $drawerStore.id === "login_drawer"}
<!-- Login Drawer -->
<!-- Login Drawer -->
<!-- Login Drawer -->
<div class="flex flex-col gap-2 p-2 pt-3">
<h4 class="h4 select-none">Enter Username and Password</h4>
<form method="POST" class="contents">
<!-- Supply the pathname so the form can redirect to the current page. -->
<input type="hidden" name="redirect_url" value={$page.url.pathname} />
<Input name="username" placeholder="Username" autocomplete="username" required
><UserIcon />
</Input>
<Input name="password" type="password" placeholder="Password" autocomplete="off" required
><PasswordIcon />
</Input>
<div class="flex justify-end gap-2">
<Button formaction="/profile?/login" onclick={close_drawer} color="tertiary" submit
>Login
</Button>
<Button
formaction="/profile?/create_profile"
onclick={close_drawer}
color="tertiary"
submit
>Register
</Button>
</div>
</form>
</div>
{:else if $drawerStore.id === "profile_drawer" && data.user}
<!-- Profile Drawer -->
<!-- Profile Drawer -->
<!-- Profile Drawer -->
<div class="flex flex-col gap-2 p-2 pt-3">
<h4 class="h4 select-none">Edit Profile</h4>
<form method="POST" enctype="multipart/form-data" class="contents">
<!-- Supply the pathname so the form can redirect to the current page. -->
<input type="hidden" name="redirect_url" value={$page.url.pathname} />
<input type="hidden" name="id" value={data.user.id} />
<Input
name="username"
value={data.user.username}
placeholder="Username"
autocomplete="username"
>
<UserIcon />
</Input>
<FileDropzone
name="avatar"
onchange={get_avatar_preview_event_handler("user_avatar_preview")}
>
<svelte:fragment slot="message"><b>Upload Avatar</b> or Drag and Drop</svelte:fragment>
</FileDropzone>
<div class="flex justify-end gap-2">
<Button
formaction="/profile?/update_profile"
onclick={close_drawer}
color="secondary"
submit
>
Save Changes
</Button>
<Button formaction="/profile?/logout" onclick={close_drawer} color="primary" submit>
Logout
</Button>
</div>
</form>
</div>
{/if}
</Drawer>
<nav>
<div class="fixed left-0 right-0 top-0 z-50">
<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="/" color="primary"><span class="text-xl font-bold">Formula 11</span></Button
>
</div>
</svelte:fragment>
<!-- Large navigation -->
<div class="hidden gap-2 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>
</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 !data.user}
<!-- Login drawer -->
<Button color="primary" onclick={login_drawer}>Login</Button>
{:else}
<!-- Profile drawer -->
<Avatar
id="user_avatar_preview"
src={data.user.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" style="margin-top: 60px;">
{@render children()}
</div>

View File

@ -1,2 +1,5 @@
<h1>Welcome to SvelteKit</h1> <svelte:head>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p> <title>F11 - Formula 11</title>
</svelte:head>
<h1>Formula 11</h1>

View File

@ -0,0 +1,298 @@
import type { ActionData, Actions, PageServerLoad } from "./$types";
import {
form_data_clean,
form_data_ensure_keys,
form_data_fix_dates,
form_data_get_and_remove_id,
} from "$lib/form";
import type { Team, Driver, Race, Substitution, Graphic } from "$lib/schema";
import { image_to_avif } from "$lib/server/image";
import {
DRIVER_HEADSHOT_HEIGHT,
DRIVER_HEADSHOT_WIDTH,
RACE_PICTOGRAM_HEIGHT,
RACE_PICTOGRAM_WIDTH,
TEAM_LOGO_HEIGHT,
TEAM_LOGO_WIDTH,
} from "$lib/config";
// These "actions" run serverside only, as they're located inside +page.server.ts
export const actions = {
create_team: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
const data: FormData = form_data_clean(await request.formData());
form_data_ensure_keys(data, ["name", "logo"]);
// Compress logo
const compressed: Blob = await image_to_avif(
await (data.get("logo") as File).arrayBuffer(),
TEAM_LOGO_WIDTH,
TEAM_LOGO_HEIGHT,
);
data.set("logo", compressed);
await locals.pb.collection("teams").create(data);
return { tab: 0 };
},
update_team: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
const data: FormData = form_data_clean(await request.formData());
const id: string = form_data_get_and_remove_id(data);
if (data.has("logo")) {
// Compress logo
const compressed: Blob = await image_to_avif(
await (data.get("logo") as File).arrayBuffer(),
TEAM_LOGO_WIDTH,
TEAM_LOGO_HEIGHT,
);
data.set("logo", compressed);
}
await locals.pb.collection("teams").update(id, data);
return { tab: 0 };
},
delete_team: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
const data: FormData = form_data_clean(await request.formData());
const id: string = form_data_get_and_remove_id(data);
await locals.pb.collection("teams").delete(id);
return { tab: 0 };
},
create_driver: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
const data: FormData = form_data_clean(await request.formData());
form_data_ensure_keys(data, ["firstname", "lastname", "code", "team", "headshot"]);
// The toggle switch will report "on" or nothing
data.set("active", data.has("active") ? "true" : "false");
// Compress headshot
const compressed: Blob = await image_to_avif(
await (data.get("headshot") as File).arrayBuffer(),
DRIVER_HEADSHOT_WIDTH,
DRIVER_HEADSHOT_HEIGHT,
);
data.set("headshot", compressed);
await locals.pb.collection("drivers").create(data);
return { tab: 1 };
},
update_driver: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
const data: FormData = form_data_clean(await request.formData());
const id: string = form_data_get_and_remove_id(data);
// The toggle switch will report "on" or nothing
data.set("active", data.has("active") ? "true" : "false");
if (data.has("headshot")) {
// Compress headshot
const compressed: Blob = await image_to_avif(
await (data.get("headshot") as File).arrayBuffer(),
DRIVER_HEADSHOT_WIDTH,
DRIVER_HEADSHOT_HEIGHT,
);
data.set("headshot", compressed);
}
await locals.pb.collection("drivers").update(id, data);
return { tab: 1 };
},
delete_driver: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
const data: FormData = form_data_clean(await request.formData());
const id: string = form_data_get_and_remove_id(data);
await locals.pb.collection("drivers").delete(id);
return { tab: 1 };
},
create_race: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
const data: FormData = form_data_clean(await request.formData());
form_data_ensure_keys(data, ["name", "step", "pictogram", "pxx", "qualidate", "racedate"]);
form_data_fix_dates(data, ["sprintqualidate", "sprintdate", "qualidate", "racedate"]);
// Compress pictogram
const compressed: Blob = await image_to_avif(
await (data.get("pictogram") as File).arrayBuffer(),
RACE_PICTOGRAM_WIDTH,
RACE_PICTOGRAM_HEIGHT,
);
data.set("pictogram", compressed);
await locals.pb.collection("races").create(data);
return { tab: 2 };
},
update_race: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
// Do not remove empty sprint dates so they can be cleared by updating the record
const data: FormData = form_data_clean(await request.formData(), [
"sprintqualidate",
"sprintdate",
]);
form_data_fix_dates(data, ["sprintqualidate", "sprintdate", "qualidate", "racedate"]);
const id: string = form_data_get_and_remove_id(data);
if (data.has("pictogram")) {
// Compress pictogram
const compressed: Blob = await image_to_avif(
await (data.get("pictogram") as File).arrayBuffer(),
RACE_PICTOGRAM_WIDTH,
RACE_PICTOGRAM_HEIGHT,
);
data.set("pictogram", compressed);
}
await locals.pb.collection("races").update(id, data);
return { tab: 2 };
},
delete_race: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
const data: FormData = form_data_clean(await request.formData());
const id: string = form_data_get_and_remove_id(data);
await locals.pb.collection("races").delete(id);
return { tab: 2 };
},
create_substitution: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
const data: FormData = form_data_clean(await request.formData());
form_data_ensure_keys(data, ["substitute", "for", "race"]);
await locals.pb.collection("substitutions").create(data);
return { tab: 3 };
},
update_substitution: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
const data: FormData = form_data_clean(await request.formData());
const id: string = form_data_get_and_remove_id(data);
await locals.pb.collection("substitutions").update(id, data);
return { tab: 3 };
},
delete_substitution: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
const data: FormData = form_data_clean(await request.formData());
const id: string = form_data_get_and_remove_id(data);
await locals.pb.collection("substitutions").delete(id);
return { tab: 3 };
},
} satisfies Actions;
// This "load" function runs serverside only, as it's located inside +page.server.ts
export const load: PageServerLoad = async ({ fetch, locals }) => {
const fetch_graphics = async (): Promise<Graphic[]> => {
const graphics: Graphic[] = await locals.pb
.collection("graphics")
.getFullList({ fetch: fetch });
graphics.map((graphic: Graphic) => {
graphic.file_url = locals.pb.files.getURL(graphic, graphic.file);
});
return graphics;
};
const fetch_teams = async (): Promise<Team[]> => {
const teams: Team[] = await locals.pb.collection("teams").getFullList({
sort: "+name",
fetch: fetch,
});
teams.map((team: Team) => {
team.logo_url = locals.pb.files.getURL(team, team.logo);
});
return teams;
};
const fetch_drivers = async (): Promise<Driver[]> => {
const drivers: Driver[] = await locals.pb.collection("drivers").getFullList({
sort: "+lastname",
fetch: fetch,
});
drivers.map((driver: Driver) => {
driver.headshot_url = locals.pb.files.getURL(driver, driver.headshot);
});
return drivers;
};
const fetch_races = async (): Promise<Race[]> => {
const races: Race[] = await locals.pb.collection("races").getFullList({
sort: "+step",
fetch: fetch,
});
races.map((race: Race) => {
race.pictogram_url = locals.pb.files.getURL(race, race.pictogram);
});
return races;
};
const fetch_substitutions = async (): Promise<Substitution[]> => {
// TODO: Sort by race step (does the race need to be expanded for this?)
const substitutions: Substitution[] = await locals.pb.collection("substitutions").getFullList({
fetch: fetch,
});
return substitutions;
};
return {
// Graphics and teams are awaited, since those are visible on page load.
graphics: await fetch_graphics(),
teams: await fetch_teams(),
// The rest is streamed gradually, since the user has to switch tabs to need them.
drivers: fetch_drivers(),
races: fetch_races(),
substitutions: fetch_substitutions(),
};
};

View File

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

View File

@ -0,0 +1 @@
<h1>User Data</h1>

View File

@ -0,0 +1 @@
<h1>Leaderboard</h1>

View File

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

View File

@ -0,0 +1 @@
<h1>Race Picks</h1>

View File

@ -0,0 +1 @@
<h1>Rules</h1>

View File

@ -0,0 +1 @@
<h1>Season Picks</h1>

View File

@ -0,0 +1 @@
<h1>Statistics</h1>

16
static/f1_logo.svg Normal file
View File

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 120 30" version="1.1" class="injected-svg js-svg-inject">
<!-- Generator: Sketch 49.1 (51147) - http://www.bohemiancoding.com/sketch -->
<title>Logos / F1-logo red</title>
<desc>Created with Sketch.</desc>
<defs>
<path d="M101.086812,30 L101.711812,30 L101.711812,27.106875 L101.722437,27.106875 L102.761812,30 L103.302437,30 L104.341812,27.106875 L104.352437,27.106875 L104.352437,30 L104.977437,30 L104.977437,26.25125 L104.063687,26.25125 L103.055562,29.18625 L103.044937,29.18625 L102.011187,26.25125 L101.086812,26.25125 L101.086812,30 Z M97.6274375,26.818125 L98.8136875,26.818125 L98.8136875,30 L99.4699375,30 L99.4699375,26.818125 L100.661812,26.818125 L100.661812,26.25125 L97.6274375,26.25125 L97.6274375,26.818125 Z M89.9999375,30 L119.999937,0 L101.943687,0 L71.9443125,30 L89.9999375,30 Z M85.6986875,13.065 L49.3818125,13.065 C38.3136875,13.065 36.3768125,13.651875 31.6361875,18.3925 C27.2024375,22.82625 20.0005625,30 20.0005625,30 L35.7324375,30 L39.4855625,26.246875 C41.9530625,23.779375 43.2255625,23.52375 48.4068125,23.52375 L75.2405625,23.52375 L85.6986875,13.065 Z M31.1518125,16.253125 C27.8774375,19.3425 20.7530625,26.263125 16.9130625,30 L-6.25e-05,30 C-6.25e-05,30 13.5524375,16.486875 21.0849375,9.0725 C28.8455625,1.685 32.7143125,0 46.9486875,0 L98.7643125,0 L87.5449375,11.21875 L48.0011875,11.21875 C37.9993125,11.21875 35.7518125,11.911875 31.1518125,16.253125 Z" id="path-1"/>
</defs>
<g id="Logos-/-F1-logo-red" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Page-1">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"/>
</mask>
<use id="Fill-1" fill="#EE0000" xlink:href="#path-1"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

16
static/favicon.svg Normal file
View File

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 120 60" version="1.1" class="injected-svg js-svg-inject">
<!-- Generator: Sketch 49.1 (51147) - http://www.bohemiancoding.com/sketch -->
<title>Logos / F1-logo red</title>
<desc>Created with Sketch.</desc>
<defs>
<path d="M101.086812,30 L101.711812,30 L101.711812,27.106875 L101.722437,27.106875 L102.761812,30 L103.302437,30 L104.341812,27.106875 L104.352437,27.106875 L104.352437,30 L104.977437,30 L104.977437,26.25125 L104.063687,26.25125 L103.055562,29.18625 L103.044937,29.18625 L102.011187,26.25125 L101.086812,26.25125 L101.086812,30 Z M97.6274375,26.818125 L98.8136875,26.818125 L98.8136875,30 L99.4699375,30 L99.4699375,26.818125 L100.661812,26.818125 L100.661812,26.25125 L97.6274375,26.25125 L97.6274375,26.818125 Z M89.9999375,30 L119.999937,0 L101.943687,0 L71.9443125,30 L89.9999375,30 Z M85.6986875,13.065 L49.3818125,13.065 C38.3136875,13.065 36.3768125,13.651875 31.6361875,18.3925 C27.2024375,22.82625 20.0005625,30 20.0005625,30 L35.7324375,30 L39.4855625,26.246875 C41.9530625,23.779375 43.2255625,23.52375 48.4068125,23.52375 L75.2405625,23.52375 L85.6986875,13.065 Z M31.1518125,16.253125 C27.8774375,19.3425 20.7530625,26.263125 16.9130625,30 L-6.25e-05,30 C-6.25e-05,30 13.5524375,16.486875 21.0849375,9.0725 C28.8455625,1.685 32.7143125,0 46.9486875,0 L98.7643125,0 L87.5449375,11.21875 L48.0011875,11.21875 C37.9993125,11.21875 35.7518125,11.911875 31.1518125,16.253125 Z" id="path-1"/>
</defs>
<g id="Logos-/-F1-logo-red" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Page-1">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"/>
</mask>
<use id="Fill-1" fill="#EE0000" xlink:href="#path-1"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -1,4 +1,4 @@
import adapter from "@sveltejs/adapter-auto"; import adapter from "@sveltejs/adapter-node";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */

View File

@ -1,8 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./src/**/*.{html,js,svelte,ts}"],
theme: {
extend: {},
},
plugins: [],
};

37
tailwind.config.ts Normal file
View File

@ -0,0 +1,37 @@
import type { Config } from "tailwindcss";
import { skeleton } from "@skeletonlabs/tw-plugin";
import { join } from "path";
import { formula11Theme } from "./tailwind.formula11";
import forms from "@tailwindcss/forms";
const config = {
content: [
"./src/**/*.{html,js,svelte,ts}",
join(require.resolve("@skeletonlabs/skeleton"), "../**/*.{html,js,svelte,ts}"),
],
theme: {
extend: {},
},
plugins: [
forms,
skeleton({
themes: {
custom: [formula11Theme],
},
}),
],
safelist: [
// List all patterns that are used dynamically, e.g. class="variant-filled-{color}"
{
pattern: /variant-filled-+/,
},
{
pattern: /w-full/,
},
{
pattern: /w-auto/,
},
],
} satisfies Config;
export default config;

101
tailwind.formula11.ts Normal file
View File

@ -0,0 +1,101 @@
import type { CustomThemeConfig } from "@skeletonlabs/tw-plugin";
export const formula11Theme: CustomThemeConfig = {
name: "formula11Theme",
properties: {
// =~= Theme Properties =~=
"--theme-font-family-base": `Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'`,
"--theme-font-family-heading": `Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'`,
"--theme-font-color-base": "0 0 0",
"--theme-font-color-dark": "255 255 255",
"--theme-rounded-base": "8px",
"--theme-rounded-container": "8px",
"--theme-border-base": "1px",
// =~= Theme On-X Colors =~=
"--on-primary": "0 0 0",
"--on-secondary": "0 0 0",
"--on-tertiary": "0 0 0",
"--on-success": "var(--color-primary-50)",
"--on-warning": "0 0 0",
"--on-error": "255 255 255",
"--on-surface": "0 0 0",
// =~= Theme Colors =~=
// primary | #F8D7DA
"--color-primary-50": "254 249 249", // #fef9f9
"--color-primary-100": "254 247 248", // #fef7f8
"--color-primary-200": "253 245 246", // #fdf5f6
"--color-primary-300": "252 239 240", // #fceff0
"--color-primary-400": "250 227 229", // #fae3e5
"--color-primary-500": "248 215 218", // #F8D7DA
"--color-primary-600": "223 194 196", // #dfc2c4
"--color-primary-700": "186 161 164", // #baa1a4
"--color-primary-800": "149 129 131", // #958183
"--color-primary-900": "122 105 107", // #7a696b
// secondary | #FFF3CD
"--color-secondary-50": "255 253 248", // #fffdf8
"--color-secondary-100": "255 253 245", // #fffdf5
"--color-secondary-200": "255 252 243", // #fffcf3
"--color-secondary-300": "255 250 235", // #fffaeb
"--color-secondary-400": "255 247 220", // #fff7dc
"--color-secondary-500": "255 243 205", // #FFF3CD
"--color-secondary-600": "230 219 185", // #e6dbb9
"--color-secondary-700": "191 182 154", // #bfb69a
"--color-secondary-800": "153 146 123", // #99927b
"--color-secondary-900": "125 119 100", // #7d7764
// tertiary | #D1E7DD
"--color-tertiary-50": "248 251 250", // #f8fbfa
"--color-tertiary-100": "246 250 248", // #f6faf8
"--color-tertiary-200": "244 249 247", // #f4f9f7
"--color-tertiary-300": "237 245 241", // #edf5f1
"--color-tertiary-400": "223 238 231", // #dfeee7
"--color-tertiary-500": "209 231 221", // #D1E7DD
"--color-tertiary-600": "188 208 199", // #bcd0c7
"--color-tertiary-700": "157 173 166", // #9dada6
"--color-tertiary-800": "125 139 133", // #7d8b85
"--color-tertiary-900": "102 113 108", // #66716c
// success | #198754
"--color-success-50": "221 237 229", // #ddede5
"--color-success-100": "209 231 221", // #d1e7dd
"--color-success-200": "198 225 212", // #c6e1d4
"--color-success-300": "163 207 187", // #a3cfbb
"--color-success-400": "94 171 135", // #5eab87
"--color-success-500": "25 135 84", // #198754
"--color-success-600": "23 122 76", // #177a4c
"--color-success-700": "19 101 63", // #13653f
"--color-success-800": "15 81 50", // #0f5132
"--color-success-900": "12 66 41", // #0c4229
// warning | #FFC107
"--color-warning-50": "255 246 218", // #fff6da
"--color-warning-100": "255 243 205", // #fff3cd
"--color-warning-200": "255 240 193", // #fff0c1
"--color-warning-300": "255 230 156", // #ffe69c
"--color-warning-400": "255 212 81", // #ffd451
"--color-warning-500": "255 193 7", // #FFC107
"--color-warning-600": "230 174 6", // #e6ae06
"--color-warning-700": "191 145 5", // #bf9105
"--color-warning-800": "153 116 4", // #997404
"--color-warning-900": "125 95 3", // #7d5f03
// error | #DC3545
"--color-error-50": "250 225 227", // #fae1e3
"--color-error-100": "248 215 218", // #f8d7da
"--color-error-200": "246 205 209", // #f6cdd1
"--color-error-300": "241 174 181", // #f1aeb5
"--color-error-400": "231 114 125", // #e7727d
"--color-error-500": "220 53 69", // #DC3545
"--color-error-600": "198 48 62", // #c6303e
"--color-error-700": "165 40 52", // #a52834
"--color-error-800": "132 32 41", // #842029
"--color-error-900": "108 26 34", // #6c1a22
// surface | #DDDDDD
"--color-surface-50": "250 250 250", // #fafafa
"--color-surface-100": "248 248 248", // #f8f8f8
"--color-surface-200": "247 247 247", // #f7f7f7
"--color-surface-300": "241 241 241", // #f1f1f1
"--color-surface-400": "231 231 231", // #e7e7e7
"--color-surface-500": "221 221 221", // #DDDDDD
"--color-surface-600": "199 199 199", // #c7c7c7
"--color-surface-700": "166 166 166", // #a6a6a6
"--color-surface-800": "133 133 133", // #858585
"--color-surface-900": "108 108 108", // #6c6c6c
},
};

View File

@ -1,6 +1,11 @@
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from 'vite'; import { defineConfig } from "vite";
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()] plugins: [sveltekit()],
build: {
rollupOptions: {
external: ["sharp"],
},
},
}); });