Compare commits

..

18 Commits

Author SHA1 Message Date
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
112 changed files with 1318 additions and 13550 deletions

View File

@ -1,20 +0,0 @@
.direnv
.git
.gitea
.idea
.svelte-kit
build
node_modules
pb_data
pb_migrations
.dockerignore
.envrc
.gitignore
README.md
flake.lock
flake.nix
formula11.dockerfile
pocketbase.dockerfile
prettier.config.js

2
.envrc Normal file
View File

@ -0,0 +1,2 @@
use flake
layout node

View File

@ -1,25 +0,0 @@
name: Build Formula11 Docker Image
on:
push:
branches: [master]
# paths:
# - ".gitea/workflows/pocketbase-docker.yaml"
# - "pocketbase.dockerfile"
jobs:
pocketbase-docker:
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Login to container registry
uses: docker/login-action@v3
with:
registry: gitea.vps.chriphost.de
username: ${{ secrets.CONTAINER_REGISTRY_USER }}
password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
- name: Build Formula11 Docker Image
run: docker build --file formula11.dockerfile --tag gitea.vps.chriphost.de/christoph/formula11:latest .
- name: Push Formula11 Docker Image
run: docker push gitea.vps.chriphost.de/christoph/formula11:latest

View File

@ -1,25 +0,0 @@
name: Build Pocketbase Docker Image
on:
push:
branches: [master]
paths:
- ".gitea/workflows/pocketbase-docker.yaml"
- "pocketbase.dockerfile"
jobs:
pocketbase-docker:
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Login to container registry
uses: docker/login-action@v3
with:
registry: gitea.vps.chriphost.de
username: ${{ secrets.CONTAINER_REGISTRY_USER }}
password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
- name: Build Pocketbase Docker Image
run: docker build --build-arg PB_VERSION=0.33.0 --file pocketbase.dockerfile --tag gitea.vps.chriphost.de/christoph/pocketbase:0.33.0 .
- name: Push Pocketbase Docker Image
run: docker push gitea.vps.chriphost.de/christoph/pocketbase:0.33.0

6
.prettierrc Normal file
View File

@ -0,0 +1,6 @@
{
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"tailwindStylesheet": "./src/app.css",
"tailwindConfig": "./tailwind.config.js",
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }],
}

128
flake.lock generated
View File

@ -1,40 +1,15 @@
{
"nodes": {
"clj-nix": {
"inputs": {
"devshell": "devshell",
"nix-fetcher-data": "nix-fetcher-data",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1763549559,
"narHash": "sha256-w7qhicMuDyfm9/dJKs5+47XqhZmGXRfkZjyn8XjO+c0=",
"owner": "jlesquembre",
"repo": "clj-nix",
"rev": "a55b9fbce3da4aa35c94221f76d40c79e6de4d81",
"type": "github"
},
"original": {
"owner": "jlesquembre",
"repo": "clj-nix",
"type": "github"
}
},
"devshell": {
"inputs": {
"nixpkgs": [
"clj-nix",
"nixpkgs"
]
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1741473158,
"narHash": "sha256-kWNaq6wQUbUMlPgw8Y+9/9wP0F8SHkjy24/mN3UAppg=",
"lastModified": 1728330715,
"narHash": "sha256-xRJ2nPOXb//u1jaBnDP56M7v5ldavjbtR6lfGqSvcKg=",
"owner": "numtide",
"repo": "devshell",
"rev": "7c9e793ebe66bcba8292989a68c0419b737a22a0",
"rev": "dd6b80932022cea34a019e2bb32f6fa9e494dfef",
"type": "github"
},
"original": {
@ -43,24 +18,6 @@
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1719745305,
"narHash": "sha256-xwgjVUpqSviudEkpQnioeez1Uo2wzrsMaJKJClh+Bls=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "c3c5ecc05edc7dafba779c6c1a61cd08ac6583e9",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
@ -79,80 +36,43 @@
"type": "github"
}
},
"nix-fetcher-data": {
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": [
"clj-nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1728229178,
"narHash": "sha256-p5Fx880uBYstIsbaDYN7sECJT11oHxZQKtHgMAVblWA=",
"owner": "jlesquembre",
"repo": "nix-fetcher-data",
"rev": "f3a73c34d28db49ef90fd7872a142bfe93120e55",
"type": "github"
},
"original": {
"owner": "jlesquembre",
"repo": "nix-fetcher-data",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1763618868,
"narHash": "sha256-v5afmLjn/uyD9EQuPBn7nZuaZVV9r+JerayK/4wvdWA=",
"lastModified": 1722073938,
"narHash": "sha256-OpX0StkL8vpXyWOGUD6G+MA26wAXK6SpT94kLJXo6B4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a8d610af3f1a5fb71e23e08434d8d61a466fc942",
"rev": "e36e9f57337d0ff0cf77aceb58af4c805472bfae",
"type": "github"
},
"original": {
"id": "nixpkgs",
"type": "indirect"
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-lib": {
"nixpkgs_2": {
"locked": {
"lastModified": 1717284937,
"narHash": "sha256-lIbdfCsf8LMFloheeE6N31+BMIeixqyQWbSr2vk79EQ=",
"type": "tarball",
"url": "https://github.com/NixOS/nixpkgs/archive/eb9ceca17df2ea50a250b6b27f7bf6ab0186f198.tar.gz"
"lastModified": 1733376361,
"narHash": "sha256-aLJxoTDDSqB+/3orsulE6/qdlX6MzDLIITLZqdgMpqo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "929116e316068c7318c54eb4d827f7d9756d5e9c",
"type": "github"
},
"original": {
"type": "tarball",
"url": "https://github.com/NixOS/nixpkgs/archive/eb9ceca17df2ea50a250b6b27f7bf6ab0186f198.tar.gz"
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"clj-nix": "clj-nix",
"devshell": "devshell",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1763865987,
"narHash": "sha256-DJpzM8Jz3B0azJcAoF+YFHr8rEbxYLJ0wy1kWZ29HOw=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "042d905c01a6eec3bcae8530dacb19cda9758a63",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
"nixpkgs": "nixpkgs_2"
}
},
"systems": {

414
flake.nix
View File

@ -1,328 +1,144 @@
rec {
description = "Formula11";
{
description = "Svelte F1 Guessgame";
inputs = {
nixpkgs.url = "nixpkgs"; # Use nixpkgs from system registry
flake-utils.url = "github:numtide/flake-utils";
rust-overlay.url = "github:oxalica/rust-overlay";
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
clj-nix.url = "github:jlesquembre/clj-nix";
clj-nix.inputs.nixpkgs.follows = "nixpkgs";
};
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
inputs.flake-utils.url = "github:numtide/flake-utils";
inputs.devshell.url = "github:numtide/devshell";
outputs = {
self,
nixpkgs,
flake-utils,
rust-overlay,
clj-nix,
devshell,
}:
# Create a shell (and possibly package) for each possible system, not only x86_64-linux
flake-utils.lib.eachDefaultSystem (system: let
pkgs = import nixpkgs {
inherit system;
config.allowUnfree = true;
overlays = [
rust-overlay.overlays.default
overlays = [devshell.overlays.default];
};
timple = pkgs.python312Packages.buildPythonPackage rec {
pname = "timple";
version = "0.1.8";
src = pkgs.python312Packages.fetchPypi {
inherit pname version;
hash = "sha256-u8EgMA8BA6OpPlSg0ASRxLcIcv5psRIEcBpIicagXw8=";
};
doCheck = false;
pyproject = true;
# Build time deps
nativeBuildInputs = with pkgs.python312Packages; [
setuptools
];
# Run time deps
dependencies = with pkgs.python312Packages; [
matplotlib
numpy
];
};
inherit (pkgs) lib stdenv;
# ===========================================================================================
# Define custom dependencies
# ===========================================================================================
fastf1 = pkgs.python312Packages.buildPythonPackage rec {
pname = "fastf1";
version = "3.4.4";
# Python package example
# typed-ffmpeg = pkgs.python313Packages.buildPythonPackage rec {
# pname = "typed_ffmpeg";
# version = "3.6";
#
# src = pkgs.python313Packages.fetchPypi {
# inherit pname version;
# hash = "sha256-YPspq/lqI/jx/9FCQntmQPw4lrPIsdxtHTUg0F0QbrM=";
# };
#
# pyproject = true;
# build-system = [
# pkgs.python313Packages.setuptools
# pkgs.python313Packages.setuptools-scm
# ];
# };
src = pkgs.python312Packages.fetchPypi {
inherit pname version;
hash = "sha256-nELQtvzlLsUYyVaPe1KqvMmzHy5l5W7u1I6m8r8md/4=";
};
# python = pkgs.python313.withPackages (p:
# with p; [
# # numpy
# # matplotlib
# # typed-ffmpeg
# # pyside6
# ]);
doCheck = false;
pyproject = true;
# rust = pkgs.rust-bin.stable.latest.default.override {
# extensions = ["rust-src"]; # Include the Rust stdlib source (for IntelliJ)
# };
# Build time deps
nativeBuildInputs = with pkgs.python312Packages; [
hatchling
hatch-vcs
];
# 64 bit C/C++ compilers that don't collide (use the same libc)
# bintools = pkgs.wrapBintoolsWith {
# bintools = pkgs.bintools.bintools; # Unwrapped bintools
# libc = pkgs.glibc;
# };
# gcc = pkgs.hiPrio (pkgs.wrapCCWith {
# cc = pkgs.gcc.cc; # Unwrapped gcc
# libc = pkgs.glibc;
# bintools = bintools;
# });
# clang = pkgs.wrapCCWith {
# cc = pkgs.clang.cc; # Unwrapped clang
# libc = pkgs.glibc;
# bintools = bintools;
# };
# Run time deps
dependencies = with pkgs.python312Packages; [
matplotlib
numpy
pandas
python-dateutil
requests
requests-cache
scipy
rapidfuzz
websockets
timple
];
};
# Multilib C/C++ compilers that don't collide (use the same libc)
# bintools_multilib = pkgs.wrapBintoolsWith {
# bintools = pkgs.bintools.bintools; # Unwrapped bintools
# libc = pkgs.glibc_multi;
# };
# gcc_multilib = pkgs.hiPrio (pkgs.wrapCCWith {
# cc = pkgs.gcc.cc; # Unwrapped gcc
# libc = pkgs.glibc_multi;
# bintools = bintools_multilib;
# });
# clang_multilib = pkgs.wrapCCWith {
# cc = pkgs.clang.cc; # Unwrapped clang
# libc = pkgs.glibc_multi;
# bintools = bintools_multilib;
# };
f1python = pkgs.python312.withPackages (p:
with p; [
# Basic
rich
# ===========================================================================================
# Specify dependencies
# https://nixos.org/manual/nixpkgs/stable/#ssec-stdenv-dependencies-overview
# Just for a "nix develop" shell, buildInputs can be used for everything.
# ===========================================================================================
# Web
flask
flask-sqlalchemy
flask-caching
sqlalchemy
# Add dependencies to nativeBuildInputs if they are executed during the build:
# - Those which are needed on $PATH during the build, for example cmake and pkg-config
# - Setup hooks, for example makeWrapper
# - Interpreters needed by patchShebangs for build scripts (with the --build flag), which can be the case for e.g. perl
nativeBuildInputs = with pkgs; [
nodejs_24
# Test
pytest
# TODO: For some reason, listing those under fastf1.dependencies doesn't work???
matplotlib
numpy
pandas
python-dateutil
requests
requests-cache
scipy
rapidfuzz
websockets
timple
fastf1
]);
in {
devShell = pkgs.devshell.mkShell {
name = "Formula11";
packages = with pkgs; [
f1python
pocketbase
sqlite # For sqlite console
sqlitebrowser # To check low-level pocketbase data
nodejs_23
# nodePackages.autoprefixer
# nodePackages.postcss
# nodePackages.postcss-cli
# nodePackages.sass
# nodePackages.svelte-check
# nodePackages.tailwindcss
# Languages:
# python
# rust
# bintools
# gcc
# clang
# bintools_multilib
# gcc_multilib
# clang_multilib
# clojure
# jdk
# C/C++:
# gdb
# valgrind
# gnumake
# cmake
# pkg-config
# Clojure:
# leiningen
# clj-nix.packages.${system}.deps-lock
# Java:
# gradle
# Python:
# hatch
# py-spy
# Qt:
# qt6.wrapQtAppsHook # For the shellHook
sqlitebrowser
];
# Add dependencies to buildInputs if they will end up copied or linked into the final output or otherwise used at runtime:
# - Libraries used by compilers, for example zlib
# - Interpreters needed by patchShebangs for scripts which are installed, which can be the case for e.g. perl
buildInputs = with pkgs; [
# C/C++:
# boost
# sfml
# Qt:
# qt6.qtbase
# qt6.full
];
# ===========================================================================================
# Define buildable + installable packages
# ===========================================================================================
# package = stdenv.mkDerivation {
# inherit nativeBuildInputs buildInputs;
# pname = "";
# version = "1.0.0";
# src = ./.;
#
# installPhase = ''
# mkdir -p $out/bin
# mv ./BINARY $out/bin
# '';
# };
# package = clj-nix.lib.mkCljApp {
# inherit pkgs;
# modules = [
# # Option list: https://jlesquembre.github.io/clj-nix/options/
# {
# name = "";
# version = "1.0.0";
# main-ns = "";
# projectSrc = ./.;
# withLeiningen = true;
# buildCommand = "lein uberjar"; # Requires "withLeiningen = true;"
# jdk = pkgs.jdk; # Default is pkgs.jdk_headless
# # customJdk.enable = true;
# # nativeImage.enable = true;
# }
# ];
# };
in rec {
# Provide package for "nix build"
# defaultPackage = package;
# defaultApp = flake-utils.lib.mkApp {
# drv = defaultPackage;
# };
# Provide environment for "nix develop"
devShell = pkgs.mkShell {
inherit nativeBuildInputs buildInputs;
name = description;
# =========================================================================================
# Define environment variables
# =========================================================================================
# Rust stdlib source:
# RUST_SRC_PATH = "${rust}/lib/rustlib/src/rust/library";
# Custom dynamic libraries:
# LD_LIBRARY_PATH = builtins.concatStringsSep ":" [
# # Rust Bevy GUI app:
# # "${pkgs.xorg.libX11}/lib"
# # "${pkgs.xorg.libXcursor}/lib"
# # "${pkgs.xorg.libXrandr}/lib"
# # "${pkgs.xorg.libXi}/lib"
# # "${pkgs.libGL}/lib"
#
# # JavaFX app:
# # "${pkgs.libGL}/lib"
# # "${pkgs.gtk3}/lib"
# # "${pkgs.glib.out}/lib"
# # "${pkgs.xorg.libXtst}/lib"
# ];
# Dynamic libraries from buildinputs:
# LD_LIBRARY_PATH = nixpkgs.lib.makeLibraryPath buildInputs;
# QT imports to use with "qmlls -E"
# QML_IMPORT_PATH = "${pkgs.qt6.full}/lib/qt-6/qml";
# Set PYTHONPATH
# PYTHONPATH = ".";
# Set matplotlib backend
# MPLBACKEND = "TkAgg";
# =========================================================================================
# Define shell environment
# =========================================================================================
# Setup the shell when entering the "nix develop" environment (bash script).
shellHook = let
mkCmakeScript = type: let
typeLower = lib.toLower type;
in
pkgs.writers.writeFish "cmake-${typeLower}.fish" ''
cd $FLAKE_PROJECT_ROOT
echo "Removing build directory ./cmake-build-${typeLower}/"
rm -rf ./cmake-build-${typeLower}
echo "Creating build directory"
mkdir cmake-build-${typeLower}
cd cmake-build-${typeLower}
echo "Running cmake"
cmake -G "Unix Makefiles" -DCMAKE_BUILD_TYPE="${type}" -DCMAKE_EXPORT_COMPILE_COMMANDS="On" ..
echo "Linking compile_commands.json"
cd ..
ln -sf ./cmake-build-${typeLower}/compile_commands.json ./compile_commands.json
'';
cmakeDebug = mkCmakeScript "Debug";
cmakeRelease = mkCmakeScript "Release";
mkBuildScript = type: let
typeLower = lib.toLower type;
in
pkgs.writers.writeFish "cmake-build.fish" ''
cd $FLAKE_PROJECT_ROOT/cmake-build-${typeLower}
echo "Running cmake"
cmake --build .
'';
buildDebug = mkBuildScript "Debug";
buildRelease = mkBuildScript "Release";
# Use this to specify commands that should be ran after entering fish shell
initProjectShell = pkgs.writers.writeFish "init-shell.fish" ''
echo "Entering \"${description}\" environment..."
# Determine the project root, used e.g. in cmake scripts
set -g -x FLAKE_PROJECT_ROOT (git rev-parse --show-toplevel)
# Rust Bevy:
# abbr -a build-release-windows "CARGO_FEATURE_PURE=1 cargo xwin build --release --target x86_64-pc-windows-msvc"
# C/C++:
# abbr -a cmake-debug "${cmakeDebug}"
# abbr -a cmake-release "${cmakeRelease}"
# abbr -a build-debug "${buildDebug}"
# abbr -a build-release "${buildRelease}"
# Clojure:
# abbr -a clojure-deps "deps-lock --lein"
# Python:
# abbr -a run "python ./app/main.py"
# abbr -a profile "py-spy record -o profile.svg -- python ./app/main.py && firefox profile.svg"
# abbr -a ptop "py-spy top -- python ./app/main.py"
# Formula11:
abbr -a pb "pocketbase serve --http 192.168.86.50:8090 --dev"
abbr -a dev "npm run dev -- --host --port 5173"
abbr -a prod "npm run build && npm run preview -- --host --port 5173"
abbr -a check "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
'';
in
builtins.concatStringsSep "\n" [
# Launch into pure fish shell
''
exec "$(type -p fish)" -C "source ${initProjectShell} && abbr -a menu '${pkgs.bat}/bin/bat "${initProjectShell}"'"
''
# Qt: Launch into wrapped fish shell
# https://nixos.org/manual/nixpkgs/stable/#sec-language-qt
# ''
# fishdir=$(mktemp -d)
# makeWrapper "$(type -p fish)" "$fishdir/fish" "''${qtWrapperArgs[@]}"
# exec "$fishdir/fish" -C "source ${initProjectShell} && abbr -a menu '${pkgs.bat}/bin/bat "${initProjectShell}"'"
# ''
# Use $1 for positional args
commands = [
{
name = "db";
help = "Serve PocketBase";
command = "pocketbase serve --http 192.168.86.50:8090";
}
{
name = "dev";
help = "Serve Formula 11 (Dev)";
command = "npm run dev -- --host --port 5173";
}
{
name = "prod";
help = "Serve Formula 11 (Prod)";
command = "npm run build && npm run preview -- --host --port 5173";
}
];
};
});

View File

@ -1,26 +0,0 @@
# Build the node application
FROM node:23-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm prune --production
# Copy the built application to a minimal image
FROM node:23-alpine
WORKDIR /app
COPY --from=builder /app/build build/
COPY --from=builder /app/node_modules node_modules/
COPY package.json .
EXPOSE 5173
ENV NODE_ENV=production
CMD [ "node", "build" ]

3202
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,33 +10,22 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@carbon/charts-svelte": "^1.22.18",
"@floating-ui/dom": "^1.6.13",
"@fsouza/prettierd": "^0.26.1",
"@skeletonlabs/skeleton": "^2.10.4",
"@skeletonlabs/tw-plugin": "^0.4.0",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.19.1",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/forms": "^0.5.10",
"@types/node": "^22.13.10",
"autoprefixer": "^10.4.21",
"cheerio": "^1.0.0",
"date-fns": "^4.1.0",
"pocketbase": "^0.25.2",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"runes2": "^1.1.4",
"svelte": "^5.23.0",
"svelte-check": "^4.1.5",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.2",
"uuid": "^11.1.0",
"vite": "^6.2.2"
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"svelte": "^5.0.0",
"svelte-check": "^4.1.1",
"typescript": "^5.0.0",
"vite": "^6.0.0"
},
"dependencies": {
"sharp": "^0.33.5"
"autoprefixer": "^10.4.20",
"daisyui": "^4.12.20",
"pocketbase": "^0.22.1",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.2",
"prettier-plugin-tailwindcss": "^0.6.9",
"tailwindcss": "^3.4.16"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,24 +0,0 @@
# Lock the version because I had to pull the image manually,
# because fucking Docker thinks "toomanyrequests". Fuckers
FROM docker.io/library/alpine:3.21.2
ARG PB_VERSION=0.33.0
RUN apk add --no-cache \
unzip \
ca-certificates
# Download and unzip PocketBase
ADD https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip /tmp/pb.zip
RUN unzip /tmp/pb.zip -d /pb/
# uncomment to copy the local pb_migrations dir into the image
# COPY ./pb_migrations /pb/pb_migrations
# uncomment to copy the local pb_hooks dir into the image
# COPY ./pb_hooks /pb/pb_hooks
EXPOSE 8080
# start PocketBase
CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8080"]

View File

@ -1,6 +1,6 @@
export default {
plugins: {
tailwindcss: { config: "./tailwind.config.ts" },
tailwindcss: {},
autoprefixer: {},
},
};
}

View File

@ -1,32 +0,0 @@
/**
* @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,10 +1,3 @@
@tailwind base;
@tailwind components;
@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);
}

14
src/app.d.ts vendored
View File

@ -1,23 +1,13 @@
import type { User } from "$lib/schema";
import type { PocketBase, RecordModel } from "pocketbase";
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Locals {}
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
declare namespace svelteHTML {
interface HTMLAttributes<T> {
/** This element will be dispatched once an element with [use:lazyload] starts intersecting with the viewport. */
onLazyVisible?: (event: CustomEvent) => void;
}
}
}
export {};
export { };

View File

@ -16,11 +16,8 @@
</head>
<!-- Prefetch data specified in "load" functions on link hover -->
<body data-theme="formula11Theme" data-sveltekit-preload-data="hover">
<body data-sveltekit-preload-data="hover">
<!-- SvelteKit inserts the body contents here -->
<!-- TODO: The fucking user-select: none doesn't work in firefox -->
<div style="display: contents; user-select: none; -moz-user-select: -moz-none">
%sveltekit.body%
</div>
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

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

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

@ -0,0 +1,43 @@
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 }) => {
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);
// 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,
);
// 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);
// 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

@ -1,60 +0,0 @@
import { type LineChartOptions, ScaleTypes } from "@carbon/charts-svelte";
export const make_chart_options = (
title: string,
bottom: string,
left: string,
group: string = "group",
width: string = "100%",
height: string = "400px",
): LineChartOptions => {
return {
title: title,
axes: {
bottom: {
mapsTo: bottom,
scaleType: ScaleTypes.LABELS,
},
left: {
mapsTo: left,
scaleType: ScaleTypes.LINEAR,
},
},
data: {
groupMapsTo: group,
},
curve: "curveMonotoneX",
// toolbar: {
// enabled: false,
// },
animations: true,
// canvasZoom: {
// enabled: false,
// },
grid: {
x: {
enabled: true,
alignWithAxisTicks: true,
},
y: {
enabled: true,
alignWithAxisTicks: true,
},
},
legend: {
enabled: true,
clickable: true,
position: "top",
},
points: {
enabled: true,
radius: 5,
},
tooltip: {
showTotal: false,
},
resizable: true,
width: width,
height: height,
};
};

View File

@ -0,0 +1,20 @@
<script lang="ts">
let {
id = "",
name = "",
formaction = "",
label,
color,
disabled = false,
} = $props();
</script>
<!-- HACK: Set --tw-bg-opacity to 1 so the disabled label/button looks like the disabled input -->
<button
{id}
{name}
{formaction}
{disabled}
class="btn btn-{color}"
style="--tw-bg-opacity: 1">{label}</button
>

View File

@ -1,41 +0,0 @@
<script lang="ts">
interface CountdownProps {
date: string;
gotext?: string;
extraclass?: string;
}
let { date, gotext = "Go Go Go", extraclass = "" }: CountdownProps = $props();
// Set the date we're counting down to
const countDownDate = new Date(date).getTime();
let distance: number = $state(0);
let days: number = $state(0);
let hours: number = $state(0);
let minutes: number = $state(0);
let seconds: number = $state(0);
// Update the countdown every 1 second
setInterval(function () {
// Get today's date and time
const now = new Date().getTime();
// Find the distance between now and the countdown date
distance = countDownDate - now;
// Time calculations for days, hours, minutes and seconds
days = Math.floor(distance / (1000 * 60 * 60 * 24));
hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
seconds = Math.floor((distance % (1000 * 60)) / 1000);
}, 1000);
</script>
<span class={extraclass}>
{#if distance > 0}
{days + "d " + hours + "h " + minutes + "m "}
{:else}
{gotext}
{/if}
</span>

View File

@ -0,0 +1,35 @@
<script lang="ts">
let {
id,
name,
label,
accept = "*",
onchange = undefined,
disabled = false,
required = false,
} = $props();
</script>
{#if disabled}
<!-- HACK: Set --tw-bg-opacity to 1 so the disabled label/button looks like the disabled input -->
<label
for={id}
class="btn btn-disabled mt-2 w-full"
style="--tw-bg-opacity: 1">{label}</label
>
{:else}
<label for={id} class="btn btn-ghost input-bordered mt-2 w-full"
>{label}</label
>
{/if}
<input
{id}
{name}
class="file-input file-input-bordered file-input-ghost"
type="file"
hidden
{disabled}
{required}
{onchange}
{accept}
/>

View File

@ -0,0 +1,29 @@
<script lang="ts">
let {
id,
name,
label,
placeholder = "",
type = "text",
value = "",
disabled = false,
required = false,
} = $props();
</script>
<label
for={id}
class="input input-bordered mt-2 flex !cursor-default select-none items-center gap-2"
>
{label}
<input
{id}
{name}
{type}
class={disabled ? "pointer-events-none grow !cursor-default" : "grow"}
{disabled}
{required}
{placeholder}
{value}
/>
</label>

View File

@ -1,96 +0,0 @@
<script lang="ts">
import type { HTMLImgAttributes } from "svelte/elements";
import { lazyload } from "$lib/lazyload";
import { fetch_image_base64 } from "$lib/image";
import { popup, type PopupSettings } from "@skeletonlabs/skeleton";
import { v4 as uuidv4 } from "uuid";
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;
/** Additional classes to insert for container + image */
imgclass?: string;
/** Slightly zoom the image on mouse-hover */
hoverzoom?: boolean;
/** Optional tooltip text */
tooltip?: string;
}
let {
src,
imgwidth,
imgheight,
imgstyle = undefined,
containerstyle = undefined,
imgclass = "",
hoverzoom = false,
tooltip = 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;
};
// Once the image component is mounted (e.g. when the image has loaded),
// transition the opacity to fade-in the image
const img_opacity_handler = (node: HTMLElement) => {
setTimeout(() => (node.style.opacity = "1"), 20);
};
// Tooltip handling
const tooltipId: string = uuidv4();
const popupTooltip: PopupSettings = {
event: "hover",
target: tooltipId,
placement: "top",
};
</script>
<!-- Show a correctly sized div so the layout doesn't jump. -->
<div
use:lazyload
onLazyVisible={lazy_visible_handler}
class="overflow-hidden {imgclass}"
style="aspect-ratio: {imgwidth} / {imgheight}; max-width: {imgwidth}px; max-height: {imgheight}px; {containerstyle ??
''}"
>
{#if load}
{#await fetch_image_base64(src) then data}
<img
src={data}
use:img_opacity_handler
class="bg-surface-100 transition-all {imgclass} {hoverzoom ? 'hover:scale-105' : ''}"
style="opacity: 0; transition-duration: 300ms; {imgstyle ?? ''}"
draggable="false"
use:popup={popupTooltip}
{...restProps}
/>
{/await}
{/if}
</div>
{#if tooltip}
<div class="card variant-filled-surface p-2 shadow" data-popup={tooltipId}>
<p>{tooltip}</p>
<div class="variant-filled-surface arrow"></div>
</div>
{/if}

View File

@ -1,62 +0,0 @@
<!-- 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,27 @@
<script lang="ts">
let { id, name, disabled = false } = $props();
</script>
<label for={id} class="input input-bordered mt-2 flex items-center gap-2">
<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>
<input
{id}
{name}
type="password"
class="grow"
{disabled}
required
placeholder="Password"
/>
</label>

View File

@ -1,54 +0,0 @@
<script lang="ts">
import { type TableColumn } from "$lib/components";
interface TableProps {
/** The data that is displayed inside the table. Any array of arbitrary key-value objects. */
data: any[];
/** The columns the table should have. */
columns: TableColumn[];
/** Optional height classes */
height?: string;
/** An optional function handling clicking on a table row */
handler?: (event: Event, id: string) => Promise<void>;
}
let { data, columns, height = "", handler = undefined }: TableProps = $props();
</script>
{#if data.length > 0}
<div class="table-container bg-white shadow {height}">
<table class="table table-compact !overflow-scroll bg-white">
<thead class="sticky top-0">
<tr class="bg-surface-500">
{#each columns as col}
<th class="!px-3">{col.label}</th>
{/each}
</tr>
</thead>
<tbody>
{#each data as row}
<tr
class="cursor-pointer bg-surface-300"
onclick={async (event: Event) => {
if (handler) await handler(event, row.id);
}}
>
{#each columns as col}
{#if col.valuefun}
<td class="!align-middle">
{#await col.valuefun(row[col.data_value_name]) then value}{@html value}{/await}
</td>
{:else}
<td class="!align-middle">{row[col.data_value_name]}</td>
{/if}
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
{/if}

View File

@ -1,10 +0,0 @@
export interface TableColumn {
/** The name of the property containing the value. */
data_value_name: string;
/** The columnname for this property. */
label: string;
/** Any function to further customize the displayed value. May return HTML. */
valuefun?: (value: any) => Promise<string>;
}

View File

@ -0,0 +1,26 @@
<script lang="ts">
let { id, name, value = "", disabled = false } = $props();
</script>
<label for={id} class="input input-bordered mt-2 flex items-center gap-2">
<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>
<input
{id}
{name}
type="text"
class="grow"
{value}
{disabled}
required
placeholder="Username"
/>
</label>

View File

@ -1,67 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { LazyImage } from "$lib/components";
import { error } from "@sveltejs/kit";
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;
/** The width of the image. Required if imgsrc is set */
imgwidth?: number;
/** The height of the image. Required if imgsrc is set */
imgheight?: number;
/** The id of the header image element for JS access. */
imgid?: string;
/** Hide the header image element. It can be shown by removing the "hidden" property using JS and the imgid. */
imghidden?: boolean;
/** The width class for the card, defaults to [w-auto] */
width?: string;
/** An optional event handler for clicking the image */
imgonclick?: (event: Event) => void;
}
let {
children,
imgsrc = undefined,
imgwidth = undefined,
imgheight = undefined,
imgid = undefined,
imghidden = false,
width = "w-auto",
imgonclick = undefined,
...restProps
}: CardProps = $props();
if (imgsrc && (!imgwidth || !imgheight)) {
error(400, "imgwidth and imgheight need to be specified when setting an imgsrc!");
}
</script>
<div class="card {width} overflow-hidden bg-white shadow">
<!-- Allow empty strings for images that only appear after user action -->
{#if imgsrc !== undefined}
<LazyImage
id={imgid}
src={imgsrc}
alt="Card header"
draggable="false"
class="select-none shadow"
hidden={imghidden}
imgwidth={imgwidth ?? 0}
imgheight={imgheight ?? 0}
onclick={imgonclick}
/>
{/if}
<div class="p-2" {...restProps}>
{@render children()}
</div>
</div>

View File

@ -1,233 +0,0 @@
<script lang="ts">
import { get_image_preview_event_handler } from "$lib/image";
import {
FileDropzone,
getModalStore,
getToastStore,
SlideToggle,
type ModalStore,
type ToastStore,
} from "@skeletonlabs/skeleton";
import { Button, Input, Card, Dropdown } from "$lib/components";
import type { Driver } from "$lib/schema";
import { DRIVER_HEADSHOT_HEIGHT, DRIVER_HEADSHOT_WIDTH } from "$lib/config";
import { team_dropdown_options } from "$lib/dropdown";
import { get_driver_headshot_template } from "$lib/database";
import { get_error_toast } from "$lib/toast";
import { pb, pbUser } from "$lib/pocketbase";
import { error } from "@sveltejs/kit";
import type { PageData } from "../../../routes/data/season/drivers/$types";
interface DriverCardProps {
/** Data passed from the page context */
data: PageData;
/** The [Driver] object used to prefill values. */
driver?: Driver;
}
let { data, driver = undefined }: DriverCardProps = $props();
const modalStore: ModalStore = getModalStore();
if ($modalStore[0].meta) {
const meta = $modalStore[0].meta;
data = meta.data;
driver = meta.driver;
}
const toastStore: ToastStore = getToastStore();
// Constants
const labelwidth: string = "120px";
// Reactive state
let required: boolean = $derived(!driver);
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 ?? "");
let team_select_value: string = $state(driver?.team ?? "");
let headshot_file_value: FileList | undefined = $state();
let active_value: boolean = $state(driver?.active ?? true);
// Database actions
const update_driver = (create?: boolean): (() => Promise<void>) => {
const handler = async (): Promise<void> => {
if (!firstname_input_value || firstname_input_value === "") {
toastStore.trigger(get_error_toast("Please enter a first name!"));
return;
}
if (!lastname_input_value || lastname_input_value === "") {
toastStore.trigger(get_error_toast("Please enter a last name!"));
return;
}
if (!code_input_value || code_input_value === "") {
toastStore.trigger(get_error_toast("Please enter a driver code!"));
return;
}
if (!team_select_value || team_select_value === "") {
toastStore.trigger(get_error_toast("Please select a team!"));
return;
}
// Headshot handling
let headshot_avif: Blob | undefined = undefined;
const headshot_file: File | undefined =
headshot_file_value && headshot_file_value.length === 1
? headshot_file_value[0]
: undefined;
if (headshot_file) {
const headshot_formdata: FormData = new FormData();
headshot_formdata.append("image", headshot_file);
headshot_formdata.append("width", DRIVER_HEADSHOT_WIDTH.toString());
headshot_formdata.append("height", DRIVER_HEADSHOT_HEIGHT.toString());
try {
const response = await fetch("/api/compress", {
method: "POST",
body: headshot_formdata,
});
if (!response.ok) {
error(500, "Compression failed.");
}
headshot_avif = await response.blob();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
}
const driver_data = {
firstname: firstname_input_value,
lastname: lastname_input_value,
code: code_input_value,
team: team_select_value,
active: active_value,
headshot: headshot_avif,
};
try {
if (create) {
if (!headshot_avif) {
toastStore.trigger(get_error_toast("Please upload a single driver headshot!"));
return;
}
await pb.collection("drivers").create(driver_data);
} else {
if (!driver?.id) {
toastStore.trigger(get_error_toast("Invalid driver id!"));
return;
}
// TODO: Not sure if we want to switch teams without creating a new driver
if (team_select_value !== driver.team) {
toastStore.trigger(
get_error_toast("Please use the team switch button to change teams!"),
);
return;
}
await pb.collection("drivers").update(driver.id, driver_data);
}
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
return handler;
};
const delete_driver = async (): Promise<void> => {
if (!driver?.id) {
toastStore.trigger(get_error_toast("Invalid driver id!"));
return;
}
try {
await pb.collection("drivers").delete(driver.id);
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
</script>
<Card
imgsrc={driver?.headshot_url ?? get_driver_headshot_template(data.graphics)}
imgid="headshot_preview"
width="w-full sm:w-auto"
imgwidth={DRIVER_HEADSHOT_WIDTH}
imgheight={DRIVER_HEADSHOT_HEIGHT}
imgonclick={(event: Event) => modalStore.close()}
>
<div class="flex flex-col gap-2">
<!-- Driver name input -->
<Input bind:value={firstname_input_value} autocomplete="off" {labelwidth} {disabled} {required}>
First Name
</Input>
<Input bind:value={lastname_input_value} autocomplete="off" {labelwidth} {disabled} {required}>
Last Name
</Input>
<Input
bind:value={code_input_value}
autocomplete="off"
minlength={3}
maxlength={3}
{labelwidth}
{disabled}
{required}
>
Driver Code
</Input>
<!-- Driver team input -->
{#await data.teams then teams}
<Dropdown
bind:value={team_select_value}
options={team_dropdown_options(teams)}
{labelwidth}
{disabled}
{required}
>
Team
</Dropdown>
{/await}
<!-- Headshot upload -->
<FileDropzone
name="headshot"
bind:files={headshot_file_value}
onchange={get_image_preview_event_handler("headshot_preview")}
{disabled}
{required}
>
<svelte:fragment slot="message">
<span class="font-bold">Upload Headshot</span>
</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}
/>
</div>
{#if driver}
<Button onclick={update_driver()} color="secondary" {disabled} width="w-1/2">Save</Button>
<Button onclick={delete_driver} color="primary" {disabled} width="w-1/2">Delete</Button>
{:else}
<Button onclick={update_driver(true)} color="tertiary" {disabled} width="w-full">
Create Driver
</Button>
{/if}
</div>
</div>
</Card>

View File

@ -1,300 +0,0 @@
<script lang="ts">
import { get_image_preview_event_handler } from "$lib/image";
import {
FileDropzone,
getModalStore,
getToastStore,
type ModalStore,
type ToastStore,
} from "@skeletonlabs/skeleton";
import { Button, Card, Input } from "$lib/components";
import type { Race } from "$lib/schema";
import { RACE_PICTOGRAM_HEIGHT, RACE_PICTOGRAM_WIDTH } from "$lib/config";
import { get_race_pictogram_template } from "$lib/database";
import { format_date, isodatetimeformat } from "$lib/date";
import { get_error_toast } from "$lib/toast";
import { pb, pbUser } from "$lib/pocketbase";
import { error } from "@sveltejs/kit";
import type { PageData } from "../../../routes/data/season/races/$types";
interface RaceCardProps {
/** Data passed from the page context */
data: PageData;
/** The [Race] object used to prefill values. */
race?: Race;
}
let { data, race = undefined }: RaceCardProps = $props();
const modalStore: ModalStore = getModalStore();
if ($modalStore[0].meta) {
const meta = $modalStore[0].meta;
data = meta.data;
race = meta.race;
}
const toastStore: ToastStore = getToastStore();
// Constants
const labelwidth = "85px";
const clear_sprint = () => {
(document.getElementById("sqdate") as HTMLInputElement).value = "";
(document.getElementById("sdate") as HTMLInputElement).value = "";
sprintqualidate_value = "";
sprintdate_value = "";
};
// Reactive state
let required: boolean = $derived(!race);
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() ?? "");
let sprintqualidate_value: string = $state("");
let sprintdate_value: string = $state("");
let qualidate_value: string = $state("");
let racedate_value: string = $state("");
let pictogram_value: FileList | undefined = $state();
// Set the initial values if we've clicked on an existing race
// PocketBase stores in UTC, so resulting values will be offset by 1h here
if (race) {
if (race.sprintqualidate && race.sprintdate) {
sprintqualidate_value = format_date(race.sprintqualidate, isodatetimeformat);
sprintdate_value = format_date(race.sprintdate, isodatetimeformat);
}
qualidate_value = format_date(race.qualidate, isodatetimeformat);
racedate_value = format_date(race.racedate, isodatetimeformat);
}
// Database actions
const update_race = (create?: boolean): (() => Promise<void>) => {
const handler = async (): Promise<void> => {
if (!name_value || name_value === "") {
toastStore.trigger(get_error_toast("Please enter a name!"));
return;
}
if (!step_value || step_value === "") {
toastStore.trigger(get_error_toast("Please enter a step!"));
return;
}
if (!pxx_value || pxx_value === "") {
toastStore.trigger(get_error_toast("Please enter a place to guess (PXX)!"));
return;
}
if (!qualidate_value || qualidate_value === "") {
toastStore.trigger(get_error_toast("Please enter a qualifying date!"));
return;
}
if (!racedate_value || racedate_value === "") {
toastStore.trigger(get_error_toast("Please enter a race date!"));
return;
}
// Pictogram handling
let pictogram_avif: Blob | undefined = undefined;
const pictogram_file: File | undefined =
pictogram_value && pictogram_value.length === 1 ? pictogram_value[0] : undefined;
if (pictogram_file) {
const pictogram_formdata: FormData = new FormData();
pictogram_formdata.append("image", pictogram_file);
pictogram_formdata.append("width", RACE_PICTOGRAM_WIDTH.toString());
pictogram_formdata.append("height", RACE_PICTOGRAM_HEIGHT.toString());
try {
const response = await fetch("/api/compress", {
method: "POST",
body: pictogram_formdata,
});
if (!response.ok) {
error(500, "Compression failed.");
}
pictogram_avif = await response.blob();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
}
// Use toISOString here, as we want to convert from localtime to UTC, which PocketBase uses
const race_data = {
name: name_value,
step: step_value,
pxx: pxx_value,
sprintqualidate:
sprintqualidate_value && sprintqualidate_value !== ""
? new Date(sprintqualidate_value).toISOString()
: null,
sprintdate:
sprintdate_value && sprintdate_value != ""
? new Date(sprintdate_value).toISOString()
: null,
qualidate: new Date(qualidate_value).toISOString(),
racedate: new Date(racedate_value).toISOString(),
pictogram: pictogram_avif,
};
try {
if (create) {
if (!pictogram_avif) {
toastStore.trigger(get_error_toast("Please upload a single pictogram!"));
return;
}
await pb.collection("races").create(race_data);
} else {
if (!race?.id) {
toastStore.trigger(get_error_toast("Invalid race id!"));
return;
}
await pb.collection("races").update(race.id, race_data);
}
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
return handler;
};
const delete_race = async (): Promise<void> => {
if (!race?.id) {
toastStore.trigger(get_error_toast("Invalid race id!"));
return;
}
try {
await pb.collection("races").delete(race.id);
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
</script>
<Card
imgsrc={race?.pictogram_url ?? get_race_pictogram_template(data.graphics)}
imgid="pictogram_preview"
width="w-full sm:w-auto"
imgwidth={RACE_PICTOGRAM_WIDTH}
imgheight={RACE_PICTOGRAM_HEIGHT}
imgonclick={(event: Event) => modalStore.close()}
>
<div class="flex flex-col gap-2">
<!-- Race name input -->
<Input
bind:value={name_value}
autocomplete="off"
placeholder="Has to end with an emoji"
{labelwidth}
{disabled}
{required}
>
Name
</Input>
<Input
bind:value={step_value}
autocomplete="off"
placeholder="The step in the race calendar"
type="number"
min={1}
max={24}
{labelwidth}
{disabled}
{required}
>
Step
</Input>
<Input
bind:value={pxx_value}
autocomplete="off"
placeholder="The place to guess"
type="number"
min={1}
max={20}
{labelwidth}
{disabled}
{required}
>
PXX
</Input>
<!-- NOTE: Input datetime-local accepts YYYY-mm-ddTHH:MM format -->
<Input
id="sqdate"
type="datetime-local"
bind:value={sprintqualidate_value}
autocomplete="off"
{labelwidth}
{disabled}
>
SQuali
</Input>
<Input
id="sdate"
type="datetime-local"
bind:value={sprintdate_value}
autocomplete="off"
{labelwidth}
{disabled}
>
SRace
</Input>
<Input
type="datetime-local"
bind:value={qualidate_value}
autocomplete="off"
{labelwidth}
{disabled}
{required}
>
Quali
</Input>
<Input
type="datetime-local"
bind:value={racedate_value}
autocomplete="off"
{labelwidth}
{disabled}
{required}
>
Race
</Input>
<!-- Headshot upload -->
<FileDropzone
name="pictogram"
onchange={get_image_preview_event_handler("pictogram_preview")}
bind:files={pictogram_value}
{disabled}
{required}
>
<svelte:fragment slot="message">
<span class="font-bold">Upload Pictogram</span>
</svelte:fragment>
</FileDropzone>
<!-- Save/Delete buttons -->
<div class="flex justify-end gap-2">
<Button onclick={clear_sprint} color="secondary" {disabled} width={race ? "w-1/3" : "w-1/2"}>
Remove Sprint
</Button>
{#if race}
<Button onclick={update_race()} color="secondary" {disabled} width="w-1/3">
Save Changes
</Button>
<Button onclick={delete_race} color="primary" {disabled} width="w-1/3">Delete</Button>
{:else}
<Button onclick={update_race(true)} color="tertiary" {disabled} width="w-1/2">
Create Race
</Button>
{/if}
</div>
</div>
</Card>

View File

@ -1,209 +0,0 @@
<script lang="ts">
import { Card, Button, Dropdown } from "$lib/components";
import type { Driver, RacePick, Substitution } from "$lib/schema";
import { get_by_value, get_driver_headshot_template } from "$lib/database";
import {
getModalStore,
getToastStore,
type ModalStore,
type ToastStore,
} from "@skeletonlabs/skeleton";
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, pbUser } from "$lib/pocketbase";
import type { PageData } from "../../../routes/racepicks/$types";
interface RacePickCardProps {
/** Data passed from the page context */
data: PageData;
/** The [RacePick] object used to prefill values. */
racepick?: RacePick;
}
let { data, racepick = undefined }: RacePickCardProps = $props();
const modalStore: ModalStore = getModalStore();
if ($modalStore[0].meta) {
const meta = $modalStore[0].meta;
data = meta.data;
racepick = meta.racepick;
}
const toastStore: ToastStore = getToastStore();
// Await promises
let drivers: Driver[] | undefined = $state(undefined);
data.drivers.then((d: Driver[]) => (drivers = d));
let substitutions: Substitution[] | undefined = $state(undefined);
data.substitutions.then((s: Substitution[]) => (substitutions = s));
// Constants
const labelwidth: string = "70px";
// Reactive state
let required: boolean = $derived(!racepick);
let disabled: boolean = false; // TODO: Datelock
let pxx_select_value: string = $state(racepick?.pxx ?? "");
let dnf_select_value: string = $state(racepick?.dnf ?? "");
let active_drivers_and_substitutes: Driver[] = $derived.by(() => {
if (!data.currentrace) return [];
let active_and_substitutes: Driver[] = (drivers ?? []).filter(
(driver: Driver) => driver.active,
);
(substitutions ?? [])
.filter((substitution: Substitution) => substitution.race === data.currentrace?.id)
.forEach((substitution: Substitution) => {
const for_index = active_and_substitutes.findIndex(
(driver: Driver) => driver.id === substitution.for,
);
const sub_index = (drivers ?? []).findIndex(
(driver: Driver) => driver.id === substitution.substitute,
);
active_and_substitutes[for_index] = (drivers ?? [])[sub_index];
});
return active_and_substitutes.sort((a: Driver, b: Driver) => a.code.localeCompare(b.code));
});
// Update preview
$effect(() => {
if (!drivers) return;
const src: string = get_by_value(drivers, "id", pxx_select_value)?.headshot_url ?? "";
const img: HTMLImageElement = document.getElementById("headshot_preview") as HTMLImageElement;
if (img) img.src = src;
});
const random_select_handler = (event: Event): void => {
pxx_select_value =
active_drivers_and_substitutes[
Math.floor(Math.random() * active_drivers_and_substitutes.length)
].id;
dnf_select_value =
active_drivers_and_substitutes[
Math.floor(Math.random() * active_drivers_and_substitutes.length)
].id;
};
// Database actions
const update_racepick = (create?: boolean): (() => Promise<void>) => {
const handler = async (): Promise<void> => {
if (!$pbUser?.id || $pbUser.id === "") {
toastStore.trigger(get_error_toast("Invalid user id!"));
return;
}
if (!data.currentrace?.id || data.currentrace.id === "") {
toastStore.trigger(get_error_toast("Invalid race id!"));
return;
}
if (!pxx_select_value || pxx_select_value === "") {
toastStore.trigger(get_error_toast("Please enter a PXX guess!"));
return;
}
if (!dnf_select_value || dnf_select_value === "") {
toastStore.trigger(get_error_toast("Please enter a DNF guess!"));
return;
}
const racepick_data = {
user: $pbUser.id,
race: data.currentrace.id,
pxx: pxx_select_value,
dnf: dnf_select_value,
};
try {
if (create) {
await pb.collection("racepicks").create(racepick_data);
} else {
if (!racepick?.id) {
toastStore.trigger(get_error_toast("Invalid racepick id!"));
return;
}
await pb.collection("racepicks").update(racepick.id, racepick_data);
}
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
return handler;
};
const delete_racepick = async (): Promise<void> => {
if (!racepick?.id) {
toastStore.trigger(get_error_toast("Invalid racepick id!"));
return;
}
try {
await pb.collection("racepicks").delete(racepick.id);
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
</script>
{#await Promise.all([data.graphics, data.drivers]) then [graphics, drivers]}
<Card
imgsrc={get_by_value<Driver>(drivers, "id", racepick?.pxx ?? "")?.headshot_url ??
get_driver_headshot_template(graphics)}
imgid="headshot_preview"
width="w-full sm:w-auto"
imgwidth={DRIVER_HEADSHOT_WIDTH}
imgheight={DRIVER_HEADSHOT_HEIGHT}
imgonclick={(event: Event) => modalStore.close()}
>
<div class="flex flex-col gap-2">
<!-- PXX select -->
<Dropdown
bind:value={pxx_select_value}
options={driver_dropdown_options(active_drivers_and_substitutes)}
{labelwidth}
{disabled}
{required}
>
P{data.currentrace?.pxx ?? "XX"}
</Dropdown>
<!-- DNF select -->
<Dropdown
bind:value={dnf_select_value}
options={driver_dropdown_options(active_drivers_and_substitutes)}
{labelwidth}
{disabled}
{required}
>
DNF
</Dropdown>
<Button color="tertiary" {disabled} width="w-full" onclick={random_select_handler}>
Select Random
</Button>
<!-- Save/Delete buttons -->
<div class="flex justify-end gap-2">
{#if racepick}
<Button onclick={update_racepick()} color="secondary" {disabled} width="w-1/2">
Save Changes
</Button>
<Button onclick={delete_racepick} color="primary" {disabled} width="w-1/2">Delete</Button>
{:else}
<Button onclick={update_racepick(true)} color="tertiary" {disabled} width="w-full">
Make Pick
</Button>
{/if}
</div>
</div>
</Card>
{/await}

View File

@ -1,309 +0,0 @@
<script lang="ts">
import {
Autocomplete,
getModalStore,
getToastStore,
InputChip,
type AutocompleteOption,
type ModalStore,
type ToastStore,
} from "@skeletonlabs/skeleton";
import { Button, Card, Dropdown, type DropdownOption } from "$lib/components";
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, pbUser } from "$lib/pocketbase";
import { get_error_toast } from "$lib/toast";
import type { PageData } from "../../../routes/data/raceresults/$types";
interface RaceResultCardProps {
/** Data passed from the page context */
data: PageData;
/** The [RaceResult] object used to prefill values. */
result?: RaceResult;
}
let { data, result = undefined }: RaceResultCardProps = $props();
const modalStore: ModalStore = getModalStore();
if ($modalStore[0].meta) {
const meta = $modalStore[0].meta;
data = meta.data;
result = meta.result;
}
const toastStore: ToastStore = getToastStore();
// Await promises
let races: Race[] | undefined = $state(undefined);
data.races.then((r: Race[]) => (races = r));
let drivers: Driver[] | undefined = $state(undefined);
data.drivers.then((d: Driver[]) => (drivers = d));
let substitutions: Substitution[] | undefined = $state(undefined);
data.substitutions.then((s: Substitution[]) => (substitutions = s));
let raceresults: RaceResult[] | undefined = $state(undefined);
data.raceresults.then((r: RaceResult[]) => (raceresults = r));
// Constants
const labelwidth: string = "70px";
// Reactive state
let required: boolean = $derived(!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(
get_by_value<Race>(races ?? [], "id", race_select_value) ?? undefined,
);
let present_results: string[] = $derived.by(() => {
if (!raceresults || raceresults.length === 0) return [];
return raceresults.map((raceresult: RaceResult) => raceresult.race);
});
let pxxs_placeholder: string = $derived(
currentrace
? `Select P${(currentrace.pxx ?? -10) - 3} to P${(currentrace.pxx ?? -10) + 3}...`
: `Select race first...`,
);
let pxxs_input: string = $state("");
let pxxs_chips: string[] = $state([]);
let dnfs_input: string = $state("");
let dnfs_chips: string[] = $state([]);
// Set the pxxs/dnfs states once the drivers are loaded
data.drivers.then(async (drivers: Driver[]) => {
pxxs_chips =
result?.pxxs.map((id: string) => get_by_value(drivers, "id", id)?.code ?? "Invalid") ?? [];
dnfs_chips =
result?.dnfs.map((id: string) => get_by_value(drivers, "id", id)?.code ?? "Invalid") ?? [];
});
// This is the actual data that gets sent through the form
let pxxs_ids: string[] = $state(result?.pxxs ?? []);
let dnfs_ids: string[] = $state(result?.dnfs ?? []);
let pxxs_options: AutocompleteOption<string>[] = $derived.by(() => {
if (!race_select_value) return [];
let active_and_substitutes: Driver[] = (drivers ?? []).filter(
(driver: Driver) => driver.active,
);
(substitutions ?? [])
.filter((substitution: Substitution) => substitution.race === race_select_value)
.forEach((substitution: Substitution) => {
const for_index = active_and_substitutes.findIndex(
(driver: Driver) => driver.id === substitution.for,
);
const sub_index = (drivers ?? []).findIndex(
(driver: Driver) => driver.id === substitution.substitute,
);
active_and_substitutes[for_index] = (drivers ?? [])[sub_index];
});
return active_and_substitutes
.sort((a: Driver, b: Driver) => a.firstname.localeCompare(b.firstname))
.map((driver: Driver) => {
return {
// NOTE: Because Skeleton displays the values inside the autocomplete input,
// we have to supply the driver code twice and manage a list of ids manually (ugh)
label: `${driver.firstname} ${driver.lastname}`,
value: driver.code,
};
});
});
let pxxs_whitelist: string[] = $derived.by(() =>
(drivers ?? []).map((driver: Driver) => {
return driver.code;
}),
);
// Event handlers
const on_pxxs_chip_select = (event: CustomEvent<AutocompleteOption<string>>): void => {
if (disabled || !drivers) return;
// Can only select 7 drivers
if (pxxs_chips.length >= 7) return;
// Can only select a driver once
if (pxxs_chips.includes(event.detail.value)) return;
// Manage labels that are displayed
pxxs_chips.push(event.detail.value);
pxxs_input = "";
// Manage ids that are submitted via form
const id: string = get_by_value(drivers.filter((driver: Driver) => driver.active), "code", event.detail.value)?.id ?? "Invalid";
if (!pxxs_ids.includes(id)) {
pxxs_ids.push(id);
}
};
const on_pxxs_chip_remove = (event: CustomEvent): void => {
pxxs_ids.splice(event.detail.chipIndex, 1);
};
const on_dnfs_chip_select = (event: CustomEvent<AutocompleteOption<string>>): void => {
if (disabled || !drivers) return;
// Can only select a driver once
if (dnfs_chips.includes(event.detail.value)) return;
// Manage labels that are displayed
dnfs_chips.push(event.detail.value);
dnfs_input = "";
// Manage ids that are submitted via form
const id: string = get_by_value(drivers.filter((driver: Driver) => driver.active), "code", event.detail.value)?.id ?? "Invalid";
if (!dnfs_ids.includes(id)) {
dnfs_ids.push(id);
}
};
const on_dnfs_chip_remove = (event: CustomEvent): void => {
dnfs_ids.splice(event.detail.chipIndex, 1);
};
// Database actions
const update_raceresult = (create?: boolean): (() => Promise<void>) => {
const handler = async (): Promise<void> => {
if (!race_select_value || race_select_value === "") {
toastStore.trigger(get_error_toast("Please select a race!"));
return;
}
// If enough drivers DNF/DSQ, theoretically pxxs_ids could be empty
// if (!pxxs_ids || pxxs_ids.length !== 7) {
// toastStore.trigger(get_error_toast("Please select all 7 driver placements!"));
// return;
// }
const raceresult_data = {
race: race_select_value,
pxxs: pxxs_ids,
dnfs: dnfs_ids,
};
try {
if (create) {
await pb.collection("raceresults").create(raceresult_data);
} else {
if (!result?.id) {
toastStore.trigger(get_error_toast("Invalid result id!"));
return;
}
await pb.collection("raceresults").update(result.id, raceresult_data);
}
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
return handler;
};
const delete_raceresult = async (): Promise<void> => {
if (!result?.id) {
toastStore.trigger(get_error_toast("Invalid result id!"));
return;
}
try {
await pb.collection("raceresults").delete(result.id);
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
</script>
<Card width="w-full sm:w-[512px]">
<div class="flex flex-col gap-2">
<!-- Race select input -->
{#await data.races then races}
<Dropdown
name="race"
bind:value={race_select_value}
options={race_dropdown_options(races).filter(
(option: DropdownOption) => result || !present_results.includes(option.value),
)}
{labelwidth}
{disabled}
{required}
>
Race
</Dropdown>
{/await}
{#if race_select_value}
<!-- PXXs autocomplete chips -->
<InputChip
bind:input={pxxs_input}
bind:value={pxxs_chips}
whitelist={pxxs_whitelist}
allowUpperCase
placeholder={pxxs_placeholder}
name="pxxs_codes"
{disabled}
{required}
on:remove={on_pxxs_chip_remove}
/>
<div class="card max-h-48 w-full overflow-y-auto p-2" tabindex="-1">
<Autocomplete
bind:input={pxxs_input}
options={pxxs_options}
denylist={pxxs_chips}
on:selection={on_pxxs_chip_select}
/>
</div>
<!-- DNFs autocomplete chips -->
<InputChip
bind:input={dnfs_input}
bind:value={dnfs_chips}
whitelist={pxxs_whitelist}
allowUpperCase
placeholder="Select DNFs..."
name="dnfs_codes"
{disabled}
on:remove={on_dnfs_chip_remove}
/>
<div class="card max-h-48 w-full overflow-y-auto p-2" tabindex="-1">
<Autocomplete
bind:input={dnfs_input}
options={pxxs_options}
denylist={dnfs_chips}
on:selection={on_dnfs_chip_select}
/>
</div>
<!-- Save/Delete buttons -->
<div class="flex items-center justify-end gap-2">
{#if result}
<Button onclick={update_raceresult()} color="secondary" {disabled} width="w-1/2"
>Save</Button
>
<Button onclick={delete_raceresult} color="primary" {disabled} width="w-1/2"
>Delete</Button
>
{:else}
<Button onclick={update_raceresult(true)} color="tertiary" {disabled} width="w-full">
Create Result
</Button>
{/if}
</div>
{/if}
</div>
</Card>

View File

@ -1,399 +0,0 @@
<script lang="ts">
import { Card, Button, Dropdown, Input } from "$lib/components";
import type { Driver, SeasonPick, Team } from "$lib/schema";
import { get_by_value, get_driver_headshot_template } from "$lib/database";
import {
Autocomplete,
getModalStore,
getToastStore,
InputChip,
type AutocompleteOption,
type ModalStore,
type ToastStore,
} from "@skeletonlabs/skeleton";
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, pbUser } from "$lib/pocketbase";
import type { PageData } from "../../../routes/seasonpicks/$types";
interface SeasonPickCardProps {
/** Data passed from the page context */
data: PageData;
/** The [SeasonPick] object used to prefill values. */
seasonpick?: SeasonPick;
}
let { data, seasonpick = undefined }: SeasonPickCardProps = $props();
const modalStore: ModalStore = getModalStore();
if ($modalStore[0].meta) {
const meta = $modalStore[0].meta;
data = meta.data;
seasonpick = meta.seasonpick;
}
const toastStore: ToastStore = getToastStore();
// Await promises
let drivers: Driver[] | undefined = $state(undefined);
data.drivers.then((d: Driver[]) => (drivers = d));
let teams: Team[] | undefined = $state(undefined);
data.teams.then((t: Team[]) => (teams = t));
// Constants
const labelwidth: string = "150px";
// Reactive state
let required: boolean = $derived(!seasonpick);
let disabled: boolean = false; // TODO: Datelock
let hottake_value: string = $state(seasonpick?.hottake ?? "");
let wdc_value: string = $state(seasonpick?.wdcwinner ?? "");
let wcc_value: string = $state(seasonpick?.wccwinner ?? "");
let overtakes_value: string = $state(seasonpick?.mostovertakes ?? "");
let dnfs_value: string = $state(seasonpick?.mostdnfs ?? "");
let doohan_value: string = $state(seasonpick?.doohanstarts.toString() ?? "");
let teamwinners_input: string = $state("");
let teamwinners_chips: string[] = $state([]);
let podiums_input: string = $state("");
let podiums_chips: string[] = $state([]);
// Set the teamwinners/podiums states once the drivers are loaded
data.drivers.then(async (drivers: Driver[]) => {
teamwinners_chips =
seasonpick?.teamwinners.map(
(id: string) => get_by_value(drivers, "id", id)?.code ?? "Invalid",
) ?? [];
podiums_chips =
seasonpick?.podiums.map((id: string) => get_by_value(drivers, "id", id)?.code ?? "Invalid") ??
[];
});
// This is the actual data that gets sent through the form
let teamwinners_ids: string[] = $state(seasonpick?.teamwinners ?? []);
let podiums_ids: string[] = $state(seasonpick?.podiums ?? []);
let teamwinners_options: AutocompleteOption<string>[] = $derived.by(() =>
(drivers ?? [])
.filter((driver: Driver) => driver.active)
.map((driver: Driver) => {
const teamname: string = get_by_value(teams ?? [], "id", driver.team)?.name ?? "Invalid";
return {
firstname: driver.firstname,
lastname: driver.lastname,
code: driver.code,
teamname: teamname,
};
})
.sort((a, b) => a.teamname.localeCompare(b.teamname))
.map((driver) => {
return {
label: `${driver.teamname}: ${driver.firstname} ${driver.lastname}`,
value: driver.code,
};
}),
);
let teamwinners_whitelist: string[] = $derived.by(() =>
(drivers ?? []).map((driver: Driver) => driver.code),
);
let teamwinners_denylist: string[] = $derived.by(() => {
let denylist: string[] = [];
teamwinners_chips
.map((driver: string) => get_by_value(drivers ?? [], "code", driver))
.forEach((driver: Driver | undefined) => {
if (driver) {
(drivers ?? [])
.filter((d: Driver) => d.team === driver.team)
.forEach((d: Driver) => {
denylist.push(d.code);
});
}
});
return denylist;
});
let podiums_options: AutocompleteOption<string>[] = $derived.by(() =>
(drivers ?? [])
.filter((driver: Driver) => driver.active)
.sort((a: Driver, b: Driver) => a.firstname.localeCompare(b.firstname))
.map((driver: Driver) => {
return {
label: `${driver.firstname} ${driver.lastname}`,
value: driver.code,
};
}),
);
// Event handlers
const on_teamwinners_chip_select = (event: CustomEvent<AutocompleteOption<string>>): void => {
if (disabled || !drivers) return;
// Can only select 10 drivers
if (teamwinners_chips.length >= 10) return;
// Can only select a driver once
if (teamwinners_chips.includes(event.detail.value)) return;
// Manage labels that are displayed
teamwinners_chips.push(event.detail.value);
teamwinners_input = "";
// Manage ids that are submitted via form
const id: string = get_by_value(drivers, "code", event.detail.value)?.id ?? "Invalid";
if (!teamwinners_ids.includes(id)) {
teamwinners_ids.push(id);
}
};
const on_teamwinners_chip_remove = (event: CustomEvent): void => {
teamwinners_ids.splice(event.detail.chipIndex, 1);
};
const on_podiums_chip_select = (event: CustomEvent<AutocompleteOption<string>>): void => {
if (disabled || !drivers) return;
// Can only select a driver once
if (podiums_chips.includes(event.detail.value)) return;
// Manage labels that are displayed
podiums_chips.push(event.detail.value);
podiums_input = "";
// Manage ids that are submitted via form
const id: string = get_by_value(drivers, "code", event.detail.value)?.id ?? "Invalid";
if (!podiums_ids.includes(id)) {
podiums_ids.push(id);
}
};
const on_podiums_chip_remove = (event: CustomEvent): void => {
podiums_ids.splice(event.detail.chipIndex, 1);
};
// Database actions
const update_seasonpick = (create?: boolean): (() => Promise<void>) => {
const handler = async (): Promise<void> => {
if (!$pbUser?.id || $pbUser.id === "") {
toastStore.trigger(get_error_toast("Invalid user id!"));
return;
}
if (!hottake_value || hottake_value === "") {
toastStore.trigger(get_error_toast("Please enter a hottake!"));
return;
}
if (!wdc_value || wdc_value === "") {
toastStore.trigger(get_error_toast("Please select a driver for WDC!"));
return;
}
if (!wcc_value || wcc_value === "") {
toastStore.trigger(get_error_toast("Please select a team for WCC!"));
return;
}
if (!overtakes_value || overtakes_value === "") {
toastStore.trigger(get_error_toast("Please select a driver for most overtakes!"));
return;
}
if (!dnfs_value || dnfs_value === "") {
toastStore.trigger(get_error_toast("Please select a driver for most DNFs!"));
return;
}
if (
!doohan_value ||
doohan_value === "" ||
parseInt(doohan_value) <= 0 ||
parseInt(doohan_value) > 24
) {
toastStore.trigger(
get_error_toast("Please enter between 0 and 24 starts for Jack Doohan!"),
);
return;
}
if (!teamwinners_ids || teamwinners_ids.length !== 10) {
toastStore.trigger(get_error_toast("Please select a winner for each team!"));
return;
}
if (!podiums_ids || podiums_ids.length < 3) {
toastStore.trigger(get_error_toast("Please select at least 3 drivers with podiums!"));
return;
}
const seasonpick_data = {
user: $pbUser.id,
hottake: hottake_value,
wdcwinner: wdc_value,
wccwinner: wcc_value,
mostovertakes: overtakes_value,
mostdnfs: dnfs_value,
doohanstarts: doohan_value,
teamwinners: teamwinners_ids,
podiums: podiums_ids,
};
try {
if (create) {
await pb.collection("seasonpicks").create(seasonpick_data);
} else {
if (!seasonpick?.id) {
toastStore.trigger(get_error_toast("Invalid seasonpick id!"));
return;
}
await pb.collection("seasonpicks").update(seasonpick.id, seasonpick_data);
}
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
return handler;
};
const delete_seasonpick = async (): Promise<void> => {
if (!seasonpick?.id) {
toastStore.trigger(get_error_toast("Invalid seasonpick id!"));
return;
}
try {
await pb.collection("seasonpicks").delete(seasonpick.id);
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
</script>
{#await Promise.all([data.graphics, data.drivers, data.teams]) then [graphics, drivers, teams]}
<Card width="w-full sm:w-[512px]">
<div class="flex flex-col gap-2">
<!-- Hottake -->
<Input bind:value={hottake_value} {labelwidth} {disabled} {required}>Hottake</Input>
<!-- WDC select -->
<Dropdown
bind:value={wdc_value}
options={driver_dropdown_options(drivers.filter((driver: Driver) => driver.active))}
{labelwidth}
{disabled}
{required}
>
WDC
</Dropdown>
<!-- WCC select -->
<Dropdown
bind:value={wcc_value}
options={team_dropdown_options(teams)}
{labelwidth}
{disabled}
{required}
>
WCC
</Dropdown>
<!-- Overtakes select -->
<Dropdown
bind:value={overtakes_value}
options={driver_dropdown_options(drivers.filter((driver: Driver) => driver.active))}
{labelwidth}
{disabled}
{required}
>
Most Overtakes
</Dropdown>
<!-- DNFs select -->
<Dropdown
bind:value={dnfs_value}
options={driver_dropdown_options(drivers.filter((driver: Driver) => driver.active))}
{labelwidth}
{disabled}
{required}
>
Most DNFs
</Dropdown>
<!-- Doohan Starts -->
<Input
type="number"
min={0}
max={24}
bind:value={doohan_value}
{labelwidth}
{disabled}
{required}
>
Doohan Starts
</Input>
<!-- Teamwinners autocomplete chips -->
<InputChip
bind:input={teamwinners_input}
bind:value={teamwinners_chips}
whitelist={teamwinners_whitelist}
allowUpperCase
placeholder="Select Teamwinners..."
name="teamwinners_codes"
{disabled}
{required}
on:remove={on_teamwinners_chip_remove}
/>
<div class="card max-h-48 w-full overflow-y-auto p-2" tabindex="-1">
<Autocomplete
bind:input={teamwinners_input}
options={teamwinners_options}
denylist={teamwinners_denylist}
on:selection={on_teamwinners_chip_select}
/>
</div>
<!-- Podiums autocomplete chips -->
<InputChip
bind:input={podiums_input}
bind:value={podiums_chips}
whitelist={teamwinners_whitelist}
allowUpperCase
placeholder="Select Drivers with Podiums..."
name="podiums_codes"
{disabled}
{required}
on:remove={on_podiums_chip_remove}
/>
<div class="card max-h-48 w-full overflow-y-auto p-2" tabindex="-1">
<Autocomplete
bind:input={podiums_input}
options={podiums_options}
denylist={podiums_chips}
on:selection={on_podiums_chip_select}
/>
</div>
<!-- Save/Delete buttons -->
<div class="flex justify-end gap-2">
{#if seasonpick}
<Button onclick={update_seasonpick()} color="secondary" {disabled} width="w-1/2">
Save Changes
</Button>
<Button onclick={delete_seasonpick} color="primary" {disabled} width="w-1/2">
Delete
</Button>
{:else}
<Button onclick={update_seasonpick(true)} color="tertiary" {disabled} width="w-full">
Make Pick
</Button>
{/if}
</div>
</div>
</Card>
{/await}

View File

@ -1,179 +0,0 @@
<script lang="ts">
import { Card, Button, Dropdown } from "$lib/components";
import type { Driver, Substitution } from "$lib/schema";
import { get_by_value, get_driver_headshot_template } from "$lib/database";
import {
getModalStore,
getToastStore,
type ModalStore,
type ToastStore,
} from "@skeletonlabs/skeleton";
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, pbUser } from "$lib/pocketbase";
import type { PageData } from "../../../routes/data/season/substitutions/$types";
interface SubstitutionCardProps {
/** Data passed from the page context */
data: PageData;
/** The [Substitution] object used to prefill values. */
substitution?: Substitution;
}
let { data, substitution = undefined }: SubstitutionCardProps = $props();
const modalStore: ModalStore = getModalStore();
if ($modalStore[0].meta) {
const meta = $modalStore[0].meta;
data = meta.data;
substitution = meta.substitution;
}
const toastStore: ToastStore = getToastStore();
// Await promises
let drivers: Driver[] | undefined = $state(undefined);
data.drivers.then((d: Driver[]) => (drivers = d));
// Constants
const labelwidth: string = "120px";
// Reactive state
let required: boolean = $derived(!substitution);
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 ?? "");
let driver_value: string = $state(substitution?.for ?? "");
let race_value: string = $state(substitution?.race ?? "");
// Update preview
$effect(() => {
if (!drivers) return;
const src: string = get_by_value(drivers, "id", substitute_value)?.headshot_url ?? "";
const img: HTMLImageElement = document.getElementById("headshot_preview") as HTMLImageElement;
if (img) img.src = src;
});
// Database actions
const update_substitution = (create?: boolean): (() => Promise<void>) => {
const handler = async (): Promise<void> => {
if (!substitute_value || substitute_value === "") {
toastStore.trigger(get_error_toast("Please select a substitute driver!"));
return;
}
if (!driver_value || driver_value === "") {
toastStore.trigger(get_error_toast("Please select a replaced driver!"));
return;
}
if (!race_value || race_value === "") {
toastStore.trigger(get_error_toast("Please select a race!"));
return;
}
const substitution_data = {
substitute: substitute_value,
for: driver_value,
race: race_value,
};
try {
if (create) {
await pb.collection("substitutions").create(substitution_data);
} else {
if (!substitution?.id) {
toastStore.trigger(get_error_toast("Invalid substitution id!"));
return;
}
await pb.collection("substitutions").update(substitution.id, substitution_data);
}
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
return handler;
};
const delete_substitution = async (): Promise<void> => {
if (!substitution?.id) {
toastStore.trigger(get_error_toast("Invalid substitution id!"));
return;
}
try {
await pb.collection("substitutions").delete(substitution.id);
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
</script>
{#await data.drivers then drivers}
<Card
imgsrc={get_by_value(drivers, "id", substitution?.substitute ?? "")?.headshot_url ??
get_driver_headshot_template(data.graphics)}
imgid="headshot_preview"
width="w-full sm:w-auto"
imgwidth={DRIVER_HEADSHOT_WIDTH}
imgheight={DRIVER_HEADSHOT_HEIGHT}
imgonclick={(event: Event) => modalStore.close()}
>
<div class="flex flex-col gap-2">
<!-- Substitute select -->
<Dropdown
bind:value={substitute_value}
options={driver_dropdown_options(inactive_drivers)}
{labelwidth}
{disabled}
{required}
>
Substitute
</Dropdown>
<!-- Driver select -->
<Dropdown
bind:value={driver_value}
options={driver_dropdown_options(active_drivers)}
{labelwidth}
{disabled}
{required}
>
For
</Dropdown>
<!-- Race select -->
{#await data.races then races}
<Dropdown
bind:value={race_value}
options={race_dropdown_options(races)}
{labelwidth}
{disabled}
{required}
>
Race
</Dropdown>
{/await}
<!-- Save/Delete buttons -->
<div class="flex justify-end gap-2">
{#if substitution}
<Button onclick={update_substitution()} color="secondary" {disabled} width="w-1/2">
Save Changes
</Button>
<Button onclick={delete_substitution} color="primary" {disabled} width="w-1/2">
Delete
</Button>
{:else}
<Button onclick={update_substitution(true)} color="tertiary" {disabled} width="w-full">
Create Substitution
</Button>
{/if}
</div>
</div>
</Card>
{/await}

View File

@ -1,256 +0,0 @@
<script lang="ts">
import { get_image_preview_event_handler } from "$lib/image";
import {
FileDropzone,
getModalStore,
getToastStore,
type ModalStore,
type ToastStore,
} from "@skeletonlabs/skeleton";
import { Card, Button, Input, LazyImage } from "$lib/components";
import type { Team } from "$lib/schema";
import {
TEAM_BANNER_HEIGHT,
TEAM_BANNER_WIDTH,
TEAM_LOGO_HEIGHT,
TEAM_LOGO_WIDTH,
} from "$lib/config";
import { get_team_banner_template, get_team_logo_template } from "$lib/database";
import { get_error_toast } from "$lib/toast";
import { pb, pbUser } from "$lib/pocketbase";
import { error } from "@sveltejs/kit";
import type { PageData } from "../../../routes/data/season/teams/$types";
interface TeamCardProps {
/** Data from the page context */
data: PageData;
/** The [Team] object used to prefill values. */
team?: Team;
}
let { data, team = undefined }: TeamCardProps = $props();
const modalStore: ModalStore = getModalStore();
if ($modalStore[0].meta) {
const meta = $modalStore[0].meta;
data = meta.data;
team = meta.team;
}
const toastStore: ToastStore = getToastStore();
// Constants
const labelwidth: string = "110px";
// Reactive state
let required: boolean = $derived(!team);
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();
let logo_value: FileList | undefined = $state();
// Database actions
const update_team = (create?: boolean): (() => Promise<void>) => {
const handler = async (): Promise<void> => {
if (!name_value || name_value === "") {
toastStore.trigger(get_error_toast("Please enter a name!"));
return;
}
if (!color_value || color_value === "") {
toastStore.trigger(get_error_toast("Please enter a color!"));
return;
}
// Banner handling
let banner_avif: Blob | undefined = undefined;
const banner_file: File | undefined =
banner_value && banner_value.length === 1 ? banner_value[0] : undefined;
if (banner_file) {
const banner_formdata: FormData = new FormData();
banner_formdata.append("image", banner_file);
banner_formdata.append("width", TEAM_BANNER_WIDTH.toString());
banner_formdata.append("height", TEAM_BANNER_HEIGHT.toString());
try {
const response = await fetch("/api/compress", {
method: "POST",
body: banner_formdata,
});
if (!response.ok) {
error(500, "Compression failed.");
}
banner_avif = await response.blob();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
}
// Logo handling
let logo_avif: Blob | undefined = undefined;
const logo_file: File | undefined =
logo_value && logo_value.length === 1 ? logo_value[0] : undefined;
if (logo_file) {
const logo_formdata: FormData = new FormData();
logo_formdata.append("image", logo_file);
logo_formdata.append("width", TEAM_LOGO_WIDTH.toString());
logo_formdata.append("height", TEAM_LOGO_HEIGHT.toString());
try {
const response = await fetch("/api/compress", {
method: "POST",
body: logo_formdata,
});
if (!response.ok) {
error(500, "Compression failed.");
}
logo_avif = await response.blob();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
}
let team_data = {
name: name_value,
color: color_value,
banner: banner_avif,
logo: logo_avif,
};
// HACK: Having only a single file for the update request
// doesn't work with pocketbase for some reason
if (team_data.banner === undefined) {
delete team_data.banner;
}
if (team_data.logo === undefined) {
delete team_data.logo;
}
try {
if (create) {
if (!banner_avif) {
toastStore.trigger(get_error_toast("Please upload a single team banner!"));
return;
}
if (!logo_avif) {
toastStore.trigger(get_error_toast("Please upload a single team logo!"));
return;
}
await pb.collection("teams").create(team_data);
} else {
if (!team?.id) {
toastStore.trigger(get_error_toast("Invalid team id!"));
return;
}
await pb.collection("teams").update(team.id, team_data);
}
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
return handler;
};
const delete_team = async (): Promise<void> => {
if (!team?.id) {
toastStore.trigger(get_error_toast("Invalid team id!"));
return;
}
try {
await pb.collection("teams").delete(team.id);
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
</script>
<Card
imgsrc={team?.banner_url ?? get_team_banner_template(data.graphics)}
imgid="banner_preview"
width="w-full sm:w-auto"
imgwidth={TEAM_BANNER_WIDTH}
imgheight={TEAM_BANNER_HEIGHT}
imgonclick={(event: Event) => modalStore.close()}
>
<div class="flex flex-col gap-2">
<!-- Team name input -->
<Input bind:value={name_value} autocomplete="off" {labelwidth} {disabled} {required}>
Name
</Input>
<!-- Team color input -->
<Input
bind:value={color_value}
autocomplete="off"
placeholder="Enter as '#XXXXXX'"
minlength={7}
maxlength={7}
{labelwidth}
{disabled}
{required}
>
Color
<span class="badge ml-2 border" style="color: {color_value}; background: {color_value}">
C
</span>
</Input>
<!-- Banner upload -->
<FileDropzone
name="banner"
bind:files={banner_value}
onchange={get_image_preview_event_handler("banner_preview")}
{disabled}
{required}
>
<svelte:fragment slot="message">
<span class="font-bold">Upload Banner</span>
</svelte:fragment>
</FileDropzone>
<!-- Logo upload -->
<FileDropzone
name="logo"
bind:files={logo_value}
onchange={get_image_preview_event_handler("logo_preview")}
{disabled}
{required}
>
<svelte:fragment slot="message">
<div class="inline-flex flex-nowrap items-center gap-2">
<span class="font-bold">Upload Logo</span>
<LazyImage
src={team?.logo_url ?? get_team_logo_template(data.graphics)}
id="logo_preview"
imgwidth={32}
imgheight={32}
/>
</div>
</svelte:fragment>
</FileDropzone>
<!-- Save/Delete buttons -->
<div class="flex justify-end gap-2">
{#if team}
<Button onclick={update_team()} color="secondary" {disabled} width="w-1/2">Save</Button>
<Button onclick={delete_team} color="primary" {disabled} width="w-1/2">Delete</Button>
{:else}
<Button onclick={update_team(true)} color="tertiary" {disabled} width="w-full">
Create Team
</Button>
{/if}
</div>
</div>
</Card>

View File

@ -1,155 +0,0 @@
<script lang="ts">
import {
getModalStore,
getToastStore,
type ModalStore,
type ToastStore,
} from "@skeletonlabs/skeleton";
import { Card, Button, Dropdown } from "$lib/components";
import type { Driver } from "$lib/schema";
import { DRIVER_HEADSHOT_HEIGHT, DRIVER_HEADSHOT_WIDTH } from "$lib/config";
import { get_by_value, get_driver_headshot_template } from "$lib/database";
import { get_error_toast } from "$lib/toast";
import { pb, pbUser } from "$lib/pocketbase";
import type { PageData } from "../../../routes/data/season/drivers/$types";
import { driver_dropdown_options, team_dropdown_options } from "$lib/dropdown";
interface TeamCardProps {
/** Data from the page context */
data: PageData;
}
let { data }: TeamCardProps = $props();
const modalStore: ModalStore = getModalStore();
if ($modalStore[0].meta) {
const meta = $modalStore[0].meta;
data = meta.data;
}
const toastStore: ToastStore = getToastStore();
// Await promises
let drivers: Driver[] | undefined = $state(undefined);
data.drivers.then((d: Driver[]) => (drivers = d));
// Constants
const labelwidth: string = "110px";
// Reactive state
let required: boolean = true;
let disabled: boolean = $derived(!$pbUser?.admin);
let driver_value: string = $state("");
let team_value: string = $state("");
// Update preview
$effect(() => {
if (!drivers) return;
const src: string = get_by_value(drivers, "id", driver_value)?.headshot_url ?? "";
const img: HTMLImageElement = document.getElementById("headshot_preview") as HTMLImageElement;
if (img) img.src = src;
});
// Database actions
const update_driver = (): (() => Promise<void>) => {
const handler = async (): Promise<void> => {
if (!driver_value || driver_value === "") {
toastStore.trigger(get_error_toast("Please select a driver!"));
return;
}
const old_driver: Driver | undefined = get_by_value(await data.drivers, "id", driver_value);
if (!old_driver) {
toastStore.trigger(get_error_toast("Unable to lookup driver!"));
return;
}
if (!old_driver.headshot_url) {
toastStore.trigger(get_error_toast("Unable to lookup driver headshot!"));
return;
}
if (!team_value || team_value === "" || team_value === old_driver.team) {
toastStore.trigger(get_error_toast("Please select a new team!"));
return;
}
// Create a new driver
const headshot_response = await fetch(old_driver.headshot_url);
const headshot_blob = await headshot_response.blob();
let new_driver_data = {
code: old_driver.code,
firstname: old_driver.firstname,
lastname: old_driver.lastname,
headshot: headshot_blob, // NOTE: Duplicates the image, but no issue for low volume
team: team_value,
active: true,
started_active: false,
};
try {
await pb.collection("drivers").create(new_driver_data);
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
return;
}
// Disable the old driver
let old_driver_data = {
active: false,
};
try {
await pb.collection("drivers").update(driver_value, old_driver_data);
modalStore.close();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
};
return handler;
};
</script>
<Card
imgsrc={get_driver_headshot_template(data.graphics)}
imgid="headshot_preview"
width="w-full sm:w-auto"
imgwidth={DRIVER_HEADSHOT_WIDTH}
imgheight={DRIVER_HEADSHOT_HEIGHT}
imgonclick={(event: Event) => modalStore.close()}
>
<div class="flex flex-col gap-2">
<!-- Driver select -->
{#await data.drivers then drivers}
<Dropdown
bind:value={driver_value}
options={driver_dropdown_options(drivers.filter((driver: Driver) => driver.active))}
{labelwidth}
{disabled}
{required}
>
Driver
</Dropdown>
{/await}
<!-- New team select -->
{#await data.teams then teams}
<Dropdown
bind:value={team_value}
options={team_dropdown_options(teams)}
{labelwidth}
{disabled}
{required}
>
New Team
</Dropdown>
{/await}
<!-- Save/Delete buttons -->
<div class="flex justify-end gap-2">
<Button onclick={update_driver()} color="tertiary" {disabled} width="w-full">
Switch Team
</Button>
</div>
</div>
</Card>

View File

@ -1,105 +0,0 @@
<script lang="ts">
import { page } from "$app/state";
import type { Snippet } from "svelte";
import { popup, type PopupSettings } from "@skeletonlabs/skeleton";
const is_at_path = (path: string): boolean => {
const pathname: string = page.url.pathname;
// return pathname === path;
return pathname.endsWith(path);
};
interface ButtonProps {
children: Snippet;
/** The main color variant, e.g. "primary" or "secondary". */
color?: string;
/** Set the button type to "submit" (otherwise "button"). Only if "href" is undefined. */
submit?: boolean;
/** Make the button act as a link. */
href?: string;
/** Open the link inside a new tab. */
newtab?: boolean;
/** Add a width class to the button. */
width?: string;
/** 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;
/** Should the button have a shadow? */
shadow?: boolean;
/** Additional classes to insert */
extraclass?: string;
/** An optional onclick event for the button */
onclick?: (event: Event) => void;
/** An optional formaction for the button */
formaction?: string;
/** Optionally disable the button */
disabled?: boolean;
}
let {
children,
color = undefined,
submit = false,
href = undefined,
newtab = false,
width = "w-auto",
activate = false,
activate_href = false,
trigger_popup = { event: "click", target: "invalid" },
shadow = false,
extraclass = "",
onclick = () => {},
formaction = undefined,
disabled = false,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
{href}
target={newtab ? "_blank" : undefined}
rel={newtab ? "noopener noreferrer" : undefined}
class="btn m-0 select-none px-2 py-2 {color ? `variant-filled-${color}` : ''} {width} {activate
? 'btn-hover'
: ''} {activate_href && is_at_path(href) ? 'btn-hover' : ''} {shadow
? 'shadow'
: ''} {extraclass}"
{onclick}
{...restProps}
draggable="false"
>
{@render children()}
</a>
{:else}
<button
type={submit ? "submit" : "button"}
class="btn select-none px-2 py-2 {color ? `variant-filled-${color}` : ''} {width} {activate
? 'btn-hover'
: ''} {shadow ? 'shadow' : ''} {extraclass}"
draggable="false"
use:popup={trigger_popup}
{onclick}
{formaction}
{disabled}
{...restProps}
>
{@render children()}
</button>
{/if}

View File

@ -1,44 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { HTMLSelectAttributes } from "svelte/elements";
import { type DropdownOption } from "$lib/components";
interface DropdownProps extends HTMLSelectAttributes {
children: Snippet;
/** 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. */
value?: string;
/** The options this autocomplete component allows to choose from.
* Example: [[{ label: "Aston", value: "0" }, { label: "VCARB", value: "1" }]].
*/
options: DropdownOption[];
}
let {
children,
labelwidth = "auto",
value = $bindable(),
options,
...restProps
}: DropdownProps = $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>
<select bind:value class="!outline-none" {...restProps}>
{#each options as option}
<option value={option.value} selected={value === option.value}>
{option.label}
</option>
{/each}
</select>
</div>

View File

@ -1,16 +0,0 @@
export interface DropdownOption {
/** 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 icon width. Required if icon_url is set */
icon_width?: number;
/** The icon height. Required if icon_url is set */
icon_height?: number;
}

View File

@ -1,42 +0,0 @@
<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 variable to bind to the input element. Has to be a [$state] so its value can be updated with the input element's contents. */
value?: string;
/** The type of the input element, e.g. "text". */
type?: string;
/** An optional element at the end of the input group */
tail?: Snippet;
}
let {
children,
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 text-neutral-900"
style="width: {labelwidth};"
>
{@render children()}
</div>
<input bind:value class="{tail ? '!border-r' : ''} !border-l" {type} {...restProps} />
{#if tail}
{@render tail()}
{/if}
</div>

View File

@ -1,66 +1,7 @@
import Countdown from "./Countdown.svelte";
import LazyImage from "./LazyImage.svelte";
import LoadingIndicator from "./LoadingIndicator.svelte";
import Table from "./Table.svelte";
import FileInput from "./FileInput.svelte";
import Input from "./Input.svelte";
import Password from "./Password.svelte";
import Username from "./Username.svelte";
import Button from "./Button.svelte";
import Button from "./form/Button.svelte";
import Dropdown from "./form/Dropdown.svelte";
import Input from "./form/Input.svelte";
import Card from "./cards/Card.svelte";
import DriverCard from "./cards/DriverCard.svelte";
import RaceCard from "./cards/RaceCard.svelte";
import RacePickCard from "./cards/RacePickCard.svelte";
import RaceResultCard from "./cards/RaceResultCard.svelte";
import SeasonPickCard from "./cards/SeasonPickCard.svelte";
import SubstitutionCard from "./cards/SubstitutionCard.svelte";
import TeamCard from "./cards/TeamCard.svelte";
import TeamSwitchCard from "./cards/TeamSwitchCard.svelte";
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";
import StopwatchIcon from "./svg/StopwatchIcon.svelte";
import UserIcon from "./svg/UserIcon.svelte";
export {
// Components
Countdown,
LazyImage,
LoadingIndicator,
Table,
// Form
Button,
Dropdown,
Input,
// Cards
Card,
DriverCard,
RaceCard,
RacePickCard,
RaceResultCard,
SeasonPickCard,
SubstitutionCard,
TeamCard,
TeamSwitchCard,
// Types
type DropdownOption,
type TableColumn,
// SVG
ChequeredFlagIcon,
EMailIcon,
NameIcon,
MenuDrawerIcon,
PasswordIcon,
StopwatchIcon,
UserIcon,
};
export { FileInput, Input, Username, Password, Button };

View File

@ -1,24 +0,0 @@
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
viewBox="0 0 2400 2304"
stroke="currentColor"
fill="currentColor"
>
<g id="g4197">
<path
id="path4145"
d="m335.75 1345.9c-184.43-526.94-335.52-959.07-335.75-960.29-0.38434-2.01 102.58-49.62 107.32-49.61 0.96168 0.001 743.15 1889.5 744.16 1894.5 0.3655 1.8202-174.51 73.476-179.32 73.476-0.59033 0-151.97-431.14-336.4-958.09z"
/>
<path
id="path4141"
fill="#f2f2f2"
d="m479.24 1092.4c-221.54-563.11-287.61-732.01-286.84-733.27 7.92-12.81 103.37-85.21 156.29-118.55 226.19-142.47 522.47-212.74 817.01-193.76l10.476 0.67518 237.44 534.25c130.59 293.84 237.59 534.1 237.77 533.92 0.1795-0.1795-52.581-196.04-117.25-435.25-64.665-239.21-117.8-436.29-118.09-437.96l-0.516-3.0317 17.345-3.7869c222.72-48.624 369.79-113.98 489.63-217.57 7.5438-6.5212 15.038-13.257 16.653-14.968 1.7-1.7002 3.5-3.1 4.2-3.1 1.0301 0 455.19 1145.4 456.25 1150.7 0.7419 3.6924-13.803 26.955-29.267 46.807-137.48 176.49-482.03 362.83-734.1 397-156.36 21.198-262.46-15.13-311.29-106.58-6.8919-12.907-7.3571-11.514 9.2052-27.574 83.584-81.054 212.39-148.52 266.24-139.44 49.089 8.2692 39.627 66.512-28.664 176.44l-6.9354 11.164 5.7665-6.1213c212.42-225.49 188.76-339.31-50.521-242.99-248.98 100.22-584.66 349.95-741.13 551.36-5.6613 7.2875-10.616 13.25-11.01 13.25-0.39424 0-130.25-329.23-288.57-731.62z"
/>
<path
id="path4"
d="m668.98 99.711c-185.2 52.799-345.52 142.32-477.02 260.67l179.35 455.84 60.609-47.91-0.004-0.0117c119.97-95.98 239.95-167.97 407.92-239.95l-120.96-302.41-0.47656-0.0273-21.637-55.258-0.89258-2.2305 0.0176-0.006zm170.86 428.64 191.96 443.91c112.73-48.314 271.77-103.22 403.12-122.47l0.7969-0.7793 91.461-9.9082-181.5-408.37-73.953 1.5723 0.024 0.0527c-155.9 12.01-311.89 48.01-431.86 96zm191.96 443.91c-140.47 70.235-280.93 149.62-410.26 255.96-22.925 18.287-46.909 36.51-67.477 52.494l213.79 543.38c106.09-141.46 281.67-288.69 452.78-401.9l-40.387-94.037 0.457-0.3204zm533.38-769.13c-48.416 14.131-98.297 26.178-149.45 37.271l122.38 453.51c62.388-11.998 112.78-26.395 187.16-55.189l-76.094-207.9-0.3125 0.23242zm160.09 435.59 146.37 405.52c142.16-65.251 229.99-125.98 316.27-208.56l-0.023-0.36719 61.557-62.297-141.23-356.8-71.734 55.709-0.8008-0.19922c-109.06 84.355-199.77 130.11-310.4 166.99zm146.37 405.52c-55.189 28.794-146.37 64.788-215.96 83.984l-0.1152-0.2597-5.2402 1.5449 41.934 101.34c64.01 29.859-6.8158 155.86-132.56 283.7 215.96-326.34-119.98-170.37-239.95-38.392 89.139 200.56 426.63 129.77 704.94-15.887l-25.26-66.021c-0.2045 0.1064-0.4109 0.2179-0.6153 0.3242l-13.688-37.705-3.164-8.2695 0.1035-0.1582z"
fill="#343434"
/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

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

Before

Width:  |  Height:  |  Size: 3.0 KiB

View File

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

Before

Width:  |  Height:  |  Size: 254 B

View File

@ -1,10 +0,0 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 411 511.71"
fill="currentColor"
class="h-4 w-4 opacity-70"
>
<path
d="M69.04 126.32h70.44L76.02 0h40.4l63.25 126.32h49.54L292.22.01h40.16l-62.87 126.31h72.46c37.9 0 69.03 31.13 69.03 69.03v247.33c0 37.9-31.13 69.03-69.03 69.03H69.04C31.07 511.71 0 480.64 0 442.68V195.35c0-37.96 31.08-69.03 69.04-69.03zm36.57 231.81L89.13 334.2c-.58-.79-.94-2.51-1.08-5.17h-.43v29.1H66.06v-67.37h20.27l16.48 23.94c.58.78.94 2.5 1.08 5.17h.43v-29.11h21.57v67.37h-20.28zm49.53 0H132.4l17.46-67.37h33.31l17.46 67.37h-22.75l-2.48-10.67h-17.77l-2.49 10.67zm9.53-46.68-4.42 18.87h12.42l-4.31-18.87h-3.69zm62.31 46.68h-22.53l4.1-67.37h28.13l8.41 34.28h.75l8.41-34.28h28.13l4.1 67.37h-22.52l-1.31-32.66h-.75l-8.19 32.66h-16.49l-8.3-32.66h-.64l-1.3 32.66zm113.11-25.44h-21.55v10.71h26.41v14.73h-47.97v-67.37h47.42l-2.68 14.74h-23.18v11.57h21.55v15.62zM154.5 437.39h102v17.27h-102v-17.27zm-53.72-44.49h209.43v17.27H100.78V392.9zm104.07-217.74 12.62-25.3H69.04c-25.03 0-45.5 20.47-45.5 45.49v247.33c0 24.96 20.53 45.49 45.5 45.49h272.93c24.97 0 45.49-20.53 45.49-45.49V195.35c0-25.02-20.47-45.49-45.49-45.49h-84.2l-20.65 41.39c5.41 7 8.62 15.77 8.62 25.29 0 22.86-18.53 41.39-41.39 41.39-22.85 0-41.39-18.53-41.39-41.39s18.54-41.39 41.39-41.39l.5.01z"
/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

Before

Width:  |  Height:  |  Size: 424 B

View File

@ -1,27 +0,0 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 488.7 488.7"
fill="currentColor"
class="h-4 w-4 opacity-70"
>
<path
d="M145.512,284.7c0,7-5.6,12.8-12.7,12.8c-3.5,0-6.7-1.4-9-3.7c-2.3-2.3-3.7-5.5-3.7-9c0-7,5.6-12.8,12.7-12.8
C139.712,272,145.512,277.7,145.512,284.7z M154.012,348.2c-5,5-4.9,13,0,18l0,0c5,4.9,13.1,4.9,18-0.1c5-5,4.9-13.1-0.1-18
C167.012,343.2,158.913,343.2,154.012,348.2z M235.313,194.5c7,0,12.8-5.7,12.8-12.8s-5.7-12.8-12.8-12.8s-12.8,5.7-12.8,12.8
c0,3.5,1.4,6.7,3.7,9C228.613,193.1,231.813,194.5,235.313,194.5z M153.512,221.2c5,4.9,13.1,4.9,18-0.1l0.1-0.1
c0.1-0.1,0.1-0.1,0.2-0.2c5-5,5-13,0-18s-13.1-5-18,0c-0.1,0.1-0.1,0.1-0.2,0.2c-0.1,0.1-0.1,0.1-0.2,0.2
C148.512,208.1,148.512,216.2,153.512,221.2L153.512,221.2z M235.613,374.3c-7.1,0-12.7,5.7-12.7,12.8c0,3.5,1.4,6.7,3.7,9
s5.5,3.7,9.1,3.7c7,0,12.7-5.7,12.7-12.8C248.413,380,242.712,374.3,235.613,374.3z M299.112,347.8c-5,5-5,13.1,0,18
c5,5,13.1,5,18,0c5-5,4.9-13.1,0-18.1C312.112,342.8,304.013,342.8,299.112,347.8z M338.013,271.5c-7.1,0-12.8,5.7-12.8,12.8
c0,3.5,1.4,6.7,3.7,9c2.3,2.3,5.5,3.8,9,3.7c7.1,0,12.7-5.7,12.8-12.8C350.813,277.2,345.112,271.5,338.013,271.5z M235.913,488.7
c-112.5,0-204.1-91.6-204.1-204.1c0-104.4,78.9-190.7,180.2-202.6V51.1h-12.7c-6.4,0-11.5-5.2-11.5-11.5V11.5
c0-6.4,5.2-11.5,11.5-11.5h73.2c6.4,0,11.5,5.2,11.5,11.5v28.1c0,6.4-5.2,11.5-11.5,11.5h-12.7v30.8
c38.5,4.5,73.7,19.8,102.6,42.7l16.6-16.6l-1.5-1.5c-4.5-4.5-4.5-11.8,0-16.3l22.8-22.8c4.5-4.5,11.8-4.5,16.3,0l36.9,36.9
c4.5,4.5,4.5,11.8,0,16.3l-22.8,22.8c-4.5,4.5-11.8,4.5-16.3,0l-1.5-1.5l-16.6,16.6c27.4,34.7,43.7,78.5,43.7,126
C440.013,397.1,348.413,488.7,235.913,488.7z M392.612,284.6c0-86.4-70.3-156.7-156.7-156.7s-156.7,70.3-156.7,156.7
s70.2,156.7,156.7,156.7C322.313,441.3,392.612,371,392.612,284.6z M317.913,201.8c4.7,4.7,5,12.3,0.7,17.3l-52,60.9
c1.3,9.5-1.6,19.4-8.9,26.7c-12.3,12.3-32.3,12.3-44.7,0c-12.3-12.3-12.3-32.3,0-44.7c7.3-7.3,17.2-10.2,26.7-8.9l60.9-52
C305.712,196.8,313.212,197.1,317.913,201.8L317.913,201.8z M244.413,275.4c-5-5-13.1-5-18,0c-5,5-5,13.1,0,18c5,5,13.1,5,18,0
C249.413,288.4,249.413,280.4,244.413,275.4z"
/>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

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

Before

Width:  |  Height:  |  Size: 279 B

View File

@ -1,45 +0,0 @@
// 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_BANNER_WIDTH: number = 512;
export const TEAM_BANNER_HEIGHT: number = 288;
export const TEAM_LOGO_WIDTH: number = 96;
export const TEAM_LOGO_HEIGHT: number = 96;
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;
// Define the background colors the picks will have depending on the raceresult
export const PXX_COLORS: string[] = [];
PXX_COLORS[-1] = "auto";
PXX_COLORS[0] = "#C2FBCC"; // 1 Point
PXX_COLORS[6] = "#C2FBCC";
PXX_COLORS[1] = "#6CDB7E"; // 3 Points
PXX_COLORS[5] = "#6CDB7E";
PXX_COLORS[2] = "#07B725"; // 6 Points
PXX_COLORS[4] = "#07B725";
PXX_COLORS[3] = "#EFBF04"; // 10 Points

View File

@ -1,25 +0,0 @@
import type { Graphic } from "$lib/schema";
/**
* 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));
};
export const get_team_banner_template = (graphics: Graphic[]) =>
get_by_value(graphics, "name", "team_banner_template")?.file_url ?? "Invalid";
export const get_team_logo_template = (graphics: Graphic[]) =>
get_by_value(graphics, "name", "team_logo_template")?.file_url ?? "Invalid";
export const get_driver_headshot_template = (graphics: Graphic[]) =>
get_by_value(graphics, "name", "driver_headshot_template")?.file_url ?? "Invalid";
export const get_race_pictogram_template = (graphics: Graphic[]) =>
get_by_value(graphics, "name", "race_pictogram_template")?.file_url ?? "Invalid";

View File

@ -1,31 +0,0 @@
import { format } from "date-fns";
/**
* 2025-03-28T17:35
*/
export const isodatetimeformat: string = "yyyy-MM-dd'T'HH:mm";
/**
* 28.03. 17:35
*/
export const shortdatetimeformat: string = "dd.MM.' 'HH:mm";
/**
* 2025-03-28
*/
export const isodateformat: string = "yyyy-MM-dd";
/**
* 17:35
*/
export const timeformat: string = "HH:mm";
/**
* Format a [Date] object using a [date-fns] formatstring.
* This function uses localtime instead of UTC.
*/
export const format_date = <T extends Date | string>(date: T, formatstring: string): string => {
if (!date) return "";
return format(new Date(date), formatstring);
};

View File

@ -1,58 +0,0 @@
import type { DropdownOption } from "$lib/components";
import type { Driver, Race, Team } from "$lib/schema";
import {
DRIVER_HEADSHOT_HEIGHT,
DRIVER_HEADSHOT_WIDTH,
RACE_PICTOGRAM_HEIGHT,
RACE_PICTOGRAM_WIDTH,
TEAM_BANNER_HEIGHT,
TEAM_BANNER_WIDTH,
} from "$lib/config";
/**
* Generates a list of [DropdownOptions] for a <Dropdown> component.
*/
export const team_dropdown_options = (teams: Team[]): DropdownOption[] =>
teams
.sort((a: Team, b: Team) => a.name.localeCompare(b.name))
.map((team: Team) => {
return {
label: team.name,
value: team.id,
icon_url: team.banner_url,
icon_width: TEAM_BANNER_WIDTH,
icon_height: TEAM_BANNER_HEIGHT,
};
});
/**
* Generates a list of [DropdownOptions] for a <Dropdown> component.
*/
export const driver_dropdown_options = (drivers: Driver[]): DropdownOption[] =>
drivers
.sort((a: Driver, b: Driver) => a.lastname.localeCompare(b.lastname))
.map((driver: Driver) => {
return {
label: `${driver.firstname} ${driver.lastname}`,
value: driver.id,
icon_url: driver.headshot_url,
icon_width: DRIVER_HEADSHOT_WIDTH,
icon_height: DRIVER_HEADSHOT_HEIGHT,
};
});
/**
* Generates a list of [DropdownOptions] for a <Dropdown> component.
*/
export const race_dropdown_options = (races: Race[]): DropdownOption[] =>
races
.sort((a: Race, b: Race) => a.step - b.step)
.map((race: Race) => {
return {
label: race.name,
value: race.id,
icon_url: race.pictogram_url,
icon_width: RACE_PICTOGRAM_WIDTH,
icon_height: RACE_PICTOGRAM_HEIGHT,
};
});

View File

@ -1,375 +0,0 @@
import { get } from "svelte/store";
import { pb, pbUser } from "./pocketbase";
import type {
CurrentPickedUser,
Driver,
Graphic,
Hottake,
Race,
RacePick,
RacePickPoints,
RacePickPointsAcc,
RacePickPointsTotal,
RaceResult,
ScrapedDriverStanding,
ScrapedRaceResult,
ScrapedRaceResultAcc,
ScrapedStartingGrid,
ScrapedTeamStanding,
SeasonPick,
SeasonPickedUser,
Substitution,
Team,
User,
} from "./schema";
/**
* Fetch all [Graphics] from the database with file URLs
*/
export const fetch_graphics = async (fetch: (_: any) => Promise<Response>): Promise<Graphic[]> => {
const graphics: Graphic[] = await pb.collection("graphics").getFullList({ fetch: fetch });
graphics.map((graphic: Graphic) => {
graphic.file_url = pb.files.getURL(graphic, graphic.file);
});
return graphics;
};
/**
* Fetch all [Teams] (sorted ascending by name) from the database with file URLs for banners/logos
*/
export const fetch_teams = async (fetch: (_: any) => Promise<Response>): Promise<Team[]> => {
const teams: Team[] = await pb.collection("teams").getFullList({
sort: "+name",
fetch: fetch,
});
teams.map((team: Team) => {
team.banner_url = pb.files.getURL(team, team.banner);
team.logo_url = pb.files.getURL(team, team.logo);
});
return teams;
};
/**
* Fetch all [Drivers] (sorted ascending by code) from the database with file URLs for headshots
*/
export const fetch_drivers = async (fetch: (_: any) => Promise<Response>): Promise<Driver[]> => {
const drivers: Driver[] = await pb.collection("drivers").getFullList({
sort: "+code",
fetch: fetch,
});
drivers.map((driver: Driver) => {
driver.headshot_url = pb.files.getURL(driver, driver.headshot);
});
return drivers;
};
/**
* Fetch all [Races] (sorted ascending by step) from the database with file URLs for pictograms
*/
export const fetch_races = async (fetch: (_: any) => Promise<Response>): Promise<Race[]> => {
const races: Race[] = await pb.collection("races").getFullList({
sort: "+step",
fetch: fetch,
});
races.map((race: Race) => {
race.pictogram_url = pb.files.getURL(race, race.pictogram);
});
return races;
};
/**
* Fetch all [Substitutions] (sorted ascending by race step) from the database
*/
export const fetch_substitutions = async (
fetch: (_: any) => Promise<Response>,
): Promise<Substitution[]> => {
const substitutions: Substitution[] = await pb.collection("substitutions").getFullList({
expand: "race",
fetch: fetch,
});
// Sort by race step (ascending)
substitutions.sort((a: Substitution, b: Substitution) => a.expand.race.step - b.expand.race.step);
return substitutions;
};
/**
* Fetch all [RaceResults] (sorted descending by race step - newest first) from the database
*/
export const fetch_raceresults = async (
fetch: (_: any) => Promise<Response>,
): Promise<RaceResult[]> => {
const raceresults: RaceResult[] = await pb
.collection("raceresultsdesc")
.getFullList({ fetch: fetch });
return raceresults;
};
/**
* Fetch all [Users] (sorted ascending by username) with file URLs for avatars
*/
export const fetch_users = async (fetch: (_: any) => Promise<Response>): Promise<User[]> => {
const users: User[] = await pb
.collection("users")
.getFullList({ fetch: fetch, sort: "+username" });
users.map((user: User) => {
if (user.avatar) {
user.avatar_url = pb.files.getURL(user, user.avatar);
}
});
return users;
};
/**
* Fetch the first [Race] without result from the database with file URL for the pictogram
*/
export const fetch_currentrace = async (
fetch: (_: any) => Promise<Response>,
): Promise<Race | null> => {
const currentrace: Race[] = await pb.collection("currentrace").getFullList({ fetch: fetch });
// The currentrace collection either has a single or no entries
if (currentrace.length == 0) return null;
currentrace[0].pictogram_url = pb.files.getURL(currentrace[0], currentrace[0].pictogram);
return currentrace[0];
};
/**
* Fetch all visible [RacePicks] from the database
*/
export const fetch_visibleracepicks = async (
fetch: (_: any) => Promise<Response>,
): Promise<RacePick[]> => {
const racepicks: RacePick[] = await pb
.collection("visibleracepicks")
.getFullList({ fetch: fetch, expand: "user" });
return racepicks;
};
/**
* Fetch the [RacePick] for the current race by the current user from the database
*/
export const fetch_currentracepick = async (
fetch: (_: any) => Promise<Response>,
): Promise<RacePick | undefined> => {
const user: User | undefined = get(pbUser);
if (!user) return undefined;
const currentpickeduser: CurrentPickedUser = await pb
.collection("currentpickedusers")
.getOne(user.id, { fetch: fetch });
if (!currentpickeduser.picked) return undefined;
const racepick: RacePick = await pb
.collection("racepicks")
.getOne(currentpickeduser.picked, { fetch: fetch });
return racepick;
};
/**
* Fetch all visible [SeasonPicks] from the database
*/
export const fetch_visibleseasonpicks = async (
fetch: (_: any) => Promise<Response>,
): Promise<SeasonPick[]> => {
const seasonpicks: SeasonPick[] = await pb
.collection("visibleseasonpicks")
.getFullList({ fetch: fetch, expand: "user" });
return seasonpicks;
};
/**
* Fetch all [Hottakes] from the databse
*/
export const fetch_hottakes = async (fetch: (_: any) => Promise<Response>): Promise<Hottake[]> => {
const hottakes: Hottake[] = await pb
.collection("hottakes")
.getFullList({ fetch: fetch, expand: "user" });
return hottakes;
};
/**
* Fetch the [SeasonPick] by the current user from the database
*/
export const fetch_currentseasonpick = async (
fetch: (_: any) => Promise<Response>,
): Promise<SeasonPick | undefined> => {
const user: User | undefined = get(pbUser);
if (!user) return undefined;
const seasonpickeduser: CurrentPickedUser = await pb
.collection("seasonpickedusers")
.getOne(user.id, { fetch: fetch });
if (!seasonpickeduser.picked) return undefined;
const seasonpick: SeasonPick = await pb
.collection("seasonpicks")
.getOne(seasonpickeduser.picked, { fetch: fetch });
return seasonpick;
};
/**
* Fetch all [Users] (with the extra field "picked" that is truthy
* if the user already picked for the current race)
* for the current race with file URLs for the avatars
*/
export const fetch_currentpickedusers = async (
fetch: (_: any) => Promise<Response>,
): Promise<CurrentPickedUser[]> => {
const currentpickedusers: CurrentPickedUser[] = await pb
.collection("currentpickedusers")
.getFullList({ fetch: fetch });
currentpickedusers.map((currentpickeduser: CurrentPickedUser) => {
if (currentpickeduser.avatar) {
currentpickeduser.avatar_url = pb.files.getURL(currentpickeduser, currentpickeduser.avatar);
}
});
return currentpickedusers;
};
/**
* Fetch all [Users] (with the extra field "picked" that is truthy
* if the user already picked for the season) with file URLs for the avatars
*/
export const fetch_seasonpickedusers = async (
fetch: (_: any) => Promise<Response>,
): Promise<SeasonPickedUser[]> => {
const seasonpickedusers: SeasonPickedUser[] = await pb
.collection("seasonpickedusers")
.getFullList({ fetch: fetch });
seasonpickedusers.map((seasonpickeduser: SeasonPickedUser) => {
if (seasonpickeduser.avatar) {
seasonpickeduser.avatar_url = pb.files.getURL(seasonpickeduser, seasonpickeduser.avatar);
}
});
return seasonpickedusers;
};
/**
* Fetch all [RacePickPoints] from the database
*/
export const fetch_racepickpoints = async (
fetch: (_: any) => Promise<Response>,
): Promise<RacePickPoints[]> => {
const racepickpoints: RacePickPoints[] = await pb
.collection("racepickpoints")
.getFullList({ fetch: fetch });
return racepickpoints;
};
/**
* Fetch all [RacePickPointsAcc] from the database, ordered ascendingly by step.
*/
export const fetch_racepickpointsacc = async (
fetch: (_: any) => Promise<Response>,
): Promise<RacePickPointsAcc[]> => {
const racepickpointsacc: RacePickPointsAcc[] = await pb
.collection("racepickpointsacc")
.getFullList({ fetch: fetch });
return racepickpointsacc;
};
/**
* Fetch all [RacePickPointsTotal] from the database, ordered descendingly by total points.
*/
export const fetch_racepickpointstotal = async (
fetch: (_: any) => Promise<Response>,
): Promise<RacePickPointsTotal[]> => {
const racepickpointstotal: RacePickPointsTotal[] = await pb
.collection("racepickpointstotal")
.getFullList({ fetch: fetch });
return racepickpointstotal;
};
/**
* Fetch all [ScrapedDriverStandings] from the database, ordered ascendingly by position.
*/
export const fetch_scraped_driverstandings = async (
fetch: (_: any) => Promise<Response>,
): Promise<ScrapedDriverStanding[]> => {
const scraped_driverstandings: ScrapedDriverStanding[] = await pb
.collection("scraped_driverstandings")
.getFullList({ fetch: fetch, sort: "+position" });
return scraped_driverstandings;
};
/**
* Fetch all [ScrapedTeamStandings] from the database, ordered ascendingly by position.
*/
export const fetch_scraped_teamstandings = async (
fetch: (_: any) => Promise<Response>,
): Promise<ScrapedTeamStanding[]> => {
const scraped_teamstandings: ScrapedTeamStanding[] = await pb
.collection("scraped_teamstandings")
.getFullList({ fetch: fetch, sort: "+position" });
return scraped_teamstandings;
};
/**
* Fetch all [ScrapedStartingGrids] from the database, ordered descendingly by race step.
*/
export const fetch_scraped_startinggrids = async (
fetch: (_: any) => Promise<Response>,
): Promise<ScrapedStartingGrid[]> => {
const scraped_startinggrids: ScrapedStartingGrid[] = await pb
.collection("scraped_startinggrids")
.getFullList({ fetch: fetch, sort: "-race_step,+position" });
return scraped_startinggrids;
};
/**
* Fetch all [ScrapedRaceResults] from the database, ordered descendingly by race step.
*/
export const fetch_scraped_raceresults = async (
fetch: (_: any) => Promise<Response>,
): Promise<ScrapedRaceResult[]> => {
const scraped_raceresults: ScrapedRaceResult[] = await pb
.collection("scraped_raceresults")
.getFullList({ fetch: fetch, sort: "-race_step,+position" });
return scraped_raceresults;
};
/**
* Fetch all [ScrapedRaceResultsAcc] from the database, ordered ascendingly by race step.
*/
export const fetch_scraped_raceresultsacc = async (
fetch: (_: any) => Promise<Response>,
): Promise<ScrapedRaceResultAcc[]> => {
const scraped_raceresultsacc: ScrapedRaceResultAcc[] = await pb
.collection("scraped_raceresultsacc")
.getFullList({ fetch: fetch, sort: "+race_step" });
return scraped_raceresultsacc;
};

48
src/lib/forms.ts Normal file
View File

@ -0,0 +1,48 @@
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 and files from FormData objects.
*/
export const form_data_clean = (data: FormData): FormData => {
for (const [key, value] of data.entries()) {
if (value === "") {
// Remove empty keys
data.delete(key);
} else if (
// Remove empty files
typeof value === "object" &&
value !== null &&
"size" in value &&
value.size === 0
) {
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) => {
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[]) => {
keys.map((key) => form_data_ensure_key(data, key));
};

View File

@ -1,20 +1,11 @@
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;
export const get_image_preview_event_handler = (id: string) => {
const handler = (event) => {
const target = event.target;
const files = target.files;
if (files.length > 0) {
const src = URL.createObjectURL(files[0]);
const preview = document.getElementById(id) as HTMLImageElement;
if (preview) {
preview.src = src;
preview.hidden = false;
@ -24,62 +15,3 @@ export const get_avatar_preview_event_handler = (id: string): ((event: Event) =>
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,35 +0,0 @@
// 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);
},
};
};

View File

@ -1,88 +0,0 @@
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");
// 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);
} else {
const driver_headshot_template: Graphic = await pb
.collection("graphics")
.getFirstListItem('name="driver_headshot_template"');
avatar_url = pb.files.getURL(driver_headshot_template, driver_headshot_template.file);
}
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);
};
// 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);
console.log("Updating pbUser...");
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>) => {
invalidate(`data:${collection}`);
});
});
};
/**
* Unsubscribe from PocketBase realtime collections
*/
export const unsubscribe = (collections: string[]) => {
collections.forEach((collection: string) => {
pb.collection(collection).unsubscribe("*");
});
};

View File

@ -1,198 +0,0 @@
// NOTE: The "expand" fields might be undefined.
// I'm not using "expand?" because I won't check for undefined anyways.
// Application Data
export interface Graphic {
name: string;
file: string;
file_url?: string;
}
export interface User {
id: string;
verified: boolean;
username: string;
firstname: string;
email?: string;
avatar?: string;
avatar_url?: string;
admin: boolean;
}
// Season Data
export interface Team {
id: string;
name: string;
banner: string;
banner_url?: string;
logo: string;
logo_url?: string;
color: string;
}
export interface Driver {
id: string;
code: string;
firstname: string;
lastname: string;
headshot: string;
headshot_url?: string;
team: string;
active: boolean;
started_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;
expand: {
race: Race;
};
}
// User Data
export interface RacePick {
id: string;
user: string;
race: string;
pxx?: string;
dnf?: string;
expand: {
user: User;
};
}
export interface SeasonPick {
id: string;
user: string;
hottake: string;
wdcwinner: string;
wccwinner: string;
mostovertakes: string;
mostdnfs: string;
doohanstarts: number;
teamwinners: string[];
podiums: string[];
expand: {
user: User;
};
}
export interface Hottake {
id: string;
user: string;
hottake: string;
}
export interface RaceResult {
id: string;
race: string;
pxxs: string[];
dnfs: string[];
}
export interface CurrentPickedUser {
id: string;
username: string;
firstname: string;
avatar: string;
avatar_url?: string;
admin: boolean;
picked: string | null;
}
export interface SeasonPickedUser {
id: string;
username: string;
firstname: string;
avatar: string;
avatar_url?: string;
admin: boolean;
picked: string | null;
}
// Points Data
export interface RacePickPoints {
id: string;
user: string;
step: number;
pxx_points: number;
dnf_points: number;
}
export interface RacePickPointsAcc {
id: string;
user: string;
step: number;
acc_pxx_points: number;
acc_dnf_points: number;
acc_points: number;
}
export interface RacePickPointsTotal {
id: string;
user: string;
total_pxx_points: number;
total_dnf_points: number;
total_points: number;
total_points_per_pick: number;
}
// Scraped Data
export interface ScrapedStartingGrid {
id: string;
race_step: number; // This maps to races
driver_code: string; // This maps to drivers
position: number;
time: string;
}
export interface ScrapedRaceResult {
id: string;
race_step: number; // This maps to races
driver_code: string; // This maps to drivers
position: number;
status: string; // Either contains time to leader or DNF/DSQ...
points: number;
}
export interface ScrapedRaceResultAcc {
id: string;
race_step: number; // This maps to races
driver_code: string; // This maps to drivers
acc_points: number;
}
export interface ScrapedDriverStanding {
id: string;
driver_code: string; // This maps to drivers
position: number;
points: number;
}
export interface ScrapedTeamStanding {
id: string;
team_fullname: string; // TODO: This does NOT map to teams! Add fullname to team data!
position: number;
points: number;
}

View File

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

View File

@ -1,181 +0,0 @@
import type {
ScrapedDriverStanding,
ScrapedStartingGrid,
ScrapedRaceResult,
ScrapedTeamStanding,
} from "$lib/schema";
import * as cheerio from "cheerio";
// TODO: Validate the generated stuff
export const base_url: string = "https://www.formula1.com/en/results/2025";
/**
* Returns a list of links to all past races of the season,
* based on official f1.com data.
*/
export const scrape_race_links = async (): Promise<string[]> => {
const races_response = await fetch(`${base_url}/races`);
const races_text = await races_response.text();
const $ = cheerio.load(races_text);
const race_links: string[] = [];
$("tbody > tr > td:first-child > p > a[href]", "table.f1-table").each((_, element) => {
const href: string = element.attribs["href"];
// Keks changed the link format, cut off the start
const substring: string = href.replace("/../../en/results/2025/", "");
race_links.push(substring);
});
console.log(`Found ${race_links.length} races...`);
console.log(race_links);
return race_links;
};
/**
* Returns a list of [ScrapedStartingGrids] for all races contained in [race_links],
* based on official f1.com data.
*/
export const scrape_starting_grids = async (
race_links: string[],
): Promise<ScrapedStartingGrid[]> => {
// Update the race_links to point to the qualifications
const starting_grid_links: string[] = race_links.map((link: string) =>
link.replace("/race-result", "/starting-grid"),
);
const starting_grids: ScrapedStartingGrid[] = [];
await Promise.all(
starting_grid_links.map(async (link: string, index: number) => {
console.log(`Fetching qualifying results from ${base_url}/${link}...`);
const starting_grids_response = await fetch(`${base_url}/${link}`);
const starting_grids_text = await starting_grids_response.text();
const $ = cheerio.load(starting_grids_text);
// Obtain the positions for this starting grid for each driver
$("tbody > tr", "table.f1-table").each((driver_index, element) => {
const $$ = cheerio.load(element);
let result: ScrapedStartingGrid = {
id: "",
race_step: index + 1,
driver_code: $$(
"td:nth-child(3) > p > span:first-child > span:last-child > span:last-child",
).text(),
position: driver_index + 1, // parseInt($$("td:nth-child(1) > p").text()),
time: $$("td:nth-child(5) > p").text(),
};
starting_grids.push(result);
});
}),
);
console.log(`Scraped ${starting_grids.length} starting grids...`);
// console.log(starting_grids);
return starting_grids;
};
/**
* Returns a list of [ScrapedRaceResults] for all races contained in [race_links],
* based on official f1.com data.
*/
export const scrape_race_results = async (race_links: string[]): Promise<ScrapedRaceResult[]> => {
const race_results: ScrapedRaceResult[] = [];
await Promise.all(
race_links.map(async (link: string, index: number) => {
console.log(`Fetching race results from ${base_url}/${link}...`);
const race_response = await fetch(`${base_url}/${link}`);
const race_text = await race_response.text();
const $ = cheerio.load(race_text);
// Obtain the results for this race for each driver
$("tbody > tr", "table.f1-table").each((driver_index, element) => {
const $$ = cheerio.load(element);
let result: ScrapedRaceResult = {
id: "",
race_step: index + 1,
driver_code: $$(
"td:nth-child(3) > p > span:first-child > span:last-child > span:last-child",
).text(),
position: driver_index + 1, // parseInt($$("td:nth-child(1) > p").text()),
status: $$("td:nth-child(6) > p").text(),
points: parseInt($$("td:nth-child(7) > p").text()),
};
// DSQ'd/DNF'd drivers have NaN positions
// if (Number.isNaN(result.position)) {
// result.position = driver_index;
// }
race_results.push(result);
});
}),
);
console.log(`Scraped ${race_results.length} race results...`);
// console.log(race_results);
return race_results;
};
/**
* Returns a list of [ScrapedDriverStandings], based on official f1.com data.
*/
export const scrape_driver_standings = async (): Promise<ScrapedDriverStanding[]> => {
const standings_response = await fetch(`${base_url}/drivers`);
const standings_text = await standings_response.text();
const $ = cheerio.load(standings_text);
const driver_standings: ScrapedDriverStanding[] = [];
$("tbody > tr", "table.f1-table").each((driver_index, element) => {
const $$ = cheerio.load(element);
let standing: ScrapedDriverStanding = {
id: "",
driver_code: $$("td:nth-child(2) > p > a > span:last-child > span:last-child").text(),
position: driver_index + 1,
points: parseInt($$("td:nth-child(5) > p").text()),
};
driver_standings.push(standing);
});
console.log(`Scraped ${driver_standings.length} driver standings...`);
// console.log(driver_standings);
return driver_standings;
};
/**
* Returns a list of [ScrapedTeamStandings], based on official f1.com data.
*/
export const scrape_team_standings = async (): Promise<ScrapedTeamStanding[]> => {
const standings_response = await fetch(`${base_url}/team`);
const standings_text = await standings_response.text();
const $ = cheerio.load(standings_text);
const team_standings: ScrapedTeamStanding[] = [];
$("tbody > tr", "table.f1-table").each((team_index, element) => {
const $$ = cheerio.load(element);
let standing: ScrapedTeamStanding = {
id: "",
team_fullname: $$("td:nth-child(2) > p > a").text(),
position: team_index + 1,
points: parseInt($$("td:nth-child(3) > p").text()),
};
team_standings.push(standing);
});
console.log(`Scraped ${team_standings.length} team standings...`);
// console.log(team_standings);
return team_standings;
};

View File

@ -1,63 +0,0 @@
import type { ToastSettings } from "@skeletonlabs/skeleton";
export const get_info_toast = (
message: string,
timeout: number | null = 2000,
action_label: string | undefined = undefined,
action_response: (() => void) | undefined = undefined,
): ToastSettings =>
get_toast(message, "variant-filled-tertiary", timeout, action_label, action_response);
export const get_warning_toast = (
message: string,
timeout: number | null = 2000,
action_label: string | undefined = undefined,
action_response: (() => void) | undefined = undefined,
): ToastSettings =>
get_toast(message, "variant-filled-secondary", timeout, action_label, action_response);
export const get_error_toast = (
message: string,
timeout: number | null = 2000,
action_label: string | undefined = undefined,
action_response: (() => void) | undefined = undefined,
): ToastSettings =>
get_toast(message, "variant-filled-primary", timeout, action_label, action_response);
/**
* Utility function to create [ToastSettings].
* If [timeout] is defined, the toast will disappear automatically and the dismiss-button will be hidden.
* If [timeout] is undefined, the toast will stay until dismissed from the dismiss-button.
* If [action_label] and [action_response] are defined, a callback function will be executed after accepting.
* In this case, [timeout] behaves as if undefined.
*/
const get_toast = (
message: string,
background: string,
timeout: number | null = 2000,
action_label: string | undefined = undefined,
action_response: (() => void) | undefined = undefined,
): ToastSettings => {
return {
message,
background,
// If we have a timeout and no action, dismiss is hidden
hideDismiss: !!timeout && (!action_label || !action_response),
// If we have a timeout and no action, the timeout is used
timeout: !!timeout && (!action_label || !action_response) ? timeout : undefined,
// If we have a timeout and no action, autohide is true
autohide: !!timeout && (!action_label || !action_response),
// If we have a label and a response, use the action
action:
!!action_label && !!action_response
? {
label: action_label,
response: action_response,
}
: undefined,
};
};

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.
// 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,
};
};

View File

@ -1,645 +1,169 @@
<script lang="ts">
import "../app.css";
import { onDestroy, onMount, type Snippet } from "svelte";
import type { Snippet } from "svelte";
import type { LayoutData } from "./$types";
import { page } from "$app/state";
import {
Button,
MenuDrawerIcon,
UserIcon,
Input,
PasswordIcon,
LoadingIndicator,
DriverCard,
TeamCard,
RaceCard,
SubstitutionCard,
NameIcon,
RacePickCard,
RaceResultCard,
SeasonPickCard,
EMailIcon,
TeamSwitchCard,
} from "$lib/components";
import { get_avatar_preview_event_handler } from "$lib/image";
import {
AppBar,
storePopup,
initializeStores,
Drawer,
getDrawerStore,
Modal,
Toast,
getModalStore,
type DrawerSettings,
Avatar,
FileDropzone,
type DrawerStore,
type ModalStore,
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, 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";
import { FileInput, Password, Username } from "$lib/components";
import { get_image_preview_event_handler } from "$lib/image";
let { data, children }: { data: LayoutData; children: Snippet } = $props();
// Init skeleton stores for drawer + modal
initializeStores();
// Modal config
const modalStore: ModalStore = getModalStore();
const modalRegistry: Record<string, ModalComponent> = {
// Card data (e.g. team, driver etc.) is passed using $modalStore[0].meta
driverCard: { ref: DriverCard },
raceCard: { ref: RaceCard },
racePickCard: { ref: RacePickCard },
raceResultCard: { ref: RaceResultCard },
seasonPickCard: { ref: SeasonPickCard },
substitutionCard: { ref: SubstitutionCard },
teamCard: { ref: TeamCard },
teamSwitchCard: { ref: TeamSwitchCard },
};
// Toast config
const toastStore: ToastStore = getToastStore();
// Drawer config
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: "2xl: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 });
// Reactive state
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> => {
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
let avatar_avif: Blob | undefined = undefined;
const avatar_file: File | undefined =
avatar_value && avatar_value.length === 1 ? avatar_value[0] : undefined;
if (avatar_file) {
const avatar_formdata: FormData = new FormData();
avatar_formdata.append("image", avatar_file);
avatar_formdata.append("width", AVATAR_WIDTH.toString());
avatar_formdata.append("height", AVATAR_HEIGHT.toString());
try {
const response = await fetch("/api/compress", {
method: "POST",
body: avatar_formdata,
});
if (!response.ok) {
error(500, "Compression failed.");
}
avatar_avif = await response.blob();
} catch (error) {
toastStore.trigger(get_error_toast("" + error));
}
}
try {
if (create) {
if (!username_value || username_value.trim() === "") {
toastStore.trigger(get_error_toast("Please enter a username!"));
return;
}
if (!firstname_value || firstname_value.trim() === "") {
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;
}
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 (!$pbUser?.id || $pbUser.id === "") {
toastStore.trigger(get_error_toast("Invalid user id!"));
return;
}
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() : $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) {
toastStore.trigger(get_error_toast("" + error));
}
};
return handler;
};
// Real-time updates without reloading
onMount(() =>
subscribe([
"users",
"drivers",
"racepicks",
"raceresults",
"races",
"seasonpicks",
"substitutions",
"teams",
"scraped_startinggrids",
"scraped_raceresults",
"scraped_driverstandings",
"scraped_teamstandings",
// The view collections do not receive realtime events
]),
);
onDestroy(() =>
unsubscribe([
"users",
"drivers",
"racepicks",
"raceresults",
"races",
"seasonpicks",
"substitutions",
"teams",
"scraped_startinggrids",
"scraped_raceresults",
"scraped_driverstandings",
"scraped_teamstandings",
]),
);
</script>
<LoadingIndicator />
<Modal components={modalRegistry} regionBackdrop="!overflow-y-scroll" />
<Toast zIndex="z-[1000]" />
<Drawer zIndex="z-30">
<!-- 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" width="w-full" shadow>
Race Picks
</Button>
<Button href="/seasonpicks" onclick={close_drawer} color="surface" width="w-full" shadow>
Season Picks
</Button>
<Button href="/leaderboard" onclick={close_drawer} color="surface" width="w-full" shadow>
Leaderboard
</Button>
<Button href="/statistics" onclick={close_drawer} color="surface" width="w-full" shadow>
Statistics
</Button>
<Button href="/rules" onclick={close_drawer} color="surface" width="w-full" shadow>
Rules
</Button>
<Button
href="https://gitea.vps.chriphost.de/christoph/svelte-formula11/projects/1"
onclick={close_drawer}
color="surface"
width="w-full"
shadow
newtab
>
Roadmap
</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/raceresults" onclick={close_drawer} color="surface" width="w-full" shadow>
Race Results
</Button>
<Button
href="/data/season/teams"
onclick={close_drawer}
color="surface"
width="w-full"
shadow
>
Season
</Button>
<Button
href="/data/official/driverstandings"
onclick={close_drawer}
color="surface"
width="w-full"
shadow
>
Official
</Button>
<Button href="/data/users" onclick={close_drawer} color="surface" width="w-full" shadow>
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">
<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"
autocomplete="username"
minlength={3}
maxlength={10}
required
onkeypress={enter_handler}
>
<UserIcon />
</Input>
<div
class="{registration_mode
? ''
: 'mt-[-8px] h-0'} overflow-hidden transition-all duration-150 ease-out"
>
<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="{!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" && $pbUser}
<!-- Profile Drawer -->
<!-- Profile Drawer -->
<!-- Profile Drawer -->
<div class="flex flex-col gap-2 p-2 pt-3">
<h4 class="h4 select-none align-middle font-bold" style="line-height: 32px;">Edit Profile</h4>
<Input
bind:value={username_value}
maxlength={10}
placeholder="Username"
autocomplete="username"
>
<UserIcon />
</Input>
<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}
onchange={get_avatar_preview_event_handler("user_avatar_preview")}
>
<svelte:fragment slot="message">
<span class="font-bold">Upload Avatar</span>
</svelte:fragment>
</FileDropzone>
<div class="flex justify-end gap-2">
<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}
</Drawer>
<nav>
<div class="fixed left-0 right-0 top-0 z-40">
<AppBar
slotDefault="place-self-center"
slotTrail="place-content-end"
background="bg-primary-500"
shadow="shadow"
padding="p-2"
<!-- TODO: Make this stick to the top somehow. -->
<!-- Fixed breaks the flexbox and sticky doesn't work. -->
<div class="navbar h-16 bg-primary shadow">
<div class="navbar-start">
<!-- Side menu be visible on low width devices -->
<div class="dropdown">
<!-- Side menu open/close icon -->
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<svelte:fragment slot="lead">
<div class="flex gap-2">
<!-- Navigation drawer -->
<div class="lg:hidden">
<Button color="primary" onclick={menu_drawer}>
<MenuDrawerIcon />
</Button>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h8m-8 6h16"
/>
</svg>
</div>
<!-- Side menu navigation items -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<ul
tabindex="0"
class="menu dropdown-content z-[1] mt-4 w-52 rounded-box border bg-base-100 p-2 shadow"
>
<li><a href="/racepicks">Race Picks</a></li>
<li><a href="/seasonpicks">Season Picks</a></li>
<li><a href="/leaderboard">Leaderboard</a></li>
<li><a href="/statistics">Statistics</a></li>
<li><a href="/rules">Rules</a></li>
</ul>
</div>
<!-- Site logo -->
<Button href="/racepicks" color="primary">
<span class="text-xl font-bold">Formula 11</span>
</Button>
</div>
</svelte:fragment>
<!-- Large navigation -->
<div class="hidden gap-2 pr-8 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>
<Button
href="https://gitea.vps.chriphost.de/christoph/svelte-formula11/projects/1"
color="primary"
activate_href
newtab
>
Roadmap
</Button>
<a href="/" class="btn btn-ghost text-xl">Formula 11</a>
</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
>
<!-- Centered navigation -->
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1">
<li>
<a class="btn btn-ghost btn-sm" href="/racepicks">Race Picks</a>
</li>
<li>
<a class="btn btn-ghost btn-sm" href="/seasonpicks">Season Picks</a>
</li>
<li>
<a class="btn btn-ghost btn-sm" href="/leaderboard">Leaderboard</a>
</li>
<li>
<a class="btn btn-ghost btn-sm" href="/statistics">Statistics</a>
</li>
<li><a class="btn btn-ghost btn-sm" href="/rules">Rules</a></li>
</ul>
</div>
{#if !$pbUser}
<!-- Login drawer -->
<Button color="primary" onclick={login_drawer}>Login</Button>
<div class="navbar-end">
<!-- Admin button -->
<div class="dropdown dropdown-end mr-2">
<div tabindex="0" role="button" class="btn btn-ghost">Admin</div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<ul
tabindex="0"
class="menu dropdown-content z-[1] mt-4 w-52 rounded-box border bg-base-100 p-2 shadow"
>
<li><a href="/admin/users">Users</a></li>
<li><a href="/admin/seasondata/teams">Season Data</a></li>
<li><a href="/admin/userdata">User Data</a></li>
</ul>
</div>
<!-- Login/profile stuff -->
{#if !data.user}
<!-- No user is logged in -->
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost m-1">Login</div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
tabindex="0"
class="menu dropdown-content z-[1] mt-4 w-[150] rounded-box border bg-base-100 p-2 shadow"
>
<h1 class="text-lg">Enter Username and Password</h1>
<form method="POST">
<Username id="signin_username" name="username" />
<Password id="signin_password" name="password" />
<div class="card-actions mt-2 justify-end">
<button
formaction="/user?/create"
type="button"
class="btn btn-accent">Register</button
>
<button
formaction="/user?/login"
type="submit"
class="btn btn-accent">Login</button
>
</div>
</form>
</div>
</div>
{:else}
<!-- Profile drawer -->
<Avatar
<!-- The user is logged in -->
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="avatar ml-2 mr-2">
<div class="mask mask-squircle w-10">
<img
id="user_avatar_preview"
src={$pbUser?.avatar_url}
rounded="rounded-full"
width="w-10"
background="bg-primary-50"
onclick={profile_drawer}
cursor="cursor-pointer"
src={data.user.avatar_url}
alt="User avatar"
/>
</div>
</div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
tabindex="0"
class="menu dropdown-content z-[1] mt-4 w-[150] rounded-box border bg-base-100 p-2 shadow"
>
<h1 class="text-lg">Edit Profile</h1>
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="id" value={data.user.id} />
<Username
id="update_username"
name="username"
value={data.user.username}
/>
<FileInput
id="update_avatar"
name="avatar"
label="Upload Avatar"
onchange={get_image_preview_event_handler(
"user_avatar_preview",
)}
/>
<div class="card-actions mt-2 justify-end">
<button formaction="/user?/update" class="btn btn-secondary"
>Save Changes</button
>
<button formaction="/user?/logout" class="btn btn-primary"
>Logout</button
>
</div>
</form>
</div>
</div>
{/if}
</div>
</svelte:fragment>
</AppBar>
</div>
</nav>
<!-- Each child's contents will be inserted here -->
<div class="p-2" style="margin-top: 60px;">
<div class="p-2">
{@render children()}
</div>

View File

@ -1,28 +0,0 @@
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;
// 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: LayoutLoad = async ({ fetch, depends }) => {
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: get(pbUser),
// admin: get(pbUser)?.admin ?? false,
// Return static data
graphics: await fetch_graphics(fetch),
};
};

5
src/routes/+page.svelte Normal file
View File

@ -0,0 +1,5 @@
<svelte:head>
<title>F11 - Formula 11</title>
</svelte:head>
<h1>Formula 11</h1>

View File

@ -1,5 +0,0 @@
import { redirect } from "@sveltejs/kit";
export function load() {
redirect(302, "/racepicks");
}

View File

@ -0,0 +1,66 @@
import type { Actions, PageServerLoad } from "./$types";
import {
form_data_clean,
form_data_ensure_keys,
form_data_get_and_remove_id,
} from "$lib/forms";
// These "actions" run serverside only, as they're located inside +page.server.ts
export const actions = {
// We destructure the RequestEvent with ({cookies, request}).
// Alternatively use (event) and event.cookies or event.request to access.
create: async ({ cookies, request, locals }) => {
if (!locals.admin) return { success: false };
const data = form_data_clean(await request.formData());
form_data_ensure_keys(data, ["name", "logo"]);
const record = await locals.pb.collection("teams").create(data);
return { success: true };
},
update: async ({ cookies, request, locals }) => {
if (!locals.admin) return { success: false };
const data = form_data_clean(await request.formData());
const id = form_data_get_and_remove_id(data);
// Destructure the FormData object
const record = await locals.pb.collection("teams").update(id, data);
return { success: true };
},
delete: async ({ cookies, request, locals }) => {
if (!locals.admin) return { success: false };
const data: FormData = form_data_clean(await request.formData());
const id = form_data_get_and_remove_id(data);
await locals.pb.collection("teams").delete(id);
return { success: true };
},
} 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_teams = async () => {
const teams = await locals.pb.collection("teams").getFullList({
sort: "+name",
fetch: fetch,
});
// Fill in the file URLs
teams.map((team) => {
team.logo_url = locals.pb.files.getURL(team, team.logo);
});
return teams;
};
return {
teams: await fetch_teams(),
};
};

View File

@ -0,0 +1,129 @@
<script lang="ts">
import type { PageData } from "./$types";
import { Input, FileInput, Button } from "$lib/components";
import { get_image_preview_event_handler } from "$lib/image";
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>F11 - Teams</title>
</svelte:head>
<!-- TODO: Move this + the tablist into the +layout.svelte and select the correct tab dynamically -->
<!-- This would also allow it to be animated? Maybe? -->
<h1>Season Data</h1>
<div role="tablist" class="tabs-boxed tabs">
<a href="teams" role="tab" class="tab tab-active">Teams</a>
<a href="drivers" role="tab" class="tab">Drivers</a>
<a href="races" role="tab" class="tab">Races</a>
</div>
<!-- TODO: End -->
<div
class="mt-2 grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6"
>
<!-- List all teams inside the database -->
{#each data.teams as team}
<div class="card card-bordered card-compact shadow">
<!-- Logo display -->
<figure>
<img
id="update_team_logo_preview_{team.id}"
src={team.logo_url}
alt="Logo of {team.name} F1 team."
draggable="false"
class="select-none"
/>
</figure>
<form method="POST" enctype="multipart/form-data">
<input name="id" type="hidden" value={team.id} />
<div class="card-body gap-0 !p-2 !pt-0">
<Input
id="team_name_{team.id}"
name="name"
value={team.name}
label="Name:"
disabled={!data.admin}
/>
<!-- Logo upload -->
<FileInput
id="team_logo_{team.id}"
name="logo"
label="Upload Logo"
onchange={get_image_preview_event_handler(
`update_team_logo_preview_${team.id}`,
)}
disabled={!data.admin}
/>
<!-- Buttons -->
<div class="card-actions mt-2 justify-end">
<Button
formaction="?/update"
color="secondary"
label="Save Changes"
disabled={!data.admin}
/>
<Button
formaction="?/delete"
color="primary"
label="Delete"
disabled={!data.admin}
/>
</div>
</div>
</form>
</div>
{/each}
<!-- Add a new team -->
{#if data.admin}
<div class="card card-bordered card-compact shadow">
<!-- Logo preview -->
<figure>
<img
id="create_team_logo_preview"
src=""
alt="Logo preview"
class="select-none"
draggable="false"
hidden
/>
</figure>
<form method="POST" enctype="multipart/form-data">
<div class="card-body">
<h2 class="card-title select-none">Add a New Team</h2>
<!-- Team name input -->
<Input id="team_name_create" name="name" label="Name:" required />
<!-- Logo upload -->
<FileInput
id="team_logo_create"
name="logo"
label="Upload Logo"
onchange={get_image_preview_event_handler(
"create_team_logo_preview",
)}
required
/>
<!-- Buttons -->
<div class="card-actions justify-end">
<!-- By specifying the formaction on the button (instead of action on the form), -->
<!-- we can have multiple buttons with different actions in a single form. -->
<button formaction="?/create" class="btn btn-secondary"
>Create</button
>
</div>
</div>
</form>
</div>
{/if}
</div>

View File

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

View File

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

View File

@ -1,45 +0,0 @@
import { image_to_avif } from "$lib/server/image";
import { error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
/**
* This route is available at /api/compress.
* It will return the image as a compressed [avif] [Blob].
* We need this because [sharp] is a server-side node library.
*/
export const POST: RequestHandler = async ({ request }) => {
console.log("Hit /api/compress...");
const data: FormData = await request.formData();
const image: File | undefined = data.has("image") ? (data.get("image") as File) : undefined;
const width: number | undefined = data.has("width")
? parseInt(data.get("width")?.toString() ?? "-1")
: undefined;
const height: number | undefined = data.has("height")
? parseInt(data.get("height")?.toString() ?? "-1")
: undefined;
const quality: number | undefined = data.has("quality")
? parseInt(data.get("quality")?.toString() ?? "-1")
: undefined;
const effort: number | undefined = data.has("effort")
? parseInt(data.get("effort")?.toString() ?? "-1")
: undefined;
if (!image) {
error(500, "Can't compress image without image data");
}
const compressedImage: Blob = await image_to_avif(
await image.arrayBuffer(),
width,
height,
quality,
effort,
);
return new Response(compressedImage, {
headers: {
"Content-Type": "image/avif",
},
});
};

View File

@ -1,164 +0,0 @@
import {
fetch_scraped_driverstandings,
fetch_scraped_raceresults,
fetch_scraped_startinggrids,
fetch_scraped_teamstandings,
} from "$lib/fetch";
import { pb } from "$lib/pocketbase";
import type {
ScrapedDriverStanding,
ScrapedRaceResult,
ScrapedStartingGrid,
ScrapedTeamStanding,
} from "$lib/schema";
import {
scrape_driver_standings,
scrape_race_links,
scrape_race_results,
scrape_starting_grids,
scrape_team_standings,
} from "$lib/server/scrape";
import type { RequestHandler } from "./$types";
/**
* This route is available at /api/scrape.
* It will fetch current statistics from f1.com and insert them into the database.
*/
// TODO: If this function aborts, it will leave the official data in an inconsistent state...
// Would be nice to use transactions for this, do I need to implement this as PB extension?
export const POST: RequestHandler = async ({ request }) => {
console.log("Fetching race results from f1.com...");
// Obtain the results for each race
const racelinks: string[] = await scrape_race_links();
const startinggrids: ScrapedStartingGrid[] = await scrape_starting_grids(racelinks);
const raceresults: ScrapedRaceResult[] = await scrape_race_results(racelinks);
const driverstandings: ScrapedDriverStanding[] = await scrape_driver_standings();
const teamstandings: ScrapedTeamStanding[] = await scrape_team_standings();
// Clear existing PocketBase data
// TODO: Do I really have to fetch everything just to delete it???
let deleted: number = 0;
const scraped_startinggrids: ScrapedStartingGrid[] = await fetch_scraped_startinggrids(fetch);
for (const grid of scraped_startinggrids) {
try {
await pb.collection("scraped_startinggrids").delete(grid.id);
} catch (e) {
console.log(e);
return new Response(); // TODO: Return error
}
deleted++;
}
console.log(`Deleted ${deleted}/${scraped_startinggrids.length} starting grids`);
deleted = 0;
const scraped_raceresults: ScrapedRaceResult[] = await fetch_scraped_raceresults(fetch);
for (const result of scraped_raceresults) {
try {
await pb.collection("scraped_raceresults").delete(result.id);
} catch (e) {
console.log(e);
return new Response(); // TODO: Return error
}
deleted++;
}
console.log(`Deleted ${deleted}/${scraped_raceresults.length} race results.`);
deleted = 0;
const scraped_driverstandings: ScrapedDriverStanding[] =
await fetch_scraped_driverstandings(fetch);
for (const standing of scraped_driverstandings) {
try {
await pb.collection("scraped_driverstandings").delete(standing.id);
} catch (e) {
console.log(e);
return new Response(); // TODO: Return error
}
deleted++;
}
console.log(`Deleted ${deleted}/${scraped_driverstandings.length} driver standings.`);
deleted = 0;
const scraped_teamstandings: ScrapedTeamStanding[] = await fetch_scraped_teamstandings(fetch);
for (const standing of scraped_teamstandings) {
try {
await pb.collection("scraped_teamstandings").delete(standing.id);
} catch (e) {
console.log(e);
return new Response(); // TODO: Return error
}
deleted++;
}
console.log(`Deleted ${deleted}/${scraped_teamstandings.length} team standings.`);
// Submit new data to PocketBase
let submissions: number = 0;
for (const grid of startinggrids) {
try {
// TODO: Authenticate this
await pb.collection("scraped_startinggrids").create(grid);
} catch (e) {
console.log("Error occured while submitting scraped data to PocketBase:");
console.log(e);
console.log("Error occured for this starting grid:");
console.log(grid);
console.log("Aborting submissions...");
return new Response(); // TODO: Return error
}
submissions++;
}
console.log(`Submitted ${submissions}/${startinggrids.length} starting grids.`);
submissions = 0;
for (const result of raceresults) {
try {
// TODO: Authenticate this
await pb.collection("scraped_raceresults").create(result);
} catch (e) {
console.log("Error occured while submitting scraped data to PocketBase:");
console.log(e);
console.log("Error occured for this race result:");
console.log(result);
console.log("Aborting submissions...");
return new Response(); // TODO: Return error
}
submissions++;
}
console.log(`Submitted ${submissions}/${raceresults.length} race results.`);
submissions = 0;
for (const standing of driverstandings) {
try {
// TODO: Authenticate this
await pb.collection("scraped_driverstandings").create(standing);
} catch (e) {
console.log("Error occured while submitting scraped data to PocketBase:");
console.log(e);
console.log("Error occured for this driver standing:");
console.log(standing);
console.log("Aborting submissions...");
return new Response(); // TODO: Return error
}
submissions++;
}
console.log(`Submitted ${submissions}/${driverstandings.length} driver standings.`);
submissions = 0;
for (const standing of teamstandings) {
try {
// TODO: Authenticate this
await pb.collection("scraped_teamstandings").create(standing);
} catch (e) {
console.log("Error occured while submitting scraped data to PocketBase:");
console.log(e);
console.log("Error occured for this team standing:");
console.log(standing);
console.log("Aborting submissions...");
return new Response(); // TODO: Return error
}
submissions++;
}
console.log(`Submitted ${submissions}/${teamstandings.length} team standings.`);
return new Response(); // TODO: Return success
};

View File

@ -1,58 +0,0 @@
<script lang="ts">
import { invalidate } from "$app/navigation";
import { Button } from "$lib/components";
import { pbUser } from "$lib/pocketbase";
import { get_error_toast, get_warning_toast } from "$lib/toast";
import { getToastStore, type ToastStore } from "@skeletonlabs/skeleton";
import type { Snippet } from "svelte";
let { children }: { children: Snippet } = $props();
const toastStore: ToastStore = getToastStore();
const scrape_message: string =
"This will clear and redownload all data from f1.com. Please don't refresh the page during the process.";
// This callback will be executed once the admin presses the "Proceed"-button in the warning toast
const scrape_callback = async () => {
const response: Response = await fetch("/api/scrape", { method: "POST" });
invalidate("data:official");
};
const scrape_official_data = async () => {
if (!$pbUser || !$pbUser.admin) {
toastStore.trigger(get_error_toast("Only admins may perform this action!"));
return;
}
// No timeout + action toast
toastStore.trigger(get_warning_toast(scrape_message, null, "Proceed", scrape_callback));
};
</script>
<div class="fixed left-0 right-0 top-14 z-10 flex justify-center">
<div
class="mx-2 flex w-full justify-center gap-2 bg-primary-500 pb-2 pt-3 shadow rounded-bl-container-token rounded-br-container-token"
>
<Button href="driverstandings" color="primary" activate_href>Drivers</Button>
<Button href="teamstandings" color="primary" activate_href>Teams</Button>
<Button href="startinggrids" color="primary" activate_href>Grids</Button>
<Button href="raceresults" color="primary" activate_href>Race Results</Button>
</div>
</div>
<!-- Each child's contents will be inserted here -->
<div style="margin-top: 56px;">
<div class="pb-2">
<Button
width="w-full"
color="tertiary"
onclick={scrape_official_data}
shadow
disabled={!$pbUser?.admin}
>
<span class="font-bold">Refresh All Data</span>
</Button>
</div>
{@render children()}
</div>

View File

@ -1,37 +0,0 @@
<script lang="ts">
import { Table, type TableColumn } from "$lib/components";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
const standings_columns: TableColumn[] = $derived([
{
data_value_name: "driver_code",
label: "Driver",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
{
data_value_name: "position",
label: "Position",
},
{
data_value_name: "points",
label: "Points",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
]);
</script>
<svelte:head>
<title>Formula 11 - Official Driver Standings</title>
</svelte:head>
{#await data.scraped_driverstandings then standings}
<Table
data={standings}
columns={standings_columns}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await}

View File

@ -1,11 +0,0 @@
import { fetch_drivers, fetch_scraped_driverstandings } from "$lib/fetch";
import type { PageLoad } from "../../../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:official", "data:drivers");
return {
scraped_driverstandings: fetch_scraped_driverstandings(fetch),
drivers: fetch_drivers(fetch),
};
};

View File

@ -1,55 +0,0 @@
<script lang="ts">
import { Table, type TableColumn } from "$lib/components";
import type { PageData } from "./$types";
import { get_by_value } from "$lib/database";
import type { Race } from "$lib/schema";
let { data }: { data: PageData } = $props();
const results_columns: TableColumn[] = $derived([
{
data_value_name: "race_step",
label: "Race",
valuefun: async (value: string): Promise<string> => {
const racename: string = get_by_value(await data.races, "step", value)?.name ?? "Invalid";
return `<span class='badge variant-filled-surface'>${racename}</span>`;
},
},
{
data_value_name: "race_step",
label: "Step",
},
{
data_value_name: "driver_code",
label: "Driver",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
{
data_value_name: "position",
label: "Position",
},
{
data_value_name: "status",
label: "Status",
},
{
data_value_name: "points",
label: "Points",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
]);
</script>
<svelte:head>
<title>Formula 11 - Official Race Results</title>
</svelte:head>
{#await data.scraped_raceresults then results}
<Table
data={results}
columns={results_columns}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await}

View File

@ -1,12 +0,0 @@
import { fetch_drivers, fetch_races, fetch_scraped_raceresults } from "$lib/fetch";
import type { PageLoad } from "../../../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:official", "data:races", "data:drivers");
return {
scraped_raceresults: fetch_scraped_raceresults(fetch),
races: fetch_races(fetch),
drivers: fetch_drivers(fetch),
};
};

View File

@ -1,49 +0,0 @@
<script lang="ts">
import { Table, type TableColumn } from "$lib/components";
import type { PageData } from "./$types";
import { get_by_value } from "$lib/database";
import type { Race } from "$lib/schema";
let { data }: { data: PageData } = $props();
const grids_columns: TableColumn[] = $derived([
{
data_value_name: "race_step",
label: "Race",
valuefun: async (value: string): Promise<string> => {
const racename: string = get_by_value(await data.races, "step", value)?.name ?? "Invalid";
return `<span class='badge variant-filled-surface'>${racename}</span>`;
},
},
{
data_value_name: "race_step",
label: "Step",
},
{
data_value_name: "driver_code",
label: "Driver",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
{
data_value_name: "position",
label: "Position",
},
{
data_value_name: "time",
label: "Time",
},
]);
</script>
<svelte:head>
<title>Formula 11 - Official Starting Grids</title>
</svelte:head>
{#await data.scraped_startinggrids then grids}
<Table
data={grids}
columns={grids_columns}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await}

View File

@ -1,12 +0,0 @@
import { fetch_drivers, fetch_races, fetch_scraped_startinggrids } from "$lib/fetch";
import type { PageLoad } from "../../../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:official", "data:races", "data:drivers");
return {
scraped_startinggrids: fetch_scraped_startinggrids(fetch),
races: fetch_races(fetch),
drivers: fetch_drivers(fetch),
};
};

View File

@ -1,37 +0,0 @@
<script lang="ts">
import { Table, type TableColumn } from "$lib/components";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
const standings_columns: TableColumn[] = $derived([
{
data_value_name: "team_fullname",
label: "Team",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
{
data_value_name: "position",
label: "Position",
},
{
data_value_name: "points",
label: "Points",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
]);
</script>
<svelte:head>
<title>Formula 11 - Official Team Standings</title>
</svelte:head>
{#await data.scraped_teamstandings then standings}
<Table
data={standings}
columns={standings_columns}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await}

View File

@ -1,11 +0,0 @@
import { fetch_scraped_teamstandings, fetch_teams } from "$lib/fetch";
import type { PageLoad } from "../../../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:official", "data:teams");
return {
scraped_teamstandings: fetch_scraped_teamstandings(fetch),
teams: fetch_teams(fetch),
};
};

View File

@ -1,106 +0,0 @@
<script lang="ts">
import { getModalStore, type ModalSettings, type ModalStore } from "@skeletonlabs/skeleton";
import type { PageData } from "./$types";
import { Button, Table, type TableColumn } from "$lib/components";
import { get_by_value } from "$lib/database";
import { PXX_COLORS } from "$lib/config";
import type { RaceResult } from "$lib/schema";
let { data }: { data: PageData } = $props();
const modalStore: ModalStore = getModalStore();
const result_handler = async (event: Event, id?: string) => {
const result: RaceResult | undefined = get_by_value(
await data.raceresults,
"id",
id ?? "Invalid",
);
if (id && !result) return;
const modalSettings: ModalSettings = {
type: "component",
component: "raceResultCard",
meta: {
data,
result,
},
};
modalStore.trigger(modalSettings);
};
const results_columns: TableColumn[] = $derived([
{
data_value_name: "race",
label: "Step",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${get_by_value(await data.races, "id", value)?.step}</span>`,
},
{
data_value_name: "race",
label: "Race",
valuefun: async (value: string): Promise<string> =>
`<span>${get_by_value(await data.races, "id", value)?.name}</span>`,
},
{
data_value_name: "race",
label: "Guessed",
valuefun: async (value: string): Promise<string> =>
`<span>P${get_by_value(await data.races, "id", value)?.pxx}</span>`,
},
{
data_value_name: "pxxs",
label: "Standing",
valuefun: async (value: string): Promise<string> => {
if (value.length === 0 || value === "") return "";
const pxxs_array: string[] = value.toString().split(",");
const pxxs_codes: string[] = await Promise.all(
pxxs_array.map(
async (id: string, index: number) =>
`<span class='w-10 badge mr-2 text-center' style='background: ${PXX_COLORS[index]};'>${get_by_value(await data.drivers, "id", id)?.code ?? "Invalid"}</span>`,
),
);
return pxxs_codes.join("");
},
},
{
data_value_name: "dnfs",
label: "DNFs",
valuefun: async (value: string): Promise<string> => {
if (value.length === 0 || value === "") return "";
const dnfs_array: string[] = value.toString().split(",");
const dnfs_codes: string[] = await Promise.all(
dnfs_array.map(
async (id: string) =>
`<span class='w-10 text-center badge mr-2' style='background: ${PXX_COLORS[3]}'>${get_by_value(await data.drivers, "id", id)?.code ?? "Invalid"}</span>`,
),
);
return dnfs_codes.join("");
},
},
]);
</script>
<svelte:head>
<title>Formula 11 - Race Results</title>
</svelte:head>
<div class="pb-2">
<Button width="w-full" color="tertiary" onclick={result_handler} shadow>
<span class="font-bold">Create Race Result</span>
</Button>
</div>
{#await data.raceresults then results}
<Table
data={results}
columns={results_columns}
handler={result_handler}
height="h-[calc(100vh-210px)] lg:h-[calc(100vh-126px)]"
/>
{/await}

View File

@ -1,13 +0,0 @@
import { fetch_drivers, fetch_raceresults, fetch_races, fetch_substitutions } from "$lib/fetch";
import type { PageLoad } from "../../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:drivers", "data:races", "data:raceresults", "data:substitutions");
return {
drivers: fetch_drivers(fetch),
races: fetch_races(fetch),
raceresults: fetch_raceresults(fetch),
substitutions: fetch_substitutions(fetch),
};
};

View File

@ -1,22 +0,0 @@
<script lang="ts">
import { Button } from "$lib/components";
import type { Snippet } from "svelte";
let { children }: { children: Snippet } = $props();
</script>
<div class="fixed left-0 right-0 top-14 z-10 flex justify-center">
<div
class="mx-2 flex w-full justify-center gap-2 bg-primary-500 pb-2 pt-3 shadow rounded-bl-container-token rounded-br-container-token"
>
<Button href="teams" color="primary" activate_href>Teams</Button>
<Button href="drivers" color="primary" activate_href>Drivers</Button>
<Button href="races" color="primary" activate_href>Races</Button>
<Button href="substitutions" color="primary" activate_href>Substitutions</Button>
</div>
</div>
<!-- Each child's contents will be inserted here -->
<div style="margin-top: 56px;">
{@render children()}
</div>

View File

@ -1,90 +0,0 @@
<script lang="ts">
import { Button, type TableColumn, Table } from "$lib/components";
import { get_by_value } from "$lib/database";
import type { Driver, Team } from "$lib/schema";
import { getModalStore, type ModalSettings, type ModalStore } from "@skeletonlabs/skeleton";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
const modalStore: ModalStore = getModalStore();
const driver_handler = async (event: Event, id?: string) => {
const driver: Driver | undefined = get_by_value(await data.drivers, "id", id ?? "Invalid");
if (id && !driver) return;
const modalSettings: ModalSettings = {
type: "component",
component: "driverCard",
meta: {
data,
driver,
},
};
modalStore.trigger(modalSettings);
};
const teamswitch_handler = async (event: Event, id?: string) => {
const modalSettings: ModalSettings = {
type: "component",
component: "teamSwitchCard",
meta: {
data,
},
};
modalStore.trigger(modalSettings);
};
const drivers_columns: TableColumn[] = $derived([
{
data_value_name: "code",
label: "Driver Code",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
{ data_value_name: "firstname", label: "First Name" },
{ data_value_name: "lastname", label: "Last Name" },
{
data_value_name: "team",
label: "Team",
valuefun: async (value: string): Promise<string> => {
const team: Team | undefined = get_by_value(await data.teams, "id", value);
return `<span class='badge mr-2' style='color: ${team?.color ?? "#FFFFFF"}; background: ${team?.color ?? "#FFFFFF"};'>C</span>${team?.name ?? "Invalid"}`;
},
},
{
data_value_name: "active",
label: "Active",
valuefun: async (value: boolean): Promise<string> =>
`<span class='badge variant-filled-${value ? "tertiary" : "primary"} text-center' style='width: 36px;'>${value ? "Yes" : "No"}</span>`,
},
{
data_value_name: "started_active",
label: "Started Active",
valuefun: async (value: boolean): Promise<string> =>
`<span class='badge variant-filled-${value ? "tertiary" : "primary"} text-center' style='width: 36px;'>${value ? "Yes" : "No"}</span>`,
},
]);
</script>
<svelte:head>
<title>Formula 11 - Drivers</title>
</svelte:head>
<div class="flex gap-2 pb-2">
<Button width="w-full" color="tertiary" onclick={driver_handler} shadow>
<span class="font-bold">Create New Driver</span>
</Button>
<Button width="w-full" color="secondary" onclick={teamswitch_handler} shadow>
<span class="font-bold">Switch Driver Team</span>
</Button>
</div>
{#await data.drivers then drivers}
<Table
data={drivers}
columns={drivers_columns}
handler={driver_handler}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await}

View File

@ -1,11 +0,0 @@
import { fetch_drivers, fetch_teams } from "$lib/fetch";
import type { PageLoad } from "../../../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:teams", "data:drivers");
return {
teams: fetch_teams(fetch),
drivers: fetch_drivers(fetch),
};
};

View File

@ -1,78 +0,0 @@
<script lang="ts">
import { Button, Table, type TableColumn } from "$lib/components";
import { getModalStore, type ModalSettings, type ModalStore } from "@skeletonlabs/skeleton";
import type { PageData } from "./$types";
import { get_by_value } from "$lib/database";
import type { Race } from "$lib/schema";
import { format_date, shortdatetimeformat } from "$lib/date";
let { data }: { data: PageData } = $props();
const modalStore: ModalStore = getModalStore();
const race_handler = async (event: Event, id?: string) => {
const race: Race | undefined = get_by_value(await data.races, "id", id ?? "Invalid");
if (id && !race) return;
const modalSettings: ModalSettings = {
type: "component",
component: "raceCard",
meta: {
data,
race,
},
};
modalStore.trigger(modalSettings);
};
// The date value functions convert UTC from PocketBase to localtime
const races_columns: TableColumn[] = $derived([
{
data_value_name: "name",
label: "Name",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
{ data_value_name: "step", label: "Step" },
{
data_value_name: "sprintqualidate",
label: "Sprint Quali",
valuefun: async (value: string): Promise<string> => format_date(value, shortdatetimeformat),
},
{
data_value_name: "sprintdate",
label: "Sprint Race",
valuefun: async (value: string): Promise<string> => format_date(value, shortdatetimeformat),
},
{
data_value_name: "qualidate",
label: "Quali",
valuefun: async (value: string): Promise<string> => format_date(value, shortdatetimeformat),
},
{
data_value_name: "racedate",
label: "Race",
valuefun: async (value: string): Promise<string> => format_date(value, shortdatetimeformat),
},
]);
</script>
<svelte:head>
<title>Formula 11 - Races</title>
</svelte:head>
<div class="pb-2">
<Button width="w-full" color="tertiary" onclick={race_handler} shadow>
<span class="font-bold">Create New Race</span>
</Button>
</div>
{#await data.races then races}
<Table
data={races}
columns={races_columns}
handler={race_handler}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await}

View File

@ -1,10 +0,0 @@
import { fetch_races } from "$lib/fetch";
import type { PageLoad } from "../../../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:races");
return {
races: fetch_races(fetch),
};
};

View File

@ -1,78 +0,0 @@
<script lang="ts">
import { get_by_value } from "$lib/database";
import { getModalStore, type ModalSettings, type ModalStore } from "@skeletonlabs/skeleton";
import type { PageData } from "./$types";
import type { Race, Substitution } from "$lib/schema";
import { Button, Table, type TableColumn } from "$lib/components";
let { data }: { data: PageData } = $props();
const modalStore: ModalStore = getModalStore();
const substitution_handler = async (event: Event, id?: string) => {
const substitution: Substitution | undefined = get_by_value(
await data.substitutions,
"id",
id ?? "Invalid",
);
if (id && !substitution) return;
const modalSettings: ModalSettings = {
type: "component",
component: "substitutionCard",
meta: {
data,
substitution,
},
};
modalStore.trigger(modalSettings);
};
const substitutions_columns: TableColumn[] = $derived([
{
data_value_name: "expand",
label: "Step",
valuefun: async (value: { race: Race }): Promise<string> =>
`<span class='badge variant-filled-surface'>${value.race.step.toString()}</span>`,
},
{
data_value_name: "substitute",
label: "Substitute",
valuefun: async (value: string): Promise<string> => {
const substitute = get_by_value(await data.drivers, "id", value)?.code ?? "Invalid";
return `<span class='badge variant-filled-surface'>${substitute}</span>`;
},
},
{
data_value_name: "for",
label: "For",
valuefun: async (value: string): Promise<string> =>
get_by_value(await data.drivers, "id", value)?.code ?? "Invalid",
},
{
data_value_name: "race",
label: "Race",
valuefun: async (value: string): Promise<string> =>
get_by_value(await data.races, "id", value)?.name ?? "Invalid",
},
]);
</script>
<svelte:head>
<title>Formula 11 - Substitutions</title>
</svelte:head>
<div class="pb-2">
<Button width="w-full" color="tertiary" onclick={substitution_handler} shadow>
<span class="font-bold">Create New Substitution</span>
</Button>
</div>
{#await data.substitutions then substitutions}
<Table
data={substitutions}
columns={substitutions_columns}
handler={substitution_handler}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await}

View File

@ -1,12 +0,0 @@
import { fetch_drivers, fetch_races, fetch_substitutions } from "$lib/fetch";
import type { PageLoad } from "../../../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:races", "data:drivers", "data:substitutions");
return {
races: fetch_races(fetch),
drivers: fetch_drivers(fetch),
substitutions: fetch_substitutions(fetch),
};
};

View File

@ -1,61 +0,0 @@
<script lang="ts">
import { Button, Table, type TableColumn } from "$lib/components";
import type { Team } from "$lib/schema";
import { getModalStore, type ModalSettings, type ModalStore } from "@skeletonlabs/skeleton";
import type { PageData } from "./$types";
import { get_by_value } from "$lib/database";
let { data }: { data: PageData } = $props();
const modalStore: ModalStore = getModalStore();
const team_handler = async (event: Event, id?: string) => {
const team: Team | undefined = get_by_value(await data.teams, "id", id ?? "Invalid");
// If we expect to find a team but don't, abort
if (id && !team) return;
const modalSettings: ModalSettings = {
type: "component",
component: "teamCard",
meta: {
data,
team,
},
};
modalStore.trigger(modalSettings);
};
const teams_columns: TableColumn[] = $derived([
{
data_value_name: "name",
label: "Name",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
{
data_value_name: "color",
label: "Color",
valuefun: async (value: string): Promise<string> =>
`<span class='badge mr-2' style='color: ${value}; background: ${value};'>C</span>`,
},
]);
</script>
<svelte:head>
<title>Formula 11 - Teams</title>
</svelte:head>
<div class="pb-2">
<Button width="w-full" color="tertiary" onclick={team_handler} shadow>
<span class="font-bold">Create New Team</span>
</Button>
</div>
{#await data.teams then teams}
<Table
data={teams}
columns={teams_columns}
handler={team_handler}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await}

View File

@ -1,10 +0,0 @@
import { fetch_teams } from "$lib/fetch";
import type { PageLoad } from "../../../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:teams");
return {
teams: fetch_teams(fetch),
};
};

View File

@ -1,51 +0,0 @@
<script lang="ts">
import { Table, type TableColumn } from "$lib/components";
import { getModalStore, type ModalStore } from "@skeletonlabs/skeleton";
import type { PageData } from "./$types";
import { get_by_value } from "$lib/database";
let { data }: { data: PageData } = $props();
const users_columns: TableColumn[] = [
// Don't display the username for login "security" (lol)
// {
// data_value_name: "username",
// label: "Username",
// valuefun: async (value: string): Promise<string> =>
// `<span class='badge variant-filled-surface'>${value}</span>`,
// },
{
data_value_name: "firstname",
label: "First Name",
},
{
data_value_name: "avatar_url",
label: "Avatar",
valuefun: async (value: string): Promise<string> =>
`<img class='rounded-full w-10 bg-surface-400' src='${value ? value : get_by_value(data.graphics, "name", "driver_headshot_template")?.file_url}'/>`,
},
{
data_value_name: "admin",
label: "Admin",
valuefun: async (value: boolean): Promise<string> =>
`<span class='badge variant-filled-${value ? "tertiary" : "primary"} text-center'>${value ? "Yes" : "No"}</span>`,
},
];
const modalStore: ModalStore = getModalStore();
const users_handler = async (event: Event, id: string) => {
// Should an admin be able to do anything here?
// Or can users only change themselves?
};
</script>
<svelte:head>
<title>Formula 11 - Users</title>
</svelte:head>
<Table
data={data.users}
columns={users_columns}
handler={users_handler}
height="h-[calc(100vh-160px)] lg:h-[calc(100vh-76px)]"
/>

View File

@ -1,10 +0,0 @@
import { fetch_users } from "$lib/fetch";
import type { PageLoad } from "../../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:users");
return {
users: await fetch_users(fetch),
};
};

View File

@ -1,96 +1 @@
<script lang="ts">
import { make_chart_options } from "$lib/chart";
import { Table, type TableColumn } from "$lib/components";
import { get_by_value } from "$lib/database";
import type { RacePickPoints, RacePickPointsAcc, User } from "$lib/schema";
import type { PageData } from "./$types";
import {
LineChart,
ScaleTypes,
type ChartTabularData,
type LineChartOptions,
} from "@carbon/charts-svelte";
import "@carbon/charts-svelte/styles.css";
let { data }: { data: PageData } = $props();
// Await promises
let users: User[] | undefined = $state(undefined);
data.users.then((u: User[]) => (users = u));
let racepickpoints: RacePickPoints[] | undefined = $state(undefined);
data.racepickpoints.then((r: RacePickPoints[]) => (racepickpoints = r));
let racepickpointsacc: RacePickPointsAcc[] | undefined = $state(undefined);
data.racepickpointsacc.then((r: RacePickPointsAcc[]) => (racepickpointsacc = r));
const leaderboard_columns: TableColumn[] = $derived([
{
data_value_name: "user",
label: "User",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${get_by_value(await data.users, "id", value)?.firstname ?? "Invalid"}</span>`,
},
{
data_value_name: "total_points",
label: "Total",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
{
data_value_name: "total_pxx_points",
label: "PXX",
},
{
data_value_name: "total_dnf_points",
label: "DNF",
},
{
data_value_name: "total_points_per_pick",
label: "Per Pick",
valuefun: async (value: string): Promise<string> => Number.parseFloat(value).toFixed(2),
},
]);
const points_chart_data: ChartTabularData = $derived.by(() => {
if (!users || !racepickpointsacc) return [];
return users
.map((user: User) => {
return {
group: user.firstname,
step: "0",
points: 0,
};
})
.concat(
racepickpointsacc.map((points: RacePickPointsAcc) => {
return {
group: get_by_value(users ?? [], "id", points.user)?.firstname || "INVALID",
step: points.step.toString(),
points: points.acc_points,
};
}),
);
});
const points_chart_options: LineChartOptions = make_chart_options(
"I ❤️ CumSum",
"step",
"points",
);
</script>
<svelte:head>
<title>Formula 11 - Leaderboard</title>
</svelte:head>
<div class="card w-full bg-surface-100 p-2 shadow">
<LineChart data={points_chart_data} options={points_chart_options} />
</div>
<div class="mt-2">
{#await data.racepickpointstotal then racepickpointstotal}
<Table data={racepickpointstotal} columns={leaderboard_columns} />
{/await}
</div>
<h1>Leaderboard</h1>

View File

@ -1,18 +0,0 @@
import {
fetch_users,
fetch_racepickpoints,
fetch_racepickpointsacc,
fetch_racepickpointstotal,
} from "$lib/fetch";
import type { PageLoad } from "../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:users", "data:raceresults");
return {
users: fetch_users(fetch),
racepickpoints: fetch_racepickpoints(fetch),
racepickpointsacc: fetch_racepickpointsacc(fetch),
racepickpointstotal: fetch_racepickpointstotal(fetch),
};
};

View File

@ -1,388 +1 @@
<script lang="ts">
import { ChequeredFlagIcon, Countdown, LazyImage, StopwatchIcon } from "$lib/components";
import {
Accordion,
AccordionItem,
getModalStore,
popup,
type ModalSettings,
type ModalStore,
type PopupSettings,
} from "@skeletonlabs/skeleton";
import type { PageData } from "./$types";
import {
AVATAR_HEIGHT,
AVATAR_WIDTH,
DRIVER_HEADSHOT_HEIGHT,
DRIVER_HEADSHOT_WIDTH,
PXX_COLORS,
RACE_PICTOGRAM_HEIGHT,
RACE_PICTOGRAM_WIDTH,
} from "$lib/config";
import { runes, substring } from "runes2";
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();
const modalStore: ModalStore = getModalStore();
const racepick_handler = async (event: Event) => {
const modalSettings: ModalSettings = {
type: "component",
component: "racePickCard",
meta: {
data,
racepick: data.racepick,
},
};
modalStore.trigger(modalSettings);
};
// Users that have already picked the current race
let pickedusers: Promise<CurrentPickedUser[]> = $derived.by(async () =>
(await data.currentpickedusers).filter(
(currentpickeduser: CurrentPickedUser) => currentpickeduser.picked,
),
);
// Users that didn't already pick the current race
let outstandingusers: Promise<CurrentPickedUser[]> = $derived.by(async () =>
(await data.currentpickedusers).filter(
(currentpickeduser: CurrentPickedUser) => !currentpickeduser.picked,
),
);
const race_popupsettings = (target: string): PopupSettings => {
return {
event: "click",
target: target,
placement: "right-start",
};
};
</script>
<svelte:head>
<title>Formula 11 - Race Picks</title>
</svelte:head>
<!-- Only show the userguess if signed in and we have a next race -->
{#if $pbUser && data.currentrace}
{#await Promise.all( [data.drivers, data.currentpickedusers, pickedusers, outstandingusers], ) then [drivers, currentpicked, picked, outstanding]}
<!-- HACK: relative was required to get the shadow to show up above the upper table occluder? -->
<Accordion
class="card relative z-20 mx-auto bg-surface-500 shadow"
regionPanel="pt-0"
width="w-full"
>
<AccordionItem>
<svelte:fragment slot="lead"><ChequeredFlagIcon /></svelte:fragment>
<svelte:fragment slot="summary">
<span class="font-bold">Next Race Guess</span>
</svelte:fragment>
<svelte:fragment slot="content">
<div class="grid grid-cols-2 gap-2 lg:mx-auto lg:w-fit lg:grid-cols-6">
<!-- Show information about the next race -->
<div class="card flex w-full min-w-40 flex-col p-2 shadow lg:max-w-40">
<span class="font-bold">
{data.currentrace.name}
</span>
{#if data.currentrace.sprintdate}
<div class="flex gap-2">
<span class="w-12">SQuali:</span>
<span>{format_date(data.currentrace.sprintqualidate, shortdatetimeformat)}</span>
</div>
<div class="flex gap-2">
<span class="w-12">SRace:</span>
<span>{format_date(data.currentrace.sprintdate, shortdatetimeformat)}</span>
</div>
{/if}
<div class="flex gap-2">
<span class="w-12">Quali:</span>
<span>{format_date(data.currentrace.qualidate, shortdatetimeformat)}</span>
</div>
<div class="flex gap-2">
<span class="w-12">Race:</span>
<span>{format_date(data.currentrace.racedate, shortdatetimeformat)}</span>
</div>
<div class="m-auto flex">
<div class="mr-1 mt-1">
<StopwatchIcon />
</div>
<Countdown date={data.currentrace.racedate} extraclass="font-bold" />
</div>
</div>
<!-- Show race pictogram -->
<div class="card w-full min-w-40 p-2 shadow lg:max-w-40">
<h1 class="mb-2 text-nowrap font-bold">Track Layout:</h1>
<LazyImage
src={data.currentrace.pictogram_url ?? "Invalid"}
imgwidth={RACE_PICTOGRAM_WIDTH}
imgheight={RACE_PICTOGRAM_HEIGHT}
containerstyle="height: 105px; margin: auto;"
imgstyle="background: transparent;"
/>
</div>
<!-- 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>
<LazyImage
src={get_by_value(drivers, "id", data.racepick?.pxx ?? "")?.headshot_url ??
get_driver_headshot_template(data.graphics)}
imgwidth={DRIVER_HEADSHOT_WIDTH}
imgheight={DRIVER_HEADSHOT_HEIGHT}
containerstyle="height: 115px; margin: auto;"
imgclass="bg-transparent cursor-pointer"
hoverzoom
onclick={racepick_handler}
/>
</div>
<!-- DNF 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 DNF Pick:</h1>
<LazyImage
src={get_by_value(drivers, "id", data.racepick?.dnf ?? "")?.headshot_url ??
get_driver_headshot_template(data.graphics)}
imgwidth={DRIVER_HEADSHOT_WIDTH}
imgheight={DRIVER_HEADSHOT_HEIGHT}
containerstyle="height: 115px; margin: auto;"
imgclass="bg-transparent cursor-pointer"
hoverzoom
onclick={racepick_handler}
/>
</div>
<!-- Show users that have picked -->
<div class="card max-h-[155px] w-full min-w-40 p-2 shadow lg:max-w-40">
<h1 class="text-nowrap font-bold">
Picked ({picked.length}/{currentpicked.length}):
</h1>
<div class="mt-1 grid max-h-[110px] grid-cols-4 gap-x-0 gap-y-0.5 overflow-y-scroll">
{#each picked as user}
<LazyImage
src={user.avatar_url ?? get_driver_headshot_template(data.graphics)}
imgwidth={AVATAR_WIDTH}
imgheight={AVATAR_HEIGHT}
containerstyle="height: 35px; width: 35px;"
imgclass="bg-surface-400 rounded-full"
tooltip={user.firstname}
/>
{/each}
</div>
</div>
<!-- Show users that have not picked yet -->
<div class="card max-h-[155px] w-full min-w-40 p-2 shadow lg:max-w-40">
<h1 class="text-nowrap font-bold">
Missing ({outstanding.length}/{currentpicked.length}):
</h1>
<div class="mt-1 grid max-h-[110px] grid-cols-4 gap-x-0 gap-y-0.5 overflow-y-scroll">
{#each outstanding as user}
<LazyImage
src={user.avatar_url ?? get_driver_headshot_template(data.graphics)}
imgwidth={AVATAR_WIDTH}
imgheight={AVATAR_HEIGHT}
containerstyle="height: 35px; width: 35px;"
imgclass="bg-surface-400 rounded-full"
tooltip={user.firstname}
/>
{/each}
</div>
</div>
</div>
</svelte:fragment>
</AccordionItem>
</Accordion>
{/await}
{/if}
<!-- The fookin table -->
<div
class="{!$pbUser
? 'mt-[-8px]'
: ''} relative h-[calc(100vh-200px)] w-[calc(100vw-16px)] scroll-pl-8 scroll-pt-[72px] flex-col overflow-scroll max-lg:hide-scrollbar lg:h-[calc(100vh-116px)] lg:scroll-pl-[152px]"
>
<div class="sticky top-0 z-[15] flex w-full min-w-fit">
<!-- Points color coding legend -->
<!-- Use mt-3/mt-4 to account for 2x padding around the avatar. -->
<div
class="sticky left-0 z-20 h-16 w-7 min-w-7 max-w-7 bg-surface-50 pt-2 lg:w-36 lg:min-w-36 lg:max-w-36 lg:pt-4"
>
<div class="hidden h-5 text-sm font-bold lg:block">Points:</div>
<div
class="flex h-full flex-col overflow-hidden rounded-b-lg rounded-t-lg lg:h-5 lg:flex-row lg:!rounded-l-lg lg:!rounded-r-lg lg:rounded-b-none lg:rounded-t-none"
>
<!-- Large Screens: -->
<span
class="hidden h-full w-full text-center align-middle text-sm lg:block"
style="background: {PXX_COLORS[3]}; line-height: 20px;"
>
10
</span>
<span
class="hidden h-full w-full text-center align-middle text-sm lg:block"
style="background: {PXX_COLORS[4]}; line-height: 20px;"
>
6
</span>
<span
class="hidden h-full w-full text-center align-middle text-sm lg:block"
style="background: {PXX_COLORS[5]}; line-height: 20px;"
>
3
</span>
<span
class="hidden h-full w-full text-center align-middle text-sm lg:block"
style="background: {PXX_COLORS[6]}; line-height: 20px;"
>
1
</span>
<!-- Small Screens: -->
<span class="block h-full w-full lg:hidden" style="background: {PXX_COLORS[3]};"></span>
<span class="block h-full w-full lg:hidden" style="background: {PXX_COLORS[4]};"></span>
<span class="block h-full w-full lg:hidden" style="background: {PXX_COLORS[5]};"></span>
<span class="block h-full w-full lg:hidden" style="background: {PXX_COLORS[6]};"></span>
</div>
</div>
<!-- Avatars -->
<div class="flex h-16 w-full bg-surface-50">
{#await data.currentpickedusers then currentpicked}
{#each currentpicked as user}
<div
class="card ml-1 mt-2 w-full min-w-14 rounded-b-none bg-surface-400 py-2
{$pbUser && $pbUser.username === user.username ? '!bg-primary-400' : ''}"
>
<!-- Avatar + name display at the top -->
<div class="m-auto flex h-10 w-fit">
<LazyImage
src={user.avatar_url ?? get_driver_headshot_template(data.graphics)}
imgwidth={AVATAR_WIDTH}
imgheight={AVATAR_HEIGHT}
containerstyle="height: 40px; width: 40px;"
imgclass="bg-surface-400 rounded-full"
tooltip={user.firstname}
/>
<div
style="height: 40px; line-height: 40px;"
class="ml-2 hidden text-nowrap text-center align-middle max-2xl:text-sm max-xl:text-xs lg:block"
>
{user.firstname}
</div>
</div>
</div>
{/each}
{/await}
</div>
</div>
<div class="flex w-full min-w-fit">
<!-- Race name display -->
<div
class="sticky left-0 z-10 w-7 min-w-7 max-w-7 bg-surface-50 lg:w-36 lg:min-w-36 lg:max-w-36"
>
{#await Promise.all( [data.races, data.raceresults, data.drivers], ) then [races, raceresults, drivers]}
{#each raceresults as result}
{@const race = get_by_value(races, "id", result.race)}
<div
use:popup={race_popupsettings(race?.id ?? "Invalid")}
class="card mt-1 flex h-16 w-7 cursor-pointer flex-col !rounded-r-none bg-surface-400 lg:h-20 lg:w-36 lg:p-2"
>
<!-- For large screens -->
<span class="hidden text-sm font-bold lg:block">
{race?.step}: {race?.name}
</span>
<span class="hidden text-sm lg:block">
Date: {format_date(race?.racedate ?? "", shortdatetimeformat)}
</span>
<span class="hidden text-sm lg:block">Guessed: P{race?.pxx}</span>
<!-- For small screens -->
<!-- TODO: This requires the race name to end with an emoji, but this is never enforced -->
<div class="mx-[2px] my-[18px] block text-lg font-bold lg:hidden">
{runes(race?.name ?? "Invalid").at(-1)}
</div>
</div>
<!-- The race result popup is triggered on click on the race -->
<div data-popup={race?.id ?? "Invalid"} class="card z-50 bg-surface-400 p-2 shadow">
<span class="font-bold">Result:</span>
<div class="mt-2 flex flex-col gap-1">
{#each result.pxxs as pxx, index}
{@const driver = get_by_value(drivers, "id", pxx)}
<div class="flex gap-2">
<span class="w-8">P{(race?.pxx ?? -100) - 3 + index}:</span>
<span class="badge w-10 p-1 text-center" style="background: {PXX_COLORS[index]};">
{driver?.code}
</span>
</div>
{/each}
{#if result.dnfs.length > 0}
<hr class="border-black" style="border-style: inset;" />
{/if}
{#each result.dnfs as dnf}
{@const driver = get_by_value(drivers, "id", dnf)}
<div class="flex gap-2">
<span class="w-8">DNF:</span>
<span class="badge w-10 p-1 text-center" style="background: {PXX_COLORS[3]};">
{driver?.code}
</span>
</div>
{/each}
</div>
</div>
{/each}
{/await}
</div>
<!-- Picks -->
<div class="flex w-full">
<!-- Not ideal but currentpickedusers contains all users, so we do not need to fetch the users separately -->
{#await Promise.all( [data.currentpickedusers, data.racepicks, data.races, data.drivers, data.raceresults], ) then [currentpicked, racepicks, races, drivers, raceresults]}
{#each currentpicked as user}
{@const picks = racepicks.filter((pick: RacePick) => pick.user === user.id)}
<div class="ml-1 w-full min-w-14">
{#each raceresults as result}
{@const race = get_by_value(races, "id", result.race)}
{@const pick = picks.filter((pick: RacePick) => pick.race === race?.id)[0]}
{@const pxxcolor = PXX_COLORS[result.pxxs.indexOf(pick?.pxx ?? "Invalid")]}
{@const dnfcolor =
result.dnfs.indexOf(pick?.dnf ?? "Invalid") >= 0 ? PXX_COLORS[3] : PXX_COLORS[-1]}
{#if pick}
<div class="mt-1 h-16 w-full border bg-surface-200 px-1 py-2 lg:h-20 lg:px-2">
<div class="mx-auto flex h-full w-fit flex-col justify-evenly">
<span
class="w-10 p-1 text-center text-xs rounded-container-token lg:text-sm"
style="background: {pxxcolor};"
>
{get_by_value(drivers, "id", pick?.pxx ?? "")?.code}
</span>
<span
class="w-10 p-1 text-center text-xs rounded-container-token lg:text-sm"
style="background: {dnfcolor};"
>
{get_by_value(drivers, "id", pick?.dnf ?? "")?.code}
</span>
</div>
</div>
{:else}
<div class="mt-1 h-16 w-full px-1 py-2 lg:h-20 lg:px-2"></div>
{/if}
{/each}
</div>
{/each}
{/await}
</div>
</div>
</div>
<h1>Race Picks</h1>

Some files were not shown because too many files have changed in this diff Show More