Compare commits

..

3 Commits

4 changed files with 266 additions and 19 deletions

View File

@ -0,0 +1,168 @@
<script lang="ts">
import { get_image_preview_event_handler } from "$lib/image";
import { FileDropzone } from "@skeletonlabs/skeleton";
import Card from "./Card.svelte";
import Button from "./Button.svelte";
import type { Race } from "$lib/schema";
import Input from "./Input.svelte";
import { format } from "date-fns";
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 = (event: Event) => {
const sprintquali: HTMLInputElement = document.getElementById(
`race_sprintqualidate_${race?.id ?? "create"}`,
);
const sprint: HTMLInputElement = document.getElementById(
`race_sprintdate_${race?.id ?? "create"}`,
);
sprintquali.value = "";
sprint.value = "";
};
</script>
<Card
imgsrc={race?.pictogram_url ?? pictogram_template}
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 ?? ""}
labelwidth="120px"
disabled={disable_inputs}
required={require_inputs}>Name</Input
>
<Input
id="race_step_{race?.id ?? 'create'}"
name="step"
value={race?.step ?? ""}
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 ?? ""}
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 ?? ""}
labelwidth="120px"
type="datetime-local"
disabled={disable_inputs}>Sprint Quali</Input
>
<Input
id="race_sprintdate_{race?.id ?? 'create'}"
name="sprintdate"
value={sprintdate ?? ""}
labelwidth="120px"
type="datetime-local"
disabled={disable_inputs}>Sprint</Input
>
<Input
id="race_qualidate_{race?.id ?? 'create'}"
name="qualidate"
value={qualidate ?? ""}
labelwidth="120px"
type="datetime-local"
disabled={disable_inputs}
required={require_inputs}>Quali</Input
>
<Input
id="race_racedate_{race?.id ?? 'create'}"
name="racedate"
value={racedate ?? ""}
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>
</Card>

View File

@ -12,21 +12,19 @@ export const form_data_get_and_remove_id = (data: FormData): string => {
}; };
/** /**
* Remove empty fields and files from FormData objects. * 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): FormData => { export const form_data_clean = (data: FormData, except: string[] = []): FormData => {
let delete_keys: string[] = []; let delete_keys: string[] = [];
for (const [key, value] of data.entries()) { for (const [key, value] of data.entries()) {
if (value === "" || value === null) { if (
// Remove empty keys !except.includes(key) &&
delete_keys.push(key); (value === null ||
} else if ( value === undefined ||
// Remove empty files (typeof value === "string" && value.trim() === "") ||
value !== null && (typeof value === "object" && "size" in value && value.size === 0))
typeof value === "object" &&
"size" in value &&
value.size === 0
) { ) {
delete_keys.push(key); delete_keys.push(key);
} }
@ -52,3 +50,21 @@ export const form_data_ensure_key = (data: FormData, key: string): void => {
export const form_data_ensure_keys = (data: FormData, keys: string[]): void => { export const form_data_ensure_keys = (data: FormData, keys: string[]): void => {
keys.map((key) => form_data_ensure_key(data, key)); 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));
};

View File

@ -1,5 +1,10 @@
import type { Actions, PageServerLoad } from "./$types"; import type { Actions, PageServerLoad } from "./$types";
import { form_data_clean, form_data_ensure_keys, form_data_get_and_remove_id } from "$lib/form"; 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 type { Team, Driver, Race, Substitution, Graphic } from "$lib/schema";
// 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
@ -43,8 +48,6 @@ 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, ["firstname", "lastname", "code", "team", "headshot", "active"]); form_data_ensure_keys(data, ["firstname", "lastname", "code", "team", "headshot", "active"]);
console.log(data);
const record: Driver = await locals.pb.collection("drivers").create(data); const record: Driver = await locals.pb.collection("drivers").create(data);
return { tab: 1 }; return { tab: 1 };
@ -73,22 +76,59 @@ export const actions = {
}, },
create_race: async ({ request, locals }) => { 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"]);
const record: Race = await locals.pb.collection("races").create(data);
return { tab: 2 }; return { tab: 2 };
}, },
update_race: async ({ request, locals }) => { 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);
const record: Race = await locals.pb.collection("races").update(id, data);
return { tab: 2 }; return { tab: 2 };
}, },
delete_race: async ({ request, locals }) => { 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 }; return { tab: 2 };
}, },
create_substitution: async ({ request, locals }) => { create_substitution: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
return { tab: 2 }; return { tab: 2 };
}, },
update_substitution: async ({ request, locals }) => { update_substitution: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
return { tab: 2 }; return { tab: 2 };
}, },
delete_substitution: async ({ request, locals }) => { delete_substitution: async ({ request, locals }) => {
if (!locals.admin) return { unauthorized: true };
return { tab: 2 }; return { tab: 2 };
}, },
} satisfies Actions; } satisfies Actions;
@ -134,11 +174,25 @@ export const load: PageServerLoad = async ({ fetch, locals }) => {
}; };
const fetch_races = async (): Promise<Race[]> => { const fetch_races = async (): Promise<Race[]> => {
return []; 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[]> => { const fetch_substitutions = async (): Promise<Substitution[]> => {
return []; // 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 { return {

View File

@ -10,6 +10,7 @@
import { get_by_value } from "$lib/database"; import { get_by_value } from "$lib/database";
import TeamCard from "$lib/components/TeamCard.svelte"; import TeamCard from "$lib/components/TeamCard.svelte";
import DriverCard from "$lib/components/DriverCard.svelte"; import DriverCard from "$lib/components/DriverCard.svelte";
import RaceCard from "$lib/components/RaceCard.svelte";
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
@ -74,7 +75,6 @@
<!-- Drivers Tab --> <!-- Drivers Tab -->
<!-- Drivers Tab --> <!-- 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"> <div class="mt-2 grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6">
<!-- List all drivers inside the database --> <!-- List all drivers inside the database -->
{#each data.drivers as driver} {#each data.drivers as driver}
@ -100,13 +100,22 @@
<!-- Races Tab --> <!-- Races Tab -->
<!-- Races Tab --> <!-- Races Tab -->
<!-- Races Tab --> <!-- Races Tab -->
<div class="mt-2 grid grid-cols-1 gap-2 md:grid-cols-3 2xl:grid-cols-5">
{#each data.races as race}
<RaceCard {race} disable_inputs={!data.admin} />
{/each}
<span>Races</span> {#if data.admin}
<RaceCard
pictogram_template={get_by_value(data.graphics, "name", "race_template")?.file_url}
require_inputs
/>
{/if}
</div>
{:else if current_tab === 3} {:else if current_tab === 3}
<!-- Substitutions Tab --> <!-- Substitutions Tab -->
<!-- Substitutions Tab --> <!-- Substitutions Tab -->
<!-- Substitutions Tab --> <!-- Substitutions Tab -->
<span>Substitutions</span> <span>Substitutions</span>
{/if} {/if}
</svelte:fragment> </svelte:fragment>