Compare commits

...

15 Commits

Author SHA1 Message Date
eec143d63b Skeleton: Add "Forgot Password" button to login drawer
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 28s
2025-03-15 00:54:56 +01:00
0642a6c0e2 Lib: Refactor Input component borders 2025-03-15 00:28:06 +01:00
3eab329b42 Skeleton: Display if a user email is verified in profile drawer 2025-03-15 00:20:12 +01:00
4f9799dcfb Lib: Set verified field for pbUser 2025-03-15 00:19:43 +01:00
e46bdc60bd Lib: Add verified field to User schema 2025-03-15 00:19:35 +01:00
62f4d211ac Hooks: Try to refresh the authStore on page reload 2025-03-15 00:19:14 +01:00
e35d56c81c Lib: Allow setting timeout in toast helpers 2025-03-15 00:19:00 +01:00
3a9b4d6834 Lib: Add optional tail element to Input component 2025-03-15 00:18:39 +01:00
43e8a00aeb Skeleton: Use writable store for pbUser object 2025-03-14 23:56:52 +01:00
614e2becc4 Skeleton: Remove user/admin from fetched data
This data was fetched before it was available, so the user object was
undefined
2025-03-14 22:35:03 +01:00
03fe027f8c Lib: Update pocketbase auth handling 2025-03-14 22:20:36 +01:00
c597fff15a Skeleton: Initial support for user emails 2025-03-14 22:19:40 +01:00
12803a7b8f Lib: Add info/warning toast helpers 2025-03-14 22:17:54 +01:00
f39c1a9090 Lib: Update user schema to include email 2025-03-14 22:17:46 +01:00
6a6c93d960 Lib: Add EMailIcon svg component 2025-03-14 22:17:34 +01:00
19 changed files with 348 additions and 80 deletions

13
src/hooks.client.ts Normal file
View File

@ -0,0 +1,13 @@
import { refresh_auth } from "$lib/pocketbase";
import type { ClientInit } from "@sveltejs/kit";
export const init: ClientInit = async () => {
// Try to refresh the authStore. This is required when e.g.
// changing e-mail address. The new e-mail will show up after
// being verified, so the authStore has to be reloaded.
try {
await refresh_auth();
} catch (error) {
console.log("hooks.client.ts:", error);
}
};

View File

@ -14,7 +14,7 @@
import { team_dropdown_options } from "$lib/dropdown";
import { get_driver_headshot_template } from "$lib/database";
import { get_error_toast } from "$lib/toast";
import { pb } from "$lib/pocketbase";
import { pb, pbUser } from "$lib/pocketbase";
import { error } from "@sveltejs/kit";
import type { PageData } from "../../../routes/data/season/drivers/$types";
@ -43,7 +43,7 @@
// Reactive state
let required: boolean = $derived(!driver);
let disabled: boolean = $derived(!data.admin);
let disabled: boolean = $derived(!$pbUser?.admin);
let firstname_input_value: string = $state(driver?.firstname ?? "");
let lastname_input_value: string = $state(driver?.lastname ?? "");
let code_input_value: string = $state(driver?.code ?? "");

View File

@ -13,7 +13,7 @@
import { get_race_pictogram_template } from "$lib/database";
import { format_date, isodatetimeformat } from "$lib/date";
import { get_error_toast } from "$lib/toast";
import { pb } from "$lib/pocketbase";
import { pb, pbUser } from "$lib/pocketbase";
import { error } from "@sveltejs/kit";
import type { PageData } from "../../../routes/data/season/races/$types";
@ -50,7 +50,7 @@
// Reactive state
let required: boolean = $derived(!race);
let disabled: boolean = $derived(!data.admin);
let disabled: boolean = $derived(!$pbUser?.admin);
let name_value: string = $state(race?.name ?? "");
let step_value: string = $state(race?.step.toString() ?? "");
let pxx_value: string = $state(race?.pxx.toString() ?? "");

View File

@ -11,7 +11,7 @@
import { DRIVER_HEADSHOT_HEIGHT, DRIVER_HEADSHOT_WIDTH } from "$lib/config";
import { driver_dropdown_options } from "$lib/dropdown";
import { get_error_toast } from "$lib/toast";
import { pb } from "$lib/pocketbase";
import { pb, pbUser } from "$lib/pocketbase";
import type { PageData } from "../../../routes/racepicks/$types";
interface RacePickCardProps {
@ -96,7 +96,7 @@
// Database actions
const update_racepick = (create?: boolean): (() => Promise<void>) => {
const handler = async (): Promise<void> => {
if (!data.user?.id || data.user.id === "") {
if (!$pbUser?.id || $pbUser.id === "") {
toastStore.trigger(get_error_toast("Invalid user id!"));
return;
}
@ -114,7 +114,7 @@
}
const racepick_data = {
user: data.user.id,
user: $pbUser.id,
race: data.currentrace.id,
pxx: pxx_select_value,
dnf: dnf_select_value,

View File

@ -12,7 +12,7 @@
import type { Driver, Race, RaceResult, Substitution } from "$lib/schema";
import { get_by_value } from "$lib/database";
import { race_dropdown_options } from "$lib/dropdown";
import { pb } from "$lib/pocketbase";
import { pb, pbUser } from "$lib/pocketbase";
import { get_error_toast } from "$lib/toast";
import type { PageData } from "../../../routes/data/raceresults/$types";
@ -51,7 +51,7 @@
// Reactive state
let required: boolean = $derived(!result);
let disabled: boolean = $derived(!data.admin); // TODO: Datelock (prevent entering future result)
let disabled: boolean = $derived(!$pbUser?.admin); // TODO: Datelock (prevent entering future result)
let race_select_value: string = $state(result?.race ?? "");
let currentrace: Race | undefined = $derived(

View File

@ -14,7 +14,7 @@
import { DRIVER_HEADSHOT_HEIGHT, DRIVER_HEADSHOT_WIDTH } from "$lib/config";
import { driver_dropdown_options, team_dropdown_options } from "$lib/dropdown";
import { get_error_toast } from "$lib/toast";
import { pb } from "$lib/pocketbase";
import { pb, pbUser } from "$lib/pocketbase";
import type { PageData } from "../../../routes/seasonpicks/$types";
interface SeasonPickCardProps {
@ -184,7 +184,7 @@
// Database actions
const update_seasonpick = (create?: boolean): (() => Promise<void>) => {
const handler = async (): Promise<void> => {
if (!data.user?.id || data.user.id === "") {
if (!$pbUser?.id || $pbUser.id === "") {
toastStore.trigger(get_error_toast("Invalid user id!"));
return;
}
@ -229,7 +229,7 @@
}
const seasonpick_data = {
user: data.user.id,
user: $pbUser.id,
hottake: hottake_value,
wdcwinner: wdc_value,
wccwinner: wcc_value,

View File

@ -11,7 +11,7 @@
import { DRIVER_HEADSHOT_HEIGHT, DRIVER_HEADSHOT_WIDTH } from "$lib/config";
import { driver_dropdown_options, race_dropdown_options } from "$lib/dropdown";
import { get_error_toast } from "$lib/toast";
import { pb } from "$lib/pocketbase";
import { pb, pbUser } from "$lib/pocketbase";
import type { PageData } from "../../../routes/data/season/substitutions/$types";
interface SubstitutionCardProps {
@ -43,7 +43,7 @@
// Reactive state
let required: boolean = $derived(!substitution);
let disabled: boolean = $derived(!data.admin);
let disabled: boolean = $derived(!$pbUser?.admin);
let active_drivers: Driver[] = $derived((drivers ?? []).filter((d: Driver) => d.active));
let inactive_drivers: Driver[] = $derived((drivers ?? []).filter((d: Driver) => !d.active));
let substitute_value: string = $state(substitution?.substitute ?? "");

View File

@ -17,7 +17,7 @@
} from "$lib/config";
import { get_team_banner_template, get_team_logo_template } from "$lib/database";
import { get_error_toast } from "$lib/toast";
import { pb } from "$lib/pocketbase";
import { pb, pbUser } from "$lib/pocketbase";
import { error } from "@sveltejs/kit";
import type { PageData } from "../../../routes/data/season/teams/$types";
@ -46,7 +46,7 @@
// Reactive state
let required: boolean = $derived(!team);
let disabled: boolean = $derived(!data.admin);
let disabled: boolean = $derived(!$pbUser?.admin);
let name_value: string = $state(team?.name ?? "");
let color_value: string = $state(team?.color ?? "");
let banner_value: FileList | undefined = $state();

View File

@ -13,6 +13,9 @@
/** The type of the input element, e.g. "text". */
type?: string;
/** An optional element at the end of the input group */
tail?: Snippet;
}
let {
@ -20,16 +23,20 @@
labelwidth = "auto",
value = $bindable(),
type = "text",
tail = undefined,
...restProps
}: InputProps = $props();
</script>
<div class="input-group input-group-divider grid-cols-[auto_1fr_auto]">
<div
class="input-group-shim select-none text-nowrap border-r text-neutral-900"
class="input-group-shim select-none text-nowrap text-neutral-900"
style="width: {labelwidth};"
>
{@render children()}
</div>
<input bind:value class="!outline-none" {type} {...restProps} />
<input bind:value class="{tail ? '!border-r' : ''} !border-l" {type} {...restProps} />
{#if tail}
{@render tail()}
{/if}
</div>

View File

@ -20,6 +20,7 @@ import type { DropdownOption } from "./form/Dropdown";
import type { TableColumn } from "./Table";
import ChequeredFlagIcon from "./svg/ChequeredFlagIcon.svelte";
import EMailIcon from "./svg/EMailIcon.svelte";
import MenuDrawerIcon from "./svg/MenuDrawerIcon.svelte";
import NameIcon from "./svg/NameIcon.svelte";
import PasswordIcon from "./svg/PasswordIcon.svelte";
@ -54,6 +55,7 @@ export {
// SVG
ChequeredFlagIcon,
EMailIcon,
NameIcon,
MenuDrawerIcon,
PasswordIcon,

View File

@ -0,0 +1,42 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
fill="currentColor"
class="h-4 w-4 opacity-70"
>
<g
style="stroke: none; stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: none; fill-rule: nonzero; opacity: 1;"
transform="translate(1.4065934065934016 1.4065934065934016) scale(2.81 2.81)"
>
<path
d="M 75.546 78.738 H 14.455 C 6.484 78.738 0 72.254 0 64.283 V 25.716 c 0 -7.97 6.485 -14.455 14.455 -14.455 h 61.091 c 7.97 0 14.454 6.485 14.454 14.455 v 38.567 C 90 72.254 83.516 78.738 75.546 78.738 z M 14.455 15.488 c -5.64 0 -10.228 4.588 -10.228 10.228 v 38.567 c 0 5.64 4.588 10.229 10.228 10.229 h 61.091 c 5.64 0 10.228 -4.589 10.228 -10.229 V 25.716 c 0 -5.64 -4.588 -10.228 -10.228 -10.228 H 14.455 z"
style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(29,29,27); fill-rule: nonzero; opacity: 1;"
transform=" matrix(1 0 0 1 0 0) "
stroke-linecap="round"
/>
<path
d="M 11.044 25.917 C 21.848 36.445 32.652 46.972 43.456 57.5 c 2.014 1.962 5.105 -1.122 3.088 -3.088 C 35.74 43.885 24.936 33.357 14.132 22.83 C 12.118 20.867 9.027 23.952 11.044 25.917 L 11.044 25.917 z"
style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(29,29,27); fill-rule: nonzero; opacity: 1;"
transform=" matrix(1 0 0 1 0 0) "
stroke-linecap="round"
/>
<path
d="M 46.544 57.5 c 10.804 -10.527 21.608 -21.055 32.412 -31.582 c 2.016 -1.965 -1.073 -5.051 -3.088 -3.088 C 65.064 33.357 54.26 43.885 43.456 54.412 C 41.44 56.377 44.529 59.463 46.544 57.5 L 46.544 57.5 z"
style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(29,29,27); fill-rule: nonzero; opacity: 1;"
transform=" matrix(1 0 0 1 0 0) "
stroke-linecap="round"
/>
<path
d="M 78.837 64.952 c -7.189 -6.818 -14.379 -13.635 -21.568 -20.453 c -2.039 -1.933 -5.132 1.149 -3.088 3.088 c 7.189 6.818 14.379 13.635 21.568 20.453 C 77.788 69.973 80.881 66.89 78.837 64.952 L 78.837 64.952 z"
style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(29,29,27); fill-rule: nonzero; opacity: 1;"
transform=" matrix(1 0 0 1 0 0) "
stroke-linecap="round"
/>
<path
d="M 14.446 68.039 c 7.189 -6.818 14.379 -13.635 21.568 -20.453 c 2.043 -1.938 -1.048 -5.022 -3.088 -3.088 c -7.189 6.818 -14.379 13.635 -21.568 20.453 C 9.315 66.889 12.406 69.974 14.446 68.039 L 14.446 68.039 z"
style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(29,29,27); fill-rule: nonzero; opacity: 1;"
transform=" matrix(1 0 0 1 0 0) "
stroke-linecap="round"
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -1,3 +1,4 @@
import { get } from "svelte/store";
import { pb, pbUser } from "./pocketbase";
import type {
CurrentPickedUser,
@ -158,11 +159,12 @@ export const fetch_visibleracepicks = async (
export const fetch_currentracepick = async (
fetch: (_: any) => Promise<Response>,
): Promise<RacePick | undefined> => {
if (!pbUser) return undefined;
const user: User | undefined = get(pbUser);
if (!user) return undefined;
const currentpickeduser: CurrentPickedUser = await pb
.collection("currentpickedusers")
.getOne(pbUser.id, { fetch: fetch });
.getOne(user.id, { fetch: fetch });
if (!currentpickeduser.picked) return undefined;
@ -203,11 +205,12 @@ export const fetch_hottakes = async (fetch: (_: any) => Promise<Response>): Prom
export const fetch_currentseasonpick = async (
fetch: (_: any) => Promise<Response>,
): Promise<SeasonPick | undefined> => {
if (!pbUser) return undefined;
const user: User | undefined = get(pbUser);
if (!user) return undefined;
const seasonpickeduser: CurrentPickedUser = await pb
.collection("seasonpickedusers")
.getOne(pbUser.id, { fetch: fetch });
.getOne(user.id, { fetch: fetch });
if (!seasonpickeduser.picked) return undefined;

View File

@ -1,18 +1,16 @@
import Pocketbase, { type AuthRecord, type RecordModel, type RecordSubscription } from "pocketbase";
import Pocketbase, { type RecordModel, type RecordSubscription } from "pocketbase";
import type { Graphic, User } from "$lib/schema";
import { env } from "$env/dynamic/public";
import { invalidate } from "$app/navigation";
import { get, writable, type Writable } from "svelte/store";
export let pb = new Pocketbase(env.PUBLIC_PBURL || "http://192.168.86.50:8090");
export let pbUser: User | undefined = undefined;
const update_user = async (record: AuthRecord): Promise<void> => {
if (!record) {
pbUser = undefined;
console.log("Returning with pbUser = undefined");
return;
}
// Keep this in a writable store, because this is basically a $state.
// We can't use $state in non-component files though.
export let pbUser: Writable<User | undefined> = writable(undefined);
const update_user = async (record: RecordModel): Promise<void> => {
let avatar_url: string;
if (record.avatar) {
avatar_url = pb.files.getURL(record, record.avatar);
@ -23,26 +21,55 @@ const update_user = async (record: AuthRecord): Promise<void> => {
avatar_url = pb.files.getURL(driver_headshot_template, driver_headshot_template.file);
}
pbUser = {
pbUser.set({
id: record.id,
verified: record.verified,
username: record.username,
firstname: record.firstname,
email: record.email ?? "",
avatar: record.avatar,
avatar_url: avatar_url,
admin: record.admin,
} as User;
} as User);
};
// Update the pbUser object when authStore changes (e.g. after logging in)
pb.authStore.onChange(async () => {
if (!pb.authStore.isValid) {
console.log("pb.authStore is invalid: Setting pbUser to undefined");
pbUser.set(undefined);
return;
}
if (!pb.authStore.record) {
console.log("pb.authStore.record is null: Setting pbUser to undefined");
pbUser.set(undefined);
return;
}
await update_user(pb.authStore.record);
// TODO: If the user has not chosen an avatar,
// the page keeps displaying the "Login" button (wtf)
console.log("Updating pbUser...");
console.dir(pbUser, { depth: null });
console.dir(get(pbUser), { depth: null });
}, true);
export const clear_auth = (): void => {
console.log("Cleared pb.authStore");
pb.authStore.clear();
};
export const refresh_auth = async (): Promise<void> => {
if (pb.authStore.isValid) {
console.log("Refreshed pb.authStore");
await pb.collection("users").authRefresh();
} else {
console.log("pb.autStore is invalid: Did not refresh pb.authStore");
pb.authStore.clear();
}
};
/**
* Subscribe to PocketBase realtime collections
*/
export const subscribe = (collections: string[]) => {
collections.forEach((collection: string) => {
pb.collection(collection).subscribe("*", (event: RecordSubscription<RecordModel>) => {
@ -51,6 +78,9 @@ export const subscribe = (collections: string[]) => {
});
};
/**
* Unsubscribe from PocketBase realtime collections
*/
export const unsubscribe = (collections: string[]) => {
collections.forEach((collection: string) => {
pb.collection(collection).unsubscribe("*");

View File

@ -11,8 +11,10 @@ export interface Graphic {
export interface User {
id: string;
verified: boolean;
username: string;
firstname: string;
email?: string;
avatar?: string;
avatar_url?: string;
admin: boolean;

View File

@ -1,10 +1,28 @@
import type { ToastSettings } from "@skeletonlabs/skeleton";
export const get_error_toast = (message: string): ToastSettings => {
export const get_info_toast = (message: string, timeout: number = 2000): ToastSettings => {
return {
message,
hideDismiss: true,
timeout: 2000,
timeout,
background: "variant-filled-tertiary",
};
};
export const get_warning_toast = (message: string, timeout: number = 2000): ToastSettings => {
return {
message,
hideDismiss: true,
timeout,
background: "variant-filled-secondary",
};
};
export const get_error_toast = (message: string, timeout: number = 2000): ToastSettings => {
return {
message,
hideDismiss: true,
timeout,
background: "variant-filled-primary",
};
};

View File

@ -18,6 +18,7 @@
RacePickCard,
RaceResultCard,
SeasonPickCard,
EMailIcon,
} from "$lib/components";
import { get_avatar_preview_event_handler } from "$lib/image";
import {
@ -37,13 +38,16 @@
type ModalComponent,
type ToastStore,
getToastStore,
SlideToggle,
} from "@skeletonlabs/skeleton";
import { computePosition, autoUpdate, offset, shift, flip, arrow } from "@floating-ui/dom";
import { invalidate } from "$app/navigation";
import { get_error_toast } from "$lib/toast";
import { pb, subscribe, unsubscribe } from "$lib/pocketbase";
import { get_error_toast, get_info_toast, get_warning_toast } from "$lib/toast";
import { clear_auth, pb, pbUser, refresh_auth, subscribe, unsubscribe } from "$lib/pocketbase";
import { AVATAR_HEIGHT, AVATAR_WIDTH } from "$lib/config";
import { error } from "@sveltejs/kit";
import type { User } from "$lib/schema";
import type { RecordModel } from "pocketbase";
let { data, children }: { data: LayoutData; children: Snippet } = $props();
@ -136,32 +140,83 @@
storePopup.set({ computePosition, autoUpdate, offset, shift, flip, arrow });
// Reactive state
let username_value: string = $state(data.user?.username ?? "");
let firstname_value: string = $state(data.user?.firstname ?? "");
let username_value: string = $state($pbUser?.username ?? "");
let firstname_value: string = $state($pbUser?.firstname ?? "");
let email_value: string = $state($pbUser?.email ?? "");
let password_value: string = $state("");
let avatar_value: FileList | undefined = $state();
let registration_mode: boolean = $state(false);
// Add "Enter" event listeners for login/register text inputs
const enter_handler = (event: KeyboardEvent) => {
if (event.key === "Enter") {
// Cancel the default action, if needed
event.preventDefault();
registration_mode ? update_profile(true) : login();
}
};
// Database actions
const login = async (): Promise<void> => {
if (!username_value || username_value.trim() === "") {
toastStore.trigger(get_error_toast("Please enter your username!"));
return;
}
if (!password_value || password_value.trim() === "") {
toastStore.trigger(get_error_toast("Please enter your password!"));
return;
}
try {
await pb.collection("users").authWithPassword(username_value, password_value);
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
await invalidate("data:user");
drawerStore.close();
username_value = $pbUser?.username ?? "";
firstname_value = $pbUser?.firstname ?? "";
email_value = $pbUser?.email ?? "";
password_value = "";
};
const logout = async (): Promise<void> => {
pb.authStore.clear();
clear_auth();
await invalidate("data:user");
drawerStore.close();
username_value = "";
firstname_value = "";
email_value = "";
password_value = "";
};
const forgot_password = async (): Promise<void> => {
if (!username_value || username_value.trim() === "") {
toastStore.trigger(get_error_toast("Please enter a username!"));
return;
}
try {
const user: RecordModel = await pb
.collection("users")
.getFirstListItem(`username="${username_value}"`);
if (!user.email) {
toastStore.trigger(get_error_toast("You did not set a recovery e-mail address!"));
return;
}
await pb.collection("users").requestPasswordReset(user.email);
toastStore.trigger(get_info_toast("Check your inbox!"));
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
const update_profile = (create?: boolean): (() => Promise<void>) => {
const handler = async (): Promise<void> => {
// Avatar handling
@ -201,6 +256,10 @@
toastStore.trigger(get_error_toast("Please enter your first name!"));
return;
}
if (!email_value || email_value.trim() === "") {
toastStore.trigger(get_error_toast("Please enter your e-mail address!"));
return;
}
if (!password_value || password_value.trim() === "") {
toastStore.trigger(get_error_toast("Please enter a password!"));
return;
@ -209,24 +268,44 @@
await pb.collection("users").create({
username: username_value.trim(),
firstname: firstname_value.trim(),
email: email_value.trim(),
emailVisibility: true,
password: password_value.trim(),
passwordConfirm: password_value.trim(), // lol
admin: false,
});
await pb.collection("users").requestVerification(email_value.trim());
toastStore.trigger(get_info_toast("Check your inbox!"));
// Just in case
clear_auth();
await login();
} else {
if (!data.user?.id || data.user.id === "") {
if (!$pbUser?.id || $pbUser.id === "") {
toastStore.trigger(get_error_toast("Invalid user id!"));
return;
}
await pb.collection("users").update(data.user.id, {
username: username_value.trim().length > 0 ? username_value.trim() : data.user.username,
await pb.collection("users").update($pbUser.id, {
username: username_value.trim().length > 0 ? username_value.trim() : $pbUser.username,
firstname:
firstname_value.trim().length > 0 ? firstname_value.trim() : data.user.firstname,
firstname_value.trim().length > 0 ? firstname_value.trim() : $pbUser.firstname,
avatar: avatar_avif,
});
if (email_value && email_value.trim() !== $pbUser.email) {
await pb.collection("users").requestEmailChange(email_value.trim());
// When changing the email address, the auth token is invalidated
await logout();
toastStore.trigger(get_info_toast("Check your inbox!"));
toastStore.trigger(
get_warning_toast("Please login AFTER confirming your e-mail address!", 5000),
);
}
drawerStore.close();
}
} catch (error) {
@ -322,7 +401,22 @@
<!-- 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>
<div class="flex">
<h4 class="h4 select-none text-nowrap align-middle font-bold" style="line-height: 32px;">
Login or Register
</h4>
<div class="w-full"></div>
<div class="flex gap-2">
<span class="align-middle" style="line-height: 32px;">Login</span>
<SlideToggle
name="registrationmode"
background="bg-tertiary-500"
active="bg-tertiary-500"
bind:checked={registration_mode}
/>
<span class="align-middle" style="line-height: 32px;">Register</span>
</div>
</div>
<Input
bind:value={username_value}
placeholder="Username"
@ -330,36 +424,77 @@
minlength={3}
maxlength={10}
required
onkeypress={enter_handler}
>
<UserIcon />
</Input>
<Input
bind:value={firstname_value}
placeholder="First Name (leave empty for login)"
autocomplete="off"
<div
class="{registration_mode
? ''
: 'mt-[-8px] h-0'} overflow-hidden transition-all duration-150 ease-out"
>
<NameIcon />
</Input>
<Input
bind:value={firstname_value}
placeholder="First Name"
autocomplete="off"
tabindex={registration_mode ? 0 : -1}
onkeypress={enter_handler}
>
<NameIcon />
</Input>
</div>
<div
class="{registration_mode
? ''
: 'mt-[-8px] h-0'} overflow-hidden transition-all duration-150 ease-out"
>
<Input
id="login_email"
type="email"
bind:value={email_value}
placeholder="E-Mail"
autocomplete="email"
tabindex={registration_mode ? 0 : -1}
onkeypress={enter_handler}
>
<EMailIcon />
</Input>
</div>
<Input
id="login_password"
bind:value={password_value}
type="password"
placeholder="Password"
autocomplete="off"
required
onkeypress={enter_handler}
>
<PasswordIcon />
</Input>
<div class="flex justify-end gap-2">
<Button onclick={login} color="tertiary" shadow>Login</Button>
<Button onclick={update_profile(true)} color="tertiary" shadow>Register</Button>
<div
class="{!registration_mode
? ''
: 'mt-[-8px] h-0'} flex w-full gap-2 overflow-hidden transition-all duration-150 ease-out"
>
<Button onclick={forgot_password} color="primary" width="w-full">Forgot Password</Button>
<Button onclick={login} color="tertiary" width="w-full" shadow>Login</Button>
</div>
<div
class="{registration_mode
? ''
: 'mt-[-8px] h-0'} w-full overflow-hidden transition-all duration-150 ease-out"
>
<Button onclick={update_profile(true)} color="tertiary" width="w-full" shadow>
Register
</Button>
</div>
</div>
{:else if $drawerStore.id === "profile_drawer" && data.user}
{:else if $drawerStore.id === "profile_drawer" && pbUser}
<!-- Profile Drawer -->
<!-- Profile Drawer -->
<!-- Profile Drawer -->
<div class="flex flex-col gap-2 p-2 pt-3">
<h4 class="h4 select-none">Edit Profile</h4>
<h4 class="h4 select-none align-middle font-bold" style="line-height: 32px;">Edit Profile</h4>
<Input
bind:value={username_value}
maxlength={10}
@ -371,6 +506,19 @@
<Input bind:value={firstname_value} placeholder="First Name" autocomplete="off">
<NameIcon />
</Input>
<Input bind:value={email_value} placeholder="E-Mail" autocomplete="email">
<EMailIcon />
{#snippet tail()}
{#if $pbUser}
<div
class="input-group-shim select-none text-nowrap text-neutral-900
{$pbUser.verified ? 'bg-tertiary-500' : 'bg-primary-500'}"
>
{$pbUser.verified ? "Verified" : "Not Verified"}
</div>
{/if}
{/snippet}
</Input>
<FileDropzone
name="avatar"
bind:files={avatar_value}
@ -381,8 +529,10 @@
</svelte:fragment>
</FileDropzone>
<div class="flex justify-end gap-2">
<Button onclick={update_profile()} color="secondary" shadow>Save Changes</Button>
<Button onclick={logout} color="primary" shadow>Logout</Button>
<Button onclick={update_profile()} color="secondary" width="w-full" shadow>
Save Changes
</Button>
<Button onclick={logout} color="primary" width="w-full" shadow>Logout</Button>
</div>
</div>
{/if}
@ -431,14 +581,14 @@
activate={$page.url.pathname.startsWith("/data")}>Data</Button
>
{#if !data.user}
{#if !$pbUser}
<!-- Login drawer -->
<Button color="primary" onclick={login_drawer}>Login</Button>
{:else}
<!-- Profile drawer -->
<Avatar
id="user_avatar_preview"
src={data.user.avatar_url}
src={$pbUser?.avatar_url}
rounded="rounded-full"
width="w-10"
background="bg-primary-50"

View File

@ -1,6 +1,8 @@
import { fetch_graphics } from "$lib/fetch";
import { pbUser } from "$lib/pocketbase";
import { get } from "svelte/store";
import type { LayoutLoad } from "./$types";
import type { User } from "$lib/schema";
// This makes the page client-side rendered
export const ssr = false;
@ -11,12 +13,14 @@ export const ssr = false;
// It will populate the "user" attribute of each page's "data" object,
// so each page has access to the current user (or knows if no one is signed in).
export const load: LayoutLoad = async ({ fetch, depends }) => {
depends("data:graphics", "data:user");
depends("data:graphics");
return {
// NOTE: Don't do this! The user object will be updated after this, so it will be undefined!
//
// User information (synchronous)
user: pbUser,
admin: pbUser?.admin ?? false,
// user: get(pbUser),
// admin: get(pbUser)?.admin ?? false,
// Return static data
graphics: await fetch_graphics(fetch),

View File

@ -22,6 +22,7 @@
import { format_date, shortdatetimeformat } from "$lib/date";
import type { CurrentPickedUser, RacePick } from "$lib/schema";
import { get_by_value, get_driver_headshot_template } from "$lib/database";
import { pbUser } from "$lib/pocketbase";
let { data }: { data: PageData } = $props();
@ -76,9 +77,8 @@
</svelte:fragment>
<svelte:fragment slot="content">
<div
class="grid grid-cols-2 gap-2 lg:mx-auto lg:w-fit {data.user
? 'lg:grid-cols-6'
: 'lg:grid-cols-4'}"
class="grid grid-cols-2 gap-2 lg:mx-auto lg:w-fit
{pbUser ? 'lg:grid-cols-6' : 'lg:grid-cols-4'}"
>
<!-- Show information about the next race -->
<div class="card flex w-full min-w-40 flex-col p-2 shadow lg:max-w-40">
@ -124,7 +124,7 @@
</div>
<!-- Only show the userguess if signed in -->
{#if data.user}
{#if pbUser}
<!-- PXX pick -->
<div class="card w-full min-w-40 p-2 pb-0 shadow lg:max-w-40">
<h1 class="mb-2 text-nowrap font-bold">Your P{data.currentrace.pxx} Pick:</h1>
@ -309,10 +309,8 @@
{@const picks = racepicks.filter((pick: RacePick) => pick.user === user.id)}
<div
class="card ml-1 mt-2 w-full min-w-12 overflow-hidden py-2 shadow lg:ml-2 lg:min-w-40 {data.user &&
data.user.username === user.username
? 'bg-primary-300'
: ''}"
class="card ml-1 mt-2 w-full min-w-12 overflow-hidden py-2 shadow lg:ml-2 lg:min-w-40
{$pbUser && $pbUser.username === user.username ? 'bg-primary-300' : ''}"
>
<!-- Avatar + name display at the top -->
<div class="mx-auto flex h-10 w-fit">

View File

@ -7,7 +7,7 @@
type ModalStore,
} from "@skeletonlabs/skeleton";
import type { PageData } from "./$types";
import type { Driver, Hottake, SeasonPick, SeasonPickedUser } from "$lib/schema";
import type { Driver, Hottake, SeasonPick, SeasonPickedUser, User } from "$lib/schema";
import { ChequeredFlagIcon, LazyImage } from "$lib/components";
import {
get_by_value,
@ -23,6 +23,7 @@
TEAM_BANNER_WIDTH,
} from "$lib/config";
import Countdown from "$lib/components/Countdown.svelte";
import { pbUser } from "$lib/pocketbase";
let { data }: { data: PageData } = $props();
@ -69,12 +70,12 @@
</svelte:fragment>
<svelte:fragment slot="content">
<div
class="grid grid-cols-2 gap-2 lg:mx-auto lg:w-fit {data.user
class="grid grid-cols-2 gap-2 lg:mx-auto lg:w-fit {pbUser
? 'lg:grid-cols-5 2xl:grid-cols-10'
: 'lg:grid-cols-2 2xl:grid-cols-2'}"
>
<!-- Only show the stuff if signed in -->
{#if data.user}
{#if $pbUser}
{@const teamwinners = data.seasonpick
? data.seasonpick.teamwinners
.map((id: string) => get_by_value(drivers, "id", id) as Driver)
@ -345,10 +346,8 @@
: [undefined]}
<div
class="card ml-1 mt-2 w-full min-w-[9.5rem] overflow-hidden py-2 shadow lg:ml-2 {data.user &&
data.user.username === user.username
? 'bg-primary-300'
: ''}"
class="card ml-1 mt-2 w-full min-w-[9.5rem] overflow-hidden py-2 shadow lg:ml-2
{$pbUser && $pbUser.username === user.username ? 'bg-primary-300' : ''}"
>
<!-- Avatar + name display at the top -->
<div class="mx-auto flex h-10 w-fit">