Compare commits

...

9 Commits

15 changed files with 283 additions and 26 deletions

View File

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import LazyImage from "./LazyImage.svelte";
interface CardProps { interface CardProps {
children: Snippet; children: Snippet;
@ -10,6 +11,12 @@
/** The id of the header image element for JS access. */ /** The id of the header image element for JS access. */
imgid?: string | undefined; 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. */ /** Hide the header image element. It can be shown by removing the "hidden" property using JS and the imgid. */
imghidden?: boolean; imghidden?: boolean;
@ -21,6 +28,8 @@
children, children,
imgsrc = undefined, imgsrc = undefined,
imgid = undefined, imgid = undefined,
imgwidth = undefined,
imgheight = undefined,
imghidden = false, imghidden = false,
fullwidth = false, fullwidth = false,
...restProps ...restProps
@ -30,14 +39,16 @@
<div class="card overflow-hidden bg-white shadow {fullwidth ? 'w-full' : 'w-auto'}"> <div class="card overflow-hidden bg-white shadow {fullwidth ? 'w-full' : 'w-auto'}">
<!-- Allow empty strings for images that only appear after user action --> <!-- Allow empty strings for images that only appear after user action -->
{#if imgsrc !== undefined} {#if imgsrc !== undefined}
<img <div style="width: auto; aspect-ratio: {imgwidth} / {imgheight};">
id={imgid} <LazyImage
src={imgsrc} id={imgid}
alt="Card header" src={imgsrc}
draggable="false" alt="Card header"
class="select-none shadow" draggable="false"
hidden={imghidden} class="select-none shadow"
/> hidden={imghidden}
/>
</div>
{/if} {/if}
<div class="p-2" {...restProps}> <div class="p-2" {...restProps}>
{@render children()} {@render children()}

View File

@ -6,6 +6,7 @@
import type { Driver } from "$lib/schema"; import type { Driver } from "$lib/schema";
import Input from "./Input.svelte"; import Input from "./Input.svelte";
import Dropdown, { type DropdownOption } from "./Dropdown.svelte"; import Dropdown, { type DropdownOption } from "./Dropdown.svelte";
import { DRIVER_HEADSHOT_HEIGHT, DRIVER_HEADSHOT_WIDTH } from "$lib/config";
interface DriverCardProps { interface DriverCardProps {
/** The [Driver] object used to prefill values. */ /** The [Driver] object used to prefill values. */
@ -34,7 +35,7 @@
driver = undefined, driver = undefined,
disable_inputs = false, disable_inputs = false,
require_inputs = false, require_inputs = false,
headshot_template = "", headshot_template = undefined,
team_select_value, team_select_value,
team_select_options, team_select_options,
active_value, active_value,
@ -43,6 +44,8 @@
<Card <Card
imgsrc={driver?.headshot_url ?? headshot_template} imgsrc={driver?.headshot_url ?? headshot_template}
imgwidth={DRIVER_HEADSHOT_WIDTH}
imgheight={DRIVER_HEADSHOT_HEIGHT}
imgid="update_driver_headshot_preview_{driver?.id ?? 'create'}" imgid="update_driver_headshot_preview_{driver?.id ?? 'create'}"
> >
<form method="POST" enctype="multipart/form-data"> <form method="POST" enctype="multipart/form-data">

View File

@ -76,7 +76,7 @@
// Just list this so SvelteKit picks it up as dependency // Just list this so SvelteKit picks it up as dependency
input_variable; input_variable;
if (input) input.dispatchEvent(new Event("DropdownChange")); if (input) input.dispatchEvent(new CustomEvent("DropdownChange"));
}); });
</script> </script>

View File

@ -0,0 +1,42 @@
<script lang="ts">
import type { HTMLImgAttributes } from "svelte/elements";
import { lazyload } from "$lib/lazyload";
interface LazyImageProps extends HTMLImgAttributes {
/** The URL to the image resource to lazyload */
src: string;
}
let { src, ...restProps }: LazyImageProps = $props();
const blobToBase64 = (blob: Blob): Promise<any> => {
return new Promise((resolve, _) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.readAsDataURL(blob);
});
};
const loadImage = async (url: string): Promise<any> => {
return await fetch(url)
.then((response) => response.blob())
.then((blob) => blobToBase64(blob));
};
const lazy_visible_handler = () => {
load = true;
};
// Once the image is visible, this will be set to true, triggering the loading
let load: boolean = $state(false);
</script>
<div use:lazyload onLazyVisible={lazy_visible_handler}>
{#if load}
{#await loadImage(src)}
<!-- Loading... -->
{:then data}
<img src={data} {...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

@ -6,6 +6,7 @@
import type { Race } from "$lib/schema"; import type { Race } from "$lib/schema";
import Input from "./Input.svelte"; import Input from "./Input.svelte";
import { format } from "date-fns"; import { format } from "date-fns";
import { RACE_PICTOGRAM_HEIGHT, RACE_PICTOGRAM_WIDTH } from "$lib/config";
interface RaceCardProps { interface RaceCardProps {
/** The [Race] object used to prefill values. */ /** The [Race] object used to prefill values. */
@ -56,6 +57,8 @@
<Card <Card
imgsrc={race?.pictogram_url ?? pictogram_template} imgsrc={race?.pictogram_url ?? pictogram_template}
imgwidth={RACE_PICTOGRAM_WIDTH}
imgheight={RACE_PICTOGRAM_HEIGHT}
imgid="update_race_pictogram_preview_{race?.id ?? 'create'}" imgid="update_race_pictogram_preview_{race?.id ?? 'create'}"
> >
<form method="POST" enctype="multipart/form-data"> <form method="POST" enctype="multipart/form-data">

View File

@ -5,6 +5,7 @@
import { get_by_value } from "$lib/database"; import { get_by_value } from "$lib/database";
import Dropdown, { type DropdownOption } from "./Dropdown.svelte"; import Dropdown, { type DropdownOption } from "./Dropdown.svelte";
import type { Action } from "svelte/action"; import type { Action } from "svelte/action";
import { DRIVER_HEADSHOT_HEIGHT, DRIVER_HEADSHOT_WIDTH } from "$lib/config";
interface SubstitutionCardProps { interface SubstitutionCardProps {
/** The [Substitution] object used to prefill values. */ /** The [Substitution] object used to prefill values. */
@ -70,7 +71,7 @@
`update_substitution_headshot_preview_${substitution?.id ?? "create"}`, `update_substitution_headshot_preview_${substitution?.id ?? "create"}`,
) as HTMLImageElement; ) as HTMLImageElement;
preview.src = src; if (preview) preview.src = src;
} }
}; };
</script> </script>
@ -78,6 +79,8 @@
<Card <Card
imgsrc={get_by_value(drivers, "id", substitution?.substitute ?? "")?.headshot_url ?? imgsrc={get_by_value(drivers, "id", substitution?.substitute ?? "")?.headshot_url ??
headshot_template} headshot_template}
imgwidth={DRIVER_HEADSHOT_WIDTH}
imgheight={DRIVER_HEADSHOT_HEIGHT}
imgid="update_substitution_headshot_preview_{substitution?.id ?? 'create'}" imgid="update_substitution_headshot_preview_{substitution?.id ?? 'create'}"
> >
<form method="POST" enctype="multipart/form-data"> <form method="POST" enctype="multipart/form-data">

View File

@ -5,6 +5,7 @@
import Button from "./Button.svelte"; import Button from "./Button.svelte";
import type { Team } from "$lib/schema"; import type { Team } from "$lib/schema";
import Input from "./Input.svelte"; import Input from "./Input.svelte";
import { TEAM_LOGO_HEIGHT, TEAM_LOGO_WIDTH } from "$lib/config";
interface TeamCardProps { interface TeamCardProps {
/** The [Team] object used to prefill values. */ /** The [Team] object used to prefill values. */
@ -30,6 +31,8 @@
<Card <Card
imgsrc={team?.logo_url ?? logo_template} imgsrc={team?.logo_url ?? logo_template}
imgwidth={TEAM_LOGO_WIDTH}
imgheight={TEAM_LOGO_HEIGHT}
imgid="update_team_logo_preview_{team?.id ?? 'create'}" imgid="update_team_logo_preview_{team?.id ?? 'create'}"
> >
<form method="POST" enctype="multipart/form-data"> <form method="POST" enctype="multipart/form-data">

View File

@ -1,22 +1,35 @@
import Input from "./Input.svelte";
import Button from "./Button.svelte"; import Button from "./Button.svelte";
import Card from "./Card.svelte"; import Card from "./Card.svelte";
import Search from "./Search.svelte"; import DriverCard from "./DriverCard.svelte";
import Dropdown from "./Dropdown.svelte"; import Dropdown from "./Dropdown.svelte";
// import type DropdownOption from "./Dropdown.svelte"; import Input from "./Input.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 MenuDrawerIcon from "./svg/MenuDrawerIcon.svelte";
import UserIcon from "./svg/UserIcon.svelte";
import PasswordIcon from "./svg/PasswordIcon.svelte"; import PasswordIcon from "./svg/PasswordIcon.svelte";
import UserIcon from "./svg/UserIcon.svelte";
export { export {
Input, // Components
Button, Button,
Card, Card,
Search, DriverCard,
Dropdown, Dropdown,
// type DropdownOption, Input,
LazyImage,
LoadingIndicator,
RaceCard,
Search,
SubstitutionCard,
TeamCard,
// SVG
MenuDrawerIcon, MenuDrawerIcon,
UserIcon,
PasswordIcon, PasswordIcon,
UserIcon,
}; };

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

@ -0,0 +1,11 @@
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;

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

@ -0,0 +1,30 @@
// 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"));
}
});
});
};
// This is used as an action on lazyloaded elements
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);
},
};
};

View File

@ -5,7 +5,14 @@
import type { LayoutData } from "./$types"; import type { LayoutData } from "./$types";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { Button, MenuDrawerIcon, UserIcon, Input, PasswordIcon } from "$lib/components"; import {
Button,
MenuDrawerIcon,
UserIcon,
Input,
PasswordIcon,
LoadingIndicator,
} from "$lib/components";
import { get_avatar_preview_event_handler } from "$lib/image"; import { get_avatar_preview_event_handler } from "$lib/image";
import { import {
@ -83,6 +90,8 @@
// }; // };
</script> </script>
<LoadingIndicator />
<Drawer> <Drawer>
{#if $drawerStore.id === "menu_drawer"} {#if $drawerStore.id === "menu_drawer"}
<!-- Menu Drawer --> <!-- Menu Drawer -->

View File

@ -6,6 +6,15 @@ import {
form_data_get_and_remove_id, form_data_get_and_remove_id,
} from "$lib/form"; } from "$lib/form";
import type { Team, Driver, Race, Substitution, Graphic } from "$lib/schema"; 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 // These "actions" run serverside only, as they're located inside +page.server.ts
export const actions = { export const actions = {
@ -15,6 +24,15 @@ export const actions = {
const data: FormData = form_data_clean(await request.formData()); const data: FormData = form_data_clean(await request.formData());
form_data_ensure_keys(data, ["name", "logo"]); 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); await locals.pb.collection("teams").create(data);
return { tab: 0 }; return { tab: 0 };
@ -26,6 +44,17 @@ export const actions = {
const data: FormData = form_data_clean(await request.formData()); const data: FormData = form_data_clean(await request.formData());
const id: string = form_data_get_and_remove_id(data); 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); await locals.pb.collection("teams").update(id, data);
return { tab: 0 }; return { tab: 0 };
@ -51,6 +80,15 @@ export const actions = {
// The toggle switch will report "on" or nothing // The toggle switch will report "on" or nothing
data.set("active", data.has("active") ? "true" : "false"); 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); await locals.pb.collection("drivers").create(data);
return { tab: 1 }; return { tab: 1 };
@ -65,6 +103,17 @@ export const actions = {
// The toggle switch will report "on" or nothing // The toggle switch will report "on" or nothing
data.set("active", data.has("active") ? "true" : "false"); 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); await locals.pb.collection("drivers").update(id, data);
return { tab: 1 }; return { tab: 1 };
@ -88,6 +137,15 @@ export const actions = {
form_data_ensure_keys(data, ["name", "step", "pictogram", "pxx", "qualidate", "racedate"]); form_data_ensure_keys(data, ["name", "step", "pictogram", "pxx", "qualidate", "racedate"]);
form_data_fix_dates(data, ["sprintqualidate", "sprintdate", "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); await locals.pb.collection("races").create(data);
return { tab: 2 }; return { tab: 2 };
@ -104,6 +162,17 @@ export const actions = {
form_data_fix_dates(data, ["sprintqualidate", "sprintdate", "qualidate", "racedate"]); form_data_fix_dates(data, ["sprintqualidate", "sprintdate", "qualidate", "racedate"]);
const id: string = form_data_get_and_remove_id(data); 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); await locals.pb.collection("races").update(id, data);
return { tab: 2 }; return { tab: 2 };

View File

@ -5,11 +5,8 @@
// TODO: Why does this work but import { type DropdownOption } from "$lib/components" does not? // TODO: Why does this work but import { type DropdownOption } from "$lib/components" does not?
import type { DropdownOption } from "$lib/components/Dropdown.svelte"; import type { DropdownOption } from "$lib/components/Dropdown.svelte";
import { TeamCard, DriverCard, RaceCard, SubstitutionCard } from "$lib/components";
import { get_by_value } from "$lib/database"; import { get_by_value } from "$lib/database";
import TeamCard from "$lib/components/TeamCard.svelte";
import DriverCard from "$lib/components/DriverCard.svelte";
import RaceCard from "$lib/components/RaceCard.svelte";
import SubstitutionCard from "$lib/components/SubstitutionCard.svelte";
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();

View File

@ -2,6 +2,7 @@ import { form_data_clean, form_data_ensure_keys, form_data_get_and_remove_id } f
import { error, redirect } from "@sveltejs/kit"; import { error, redirect } from "@sveltejs/kit";
import type { Actions } from "./$types"; import type { Actions } from "./$types";
import { image_to_avif } from "$lib/server/image"; import { image_to_avif } from "$lib/server/image";
import { AVATAR_HEIGHT, AVATAR_WIDTH } from "$lib/config";
export const actions = { export const actions = {
create_profile: async ({ request, locals }): Promise<void> => { create_profile: async ({ request, locals }): Promise<void> => {
@ -34,8 +35,8 @@ export const actions = {
// Compress image // Compress image
const compressed: Blob = await image_to_avif( const compressed: Blob = await image_to_avif(
await (data.get("avatar") as File).arrayBuffer(), await (data.get("avatar") as File).arrayBuffer(),
256, AVATAR_WIDTH,
256, AVATAR_HEIGHT,
); );
// At most 20kB // At most 20kB