Compare commits
9 Commits
00a4019ae5
...
79c97ce232
| Author | SHA1 | Date | |
|---|---|---|---|
| 79c97ce232 | |||
| 68eeae18e2 | |||
| b694a10609 | |||
| 262ac50356 | |||
| fde45eb37c | |||
| c45a24066d | |||
| 5f16b55593 | |||
| ea0320e063 | |||
| c939655a4f |
@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import LazyImage from "./LazyImage.svelte";
|
||||
|
||||
interface CardProps {
|
||||
children: Snippet;
|
||||
@ -10,6 +11,12 @@
|
||||
/** 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;
|
||||
|
||||
@ -21,6 +28,8 @@
|
||||
children,
|
||||
imgsrc = undefined,
|
||||
imgid = undefined,
|
||||
imgwidth = undefined,
|
||||
imgheight = undefined,
|
||||
imghidden = false,
|
||||
fullwidth = false,
|
||||
...restProps
|
||||
@ -30,14 +39,16 @@
|
||||
<div class="card overflow-hidden bg-white shadow {fullwidth ? 'w-full' : 'w-auto'}">
|
||||
<!-- Allow empty strings for images that only appear after user action -->
|
||||
{#if imgsrc !== undefined}
|
||||
<img
|
||||
id={imgid}
|
||||
src={imgsrc}
|
||||
alt="Card header"
|
||||
draggable="false"
|
||||
class="select-none shadow"
|
||||
hidden={imghidden}
|
||||
/>
|
||||
<div style="width: auto; aspect-ratio: {imgwidth} / {imgheight};">
|
||||
<LazyImage
|
||||
id={imgid}
|
||||
src={imgsrc}
|
||||
alt="Card header"
|
||||
draggable="false"
|
||||
class="select-none shadow"
|
||||
hidden={imghidden}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="p-2" {...restProps}>
|
||||
{@render children()}
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
import type { Driver } from "$lib/schema";
|
||||
import Input from "./Input.svelte";
|
||||
import Dropdown, { type DropdownOption } from "./Dropdown.svelte";
|
||||
import { DRIVER_HEADSHOT_HEIGHT, DRIVER_HEADSHOT_WIDTH } from "$lib/config";
|
||||
|
||||
interface DriverCardProps {
|
||||
/** The [Driver] object used to prefill values. */
|
||||
@ -34,7 +35,7 @@
|
||||
driver = undefined,
|
||||
disable_inputs = false,
|
||||
require_inputs = false,
|
||||
headshot_template = "",
|
||||
headshot_template = undefined,
|
||||
team_select_value,
|
||||
team_select_options,
|
||||
active_value,
|
||||
@ -43,6 +44,8 @@
|
||||
|
||||
<Card
|
||||
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">
|
||||
|
||||
@ -76,7 +76,7 @@
|
||||
// Just list this so SvelteKit picks it up as dependency
|
||||
input_variable;
|
||||
|
||||
if (input) input.dispatchEvent(new Event("DropdownChange"));
|
||||
if (input) input.dispatchEvent(new CustomEvent("DropdownChange"));
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
42
src/lib/components/LazyImage.svelte
Normal file
42
src/lib/components/LazyImage.svelte
Normal 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>
|
||||
62
src/lib/components/LoadingIndicator.svelte
Normal file
62
src/lib/components/LoadingIndicator.svelte
Normal 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>
|
||||
@ -6,6 +6,7 @@
|
||||
import type { Race } from "$lib/schema";
|
||||
import Input from "./Input.svelte";
|
||||
import { format } from "date-fns";
|
||||
import { RACE_PICTOGRAM_HEIGHT, RACE_PICTOGRAM_WIDTH } from "$lib/config";
|
||||
|
||||
interface RaceCardProps {
|
||||
/** The [Race] object used to prefill values. */
|
||||
@ -56,6 +57,8 @@
|
||||
|
||||
<Card
|
||||
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">
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
import { get_by_value } from "$lib/database";
|
||||
import Dropdown, { type DropdownOption } from "./Dropdown.svelte";
|
||||
import type { Action } from "svelte/action";
|
||||
import { DRIVER_HEADSHOT_HEIGHT, DRIVER_HEADSHOT_WIDTH } from "$lib/config";
|
||||
|
||||
interface SubstitutionCardProps {
|
||||
/** The [Substitution] object used to prefill values. */
|
||||
@ -70,7 +71,7 @@
|
||||
`update_substitution_headshot_preview_${substitution?.id ?? "create"}`,
|
||||
) as HTMLImageElement;
|
||||
|
||||
preview.src = src;
|
||||
if (preview) preview.src = src;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@ -78,6 +79,8 @@
|
||||
<Card
|
||||
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">
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
import Button from "./Button.svelte";
|
||||
import type { Team } from "$lib/schema";
|
||||
import Input from "./Input.svelte";
|
||||
import { TEAM_LOGO_HEIGHT, TEAM_LOGO_WIDTH } from "$lib/config";
|
||||
|
||||
interface TeamCardProps {
|
||||
/** The [Team] object used to prefill values. */
|
||||
@ -30,6 +31,8 @@
|
||||
|
||||
<Card
|
||||
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">
|
||||
|
||||
@ -1,22 +1,35 @@
|
||||
import Input from "./Input.svelte";
|
||||
import Button from "./Button.svelte";
|
||||
import Card from "./Card.svelte";
|
||||
import Search from "./Search.svelte";
|
||||
import DriverCard from "./DriverCard.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 UserIcon from "./svg/UserIcon.svelte";
|
||||
import PasswordIcon from "./svg/PasswordIcon.svelte";
|
||||
import UserIcon from "./svg/UserIcon.svelte";
|
||||
|
||||
export {
|
||||
Input,
|
||||
// Components
|
||||
Button,
|
||||
Card,
|
||||
Search,
|
||||
DriverCard,
|
||||
Dropdown,
|
||||
// type DropdownOption,
|
||||
Input,
|
||||
LazyImage,
|
||||
LoadingIndicator,
|
||||
RaceCard,
|
||||
Search,
|
||||
SubstitutionCard,
|
||||
TeamCard,
|
||||
|
||||
// SVG
|
||||
MenuDrawerIcon,
|
||||
UserIcon,
|
||||
PasswordIcon,
|
||||
UserIcon,
|
||||
};
|
||||
|
||||
11
src/lib/config.ts
Normal file
11
src/lib/config.ts
Normal 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
30
src/lib/lazyload.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
};
|
||||
@ -5,7 +5,14 @@
|
||||
import type { LayoutData } from "./$types";
|
||||
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 {
|
||||
@ -83,6 +90,8 @@
|
||||
// };
|
||||
</script>
|
||||
|
||||
<LoadingIndicator />
|
||||
|
||||
<Drawer>
|
||||
{#if $drawerStore.id === "menu_drawer"}
|
||||
<!-- Menu Drawer -->
|
||||
|
||||
@ -6,6 +6,15 @@ import {
|
||||
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 = {
|
||||
@ -15,6 +24,15 @@ export const actions = {
|
||||
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 };
|
||||
@ -26,6 +44,17 @@ export const actions = {
|
||||
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 };
|
||||
@ -51,6 +80,15 @@ export const actions = {
|
||||
// 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 };
|
||||
@ -65,6 +103,17 @@ export const actions = {
|
||||
// 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 };
|
||||
@ -88,6 +137,15 @@ export const actions = {
|
||||
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 };
|
||||
@ -104,6 +162,17 @@ export const actions = {
|
||||
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 };
|
||||
|
||||
@ -5,11 +5,8 @@
|
||||
|
||||
// TODO: Why does this work but import { type DropdownOption } from "$lib/components" does not?
|
||||
import type { DropdownOption } from "$lib/components/Dropdown.svelte";
|
||||
import { TeamCard, DriverCard, RaceCard, SubstitutionCard } from "$lib/components";
|
||||
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();
|
||||
|
||||
|
||||
@ -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 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> => {
|
||||
@ -34,8 +35,8 @@ export const actions = {
|
||||
// Compress image
|
||||
const compressed: Blob = await image_to_avif(
|
||||
await (data.get("avatar") as File).arrayBuffer(),
|
||||
256,
|
||||
256,
|
||||
AVATAR_WIDTH,
|
||||
AVATAR_HEIGHT,
|
||||
);
|
||||
|
||||
// At most 20kB
|
||||
|
||||
Reference in New Issue
Block a user