Compare commits
1 Commits
dev
...
245137c65d
| Author | SHA1 | Date | |
|---|---|---|---|
| 245137c65d |
@@ -1,10 +0,0 @@
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
node_modules
|
||||
.svelte-kit
|
||||
build
|
||||
test-results
|
||||
playwright-report
|
||||
*.log
|
||||
30
.env.example
30
.env.example
@@ -1,30 +0,0 @@
|
||||
# PostgreSQL connection (server only)
|
||||
DB_URL=postgresql://postgres:postgres@localhost:5432/personal_dev
|
||||
|
||||
# Use the exact public origin, including scheme and port. Set the same value for both.
|
||||
ORIGIN=http://localhost:5173
|
||||
BETTER_AUTH_URL=http://localhost:5173
|
||||
# Generate with: openssl rand -base64 48
|
||||
BETTER_AUTH_SECRET=
|
||||
|
||||
# Optional Google login; callback: <BETTER_AUTH_URL>/api/auth/callback/google
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# Optional osu! login; callback: <BETTER_AUTH_URL>/api/auth/callback/osu
|
||||
# These can also serve the existing beatmap finder.
|
||||
OSU_CLIENT_ID=
|
||||
OSU_CLIENT_SECRET=
|
||||
SPOTIFY_CLIENT_ID=
|
||||
SPOTIFY_CLIENT_SECRET=
|
||||
|
||||
# Public browser key for Animaldex map tiles.
|
||||
PUBLIC_MAPTILER_API_KEY=
|
||||
|
||||
# Cloudflare R2, shared by Animaldex and admin-uploaded list pictures.
|
||||
# R2_PUBLIC_URL is the public bucket/custom-domain base URL (without a trailing slash).
|
||||
R2_ACCOUNT_ID=
|
||||
R2_ACCESS_KEY_ID=
|
||||
R2_SECRET_ACCESS_KEY=
|
||||
R2_BUCKET=
|
||||
R2_PUBLIC_URL=
|
||||
|
||||
@@ -1,52 +1,45 @@
|
||||
name: Test and deploy staging
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
branches: [staging]
|
||||
branches: [main, staging]
|
||||
|
||||
jobs:
|
||||
staging:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
bun-version: 1.4.0
|
||||
- name: Install and test
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run test:unit -- --run
|
||||
registry: git.stanrunge.dev
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
# Build once. The same SHA-tagged image stays on the deployment host for
|
||||
# one-click promotion to production; the production workflow never rebuilds it.
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
push: true
|
||||
tags: git.stanrunge.dev/stan/personal-website:${{ github.sha }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
|
||||
- name: Configure deployment SSH key
|
||||
env:
|
||||
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: setup key
|
||||
run: |
|
||||
set -euo pipefail
|
||||
install -d -m 700 ~/.ssh
|
||||
printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_ed25519
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H 172.17.0.1 >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Transfer tested image
|
||||
- name: pick target
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image="git.stanrunge.dev/stan/personal-website:${{ github.sha }}"
|
||||
docker save "$image" | ssh -o BatchMode=yes deploy@172.17.0.1 'sudo docker load'
|
||||
if [ "${{ github.ref_name }}" = "main" ]; then
|
||||
echo "DIR=/opt/personal-website" >> $GITHUB_ENV
|
||||
else
|
||||
echo "DIR=/opt/personal-website-staging" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Deploy staging
|
||||
- name: deploy
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ssh -o BatchMode=yes deploy@172.17.0.1 \
|
||||
"bash -s -- '${{ github.sha }}' '/opt/personal-website-staging' 'https://staging.stanrunge.dev'" \
|
||||
< scripts/deploy-remote.sh
|
||||
ssh deploy@172.17.0.1 "echo IMAGE_TAG=${{ github.sha }} | sudo tee $DIR/.env && sudo docker compose -f $DIR/compose.prod.yml up -d"
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
name: Deploy production
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Always test the staging branch, regardless of which branch the button
|
||||
# appears on in Gitea's Actions UI.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: staging
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.4.0
|
||||
- name: Check staging source
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run test:unit -- --run
|
||||
|
||||
- name: Configure deployment SSH key
|
||||
env:
|
||||
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
install -d -m 700 ~/.ssh
|
||||
printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H 172.17.0.1 >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Promote the exact staging image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
commit=$(git rev-parse HEAD)
|
||||
ssh -o BatchMode=yes deploy@172.17.0.1 "bash -s -- '$commit'" \
|
||||
< scripts/verify-staging-image.sh
|
||||
ssh -o BatchMode=yes deploy@172.17.0.1 \
|
||||
"bash -s -- '$commit' '/opt/personal-website' 'https://stanrunge.dev'" \
|
||||
< scripts/deploy-remote.sh
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -19,9 +19,3 @@ Thumbs.db
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
.vscode/
|
||||
|
||||
# Browser test output
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
|
||||
@@ -2,6 +2,3 @@
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
|
||||
# Generated pinned catalog
|
||||
data/pokemon-catalog.json
|
||||
|
||||
31
Dockerfile
31
Dockerfile
@@ -1,22 +1,25 @@
|
||||
FROM oven/bun:1.4.0 AS builder
|
||||
FROM oven/bun AS builder
|
||||
|
||||
RUN bun
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
COPY package.json bun.lockb ./
|
||||
|
||||
RUN bun install
|
||||
COPY . .
|
||||
|
||||
|
||||
RUN bun run build
|
||||
|
||||
FROM oven/bun:1.4.0
|
||||
|
||||
|
||||
FROM oven/bun
|
||||
|
||||
WORKDIR /app
|
||||
ARG GIT_SHA=dev
|
||||
ENV APP_VERSION=$GIT_SHA
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/build ./build
|
||||
COPY --from=builder /app/scripts ./scripts
|
||||
COPY --from=builder /app/data ./data
|
||||
COPY --from=builder /app/src/lib/server/db/schema ./src/lib/server/db/schema
|
||||
COPY --from=builder /app/src/lib/availability.ts ./src/lib/availability.ts
|
||||
COPY package.json ./
|
||||
COPY --from=builder /app ./
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV BODY_SIZE_LIMIT=6M
|
||||
|
||||
CMD ["bun", "build/index.js"]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Stan Website
|
||||
|
||||
This is my personal website, including:
|
||||
|
||||
- Contact info / social links
|
||||
- About me
|
||||
- Blog posts + comments
|
||||
|
||||
@@ -6,7 +6,7 @@ services:
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DB=postgres
|
||||
ports:
|
||||
- '5432:5432'
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql
|
||||
|
||||
@@ -17,7 +17,7 @@ services:
|
||||
working_dir: /app
|
||||
command: sh -c "bun install & bun run dev --host"
|
||||
ports:
|
||||
- '3000:5173'
|
||||
- "3000:5173"
|
||||
env_file: .env
|
||||
develop:
|
||||
watch:
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,11 +2,12 @@ import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
dialect: 'postgresql',
|
||||
schema: './src/lib/server/db/schema/index.ts',
|
||||
schema: './src/lib/db/schema.ts',
|
||||
out: './drizzle',
|
||||
verbose: true,
|
||||
strict: true,
|
||||
dbCredentials: {
|
||||
url: process.env.DB_URL
|
||||
}
|
||||
},
|
||||
schemaFilter: 'personal_website'
|
||||
});
|
||||
|
||||
@@ -21,6 +21,17 @@ export default [
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte'],
|
||||
|
||||
rules: {
|
||||
'@typescript-eslint/explicit-function-return-type': [
|
||||
'error',
|
||||
{
|
||||
allowExpressions: false,
|
||||
allowTypedFunctionExpressions: true
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: ts.parser
|
||||
@@ -31,6 +42,15 @@ export default [
|
||||
files: ['**/*.ts'],
|
||||
languageOptions: {
|
||||
globals: globals.node
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/explicit-function-return-type': [
|
||||
'error',
|
||||
{
|
||||
allowExpressions: false,
|
||||
allowTypedFunctionExpressions: true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
52
migrations/0000_famous_random.sql
Executable file
52
migrations/0000_famous_random.sql
Executable file
@@ -0,0 +1,52 @@
|
||||
CREATE TABLE IF NOT EXISTS "books" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar(256),
|
||||
"progress" integer DEFAULT 0,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "certificates" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar(256),
|
||||
"progress" integer DEFAULT 0,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "courses" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar(256),
|
||||
"university_id" integer,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "publications" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar(256),
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "topics" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar(256),
|
||||
"emoji" varchar(256),
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "universities" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar(256),
|
||||
"progress" integer DEFAULT 0,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "courses" ADD CONSTRAINT "courses_university_id_universities_id_fk" FOREIGN KEY ("university_id") REFERENCES "public"."universities"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
279
migrations/meta/0000_snapshot.json
Executable file
279
migrations/meta/0000_snapshot.json
Executable file
@@ -0,0 +1,279 @@
|
||||
{
|
||||
"id": "95e1c262-0787-412f-bb2f-7db06e20f3a0",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.books": {
|
||||
"name": "books",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(256)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"progress": {
|
||||
"name": "progress",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.certificates": {
|
||||
"name": "certificates",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(256)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"progress": {
|
||||
"name": "progress",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.courses": {
|
||||
"name": "courses",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(256)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"university_id": {
|
||||
"name": "university_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"courses_university_id_universities_id_fk": {
|
||||
"name": "courses_university_id_universities_id_fk",
|
||||
"tableFrom": "courses",
|
||||
"tableTo": "universities",
|
||||
"columnsFrom": [
|
||||
"university_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.publications": {
|
||||
"name": "publications",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(256)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.topics": {
|
||||
"name": "topics",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(256)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"emoji": {
|
||||
"name": "emoji",
|
||||
"type": "varchar(256)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.universities": {
|
||||
"name": "universities",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(256)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"progress": {
|
||||
"name": "progress",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
13
migrations/meta/_journal.json
Executable file
13
migrations/meta/_journal.json
Executable file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1725668865024,
|
||||
"tag": "0000_famous_random",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
8726
package-lock.json
generated
8726
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
91
package.json
91
package.json
@@ -7,72 +7,51 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "npm run test:integration && npm run test:unit",
|
||||
"check": "svelte-kit sync && svelte-check --tsgo --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsgo --tsconfig ./tsconfig.json --watch",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write .",
|
||||
"test:integration": "playwright test",
|
||||
"test:unit": "vitest",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:seed": "bun scripts/seed-catalog.ts",
|
||||
"db:migrate:auth": "bun scripts/migrate-auth-catalog.ts",
|
||||
"db:migrate:foundation": "bun scripts/migrate-foundation.ts",
|
||||
"auth:role": "bun scripts/set-admin.ts",
|
||||
"catalog:build": "python3 scripts/build-pokemon-catalog.py",
|
||||
"test:auth": "node --env-file=.env node_modules/@playwright/test/cli.js test --config playwright.auth.config.ts"
|
||||
"generate": "drizzle-kit generate",
|
||||
"migrate": "drizzle-kit migrate",
|
||||
"studio": "drizzle-kit studio",
|
||||
"seed": "bun src/lib/db/seed.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@iconify-json/simple-icons": "^1.2.94",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@sveltejs/enhanced-img": "^0.11.0",
|
||||
"@sveltejs/kit": "^2.70.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@playwright/test": "1.49.1",
|
||||
"@sveltejs/kit": "^2.15.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@types/eslint": "9.6.1",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@typescript/native": "npm:typescript@7",
|
||||
"better-auth": "^1.7.2",
|
||||
"bits-ui": "^2.19.0",
|
||||
"@types/pg": "^8.11.10",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"bits-ui": "^1.4.7",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk-sv": "^0.0.19",
|
||||
"drizzle-kit": "^1.0.0-rc.4",
|
||||
"eslint": "^10.9.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.23.0",
|
||||
"globals": "^17.11.0",
|
||||
"lucide-svelte": "^1.0.1",
|
||||
"prettier": "3.9.6",
|
||||
"prettier-plugin-svelte": "^4.1.1",
|
||||
"svelte": "^5.57.0",
|
||||
"svelte-check": "4.7.6",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwind-variants": "^3.3.1",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6",
|
||||
"typescript-eslint": "8.68.0",
|
||||
"unplugin-icons": "^23.0.1",
|
||||
"vite": "^8.2.2",
|
||||
"vitest": "^4.1.11"
|
||||
"cmdk-sv": "^0.0.18",
|
||||
"drizzle-kit": "^0.30.1",
|
||||
"eslint": "9.17.0",
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"eslint-plugin-svelte": "^2.46.1",
|
||||
"globals": "15.14.0",
|
||||
"lucide-svelte": "^0.473.0",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "3.4.2",
|
||||
"prettier-plugin-svelte": "3.3.2",
|
||||
"svelte": "^5.16.5",
|
||||
"svelte-check": "4.1.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwind-variants": "^0.3.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "5.7.2",
|
||||
"typescript-eslint": "8.19.1",
|
||||
"vite": "^6.0.7",
|
||||
"vitest": "2.1.8"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^4.0.46",
|
||||
"@aws-sdk/client-s3": "^3.1121.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1121.0",
|
||||
"@maptiler/sdk": "^4.1.0",
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
"ai": "^7.0.85",
|
||||
"drizzle-orm": "^1.0.0-rc.4",
|
||||
"highlight.js": "^11.12.0",
|
||||
"marked": "^18.0.11",
|
||||
"marked-highlight": "^2.2.4",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"postgres": "^3.4.9",
|
||||
"zod": "^4.5.4"
|
||||
"@sveltejs/adapter-node": "^5.2.11",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"mode-watcher": "^0.5.0",
|
||||
"postgres": "^3.4.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
process.env.AUTH_E2E = '1';
|
||||
export default defineConfig({
|
||||
testDir: 'tests',
|
||||
testMatch: 'auth.test.ts',
|
||||
workers: 1,
|
||||
use: { baseURL: 'http://127.0.0.1:4174' },
|
||||
webServer: {
|
||||
command: 'npm run dev -- --host 127.0.0.1 --port 4174 --strictPort',
|
||||
url: 'http://127.0.0.1:4174/login',
|
||||
env: { ORIGIN: 'http://127.0.0.1:4174', BETTER_AUTH_URL: 'http://127.0.0.1:4174' },
|
||||
timeout: 60000
|
||||
}
|
||||
});
|
||||
6
postcss.config.js
Executable file
6
postcss.config.js
Executable file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
-- One-off: convert animaldex ids from uuid to serial, keeping existing rows.
|
||||
-- Postgres can't cast uuid -> integer, so db:push can't do this itself.
|
||||
--
|
||||
-- Run BEFORE re-running db:push, once per database:
|
||||
-- psql "$DB_URL" -v ON_ERROR_STOP=1 -f scripts/animaldex-uuid-to-serial.sql
|
||||
--
|
||||
-- Safe to run on an empty database too. Everything is in one transaction, so a
|
||||
-- failure leaves the database untouched.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- the failed push created this before erroring out
|
||||
DROP SEQUENCE IF EXISTS animals_id_seq;
|
||||
|
||||
-- 1. new integer ids alongside the uuid ones
|
||||
ALTER TABLE animals ADD COLUMN new_id serial;
|
||||
ALTER TABLE sightings ADD COLUMN new_id serial;
|
||||
ALTER TABLE photos ADD COLUMN new_id serial;
|
||||
|
||||
-- 2. translate the foreign keys through the uuid -> integer mapping
|
||||
ALTER TABLE sightings ADD COLUMN new_animal_id integer;
|
||||
UPDATE sightings s SET new_animal_id = a.new_id FROM animals a WHERE a.id = s.animal_id;
|
||||
|
||||
ALTER TABLE photos ADD COLUMN new_sighting_id integer;
|
||||
UPDATE photos p SET new_sighting_id = s.new_id FROM sightings s WHERE s.id = p.sighting_id;
|
||||
|
||||
-- 3. drop the old foreign keys (names vary by how they were created)
|
||||
DO $$
|
||||
DECLARE r record;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT conrelid::regclass AS tbl, conname
|
||||
FROM pg_constraint
|
||||
WHERE contype = 'f' AND conrelid IN ('sightings'::regclass, 'photos'::regclass)
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', r.tbl, r.conname);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- 4. swap the foreign key columns
|
||||
ALTER TABLE photos DROP COLUMN sighting_id;
|
||||
ALTER TABLE photos RENAME COLUMN new_sighting_id TO sighting_id;
|
||||
ALTER TABLE photos ALTER COLUMN sighting_id SET NOT NULL;
|
||||
|
||||
ALTER TABLE sightings DROP COLUMN animal_id;
|
||||
ALTER TABLE sightings RENAME COLUMN new_animal_id TO animal_id;
|
||||
ALTER TABLE sightings ALTER COLUMN animal_id SET NOT NULL;
|
||||
|
||||
-- 5. swap the primary keys
|
||||
DO $$
|
||||
DECLARE r record;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT conrelid::regclass AS tbl, conname
|
||||
FROM pg_constraint
|
||||
WHERE contype = 'p'
|
||||
AND conrelid IN ('animals'::regclass, 'sightings'::regclass, 'photos'::regclass)
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', r.tbl, r.conname);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE animals DROP COLUMN id;
|
||||
ALTER TABLE animals RENAME COLUMN new_id TO id;
|
||||
ALTER TABLE animals ADD PRIMARY KEY (id);
|
||||
ALTER SEQUENCE animals_new_id_seq RENAME TO animals_id_seq;
|
||||
|
||||
ALTER TABLE sightings DROP COLUMN id;
|
||||
ALTER TABLE sightings RENAME COLUMN new_id TO id;
|
||||
ALTER TABLE sightings ADD PRIMARY KEY (id);
|
||||
ALTER SEQUENCE sightings_new_id_seq RENAME TO sightings_id_seq;
|
||||
|
||||
ALTER TABLE photos DROP COLUMN id;
|
||||
ALTER TABLE photos RENAME COLUMN new_id TO id;
|
||||
ALTER TABLE photos ADD PRIMARY KEY (id);
|
||||
ALTER SEQUENCE photos_new_id_seq RENAME TO photos_id_seq;
|
||||
|
||||
-- 6. restore the foreign keys under the names drizzle expects
|
||||
ALTER TABLE sightings ADD CONSTRAINT sightings_animal_id_animals_id_fkey
|
||||
FOREIGN KEY (animal_id) REFERENCES animals(id) ON DELETE CASCADE;
|
||||
ALTER TABLE photos ADD CONSTRAINT photos_sighting_id_sightings_id_fkey
|
||||
FOREIGN KEY (sighting_id) REFERENCES sightings(id) ON DELETE CASCADE;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,18 +0,0 @@
|
||||
-- Additive migration for the existing domain schema. No users or progress are removed.
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS role text NOT NULL DEFAULT 'user';
|
||||
ALTER TABLE account ADD COLUMN IF NOT EXISTS issuer text;
|
||||
-- Existing credentials use Better Auth's local namespace. Fail on unknown legacy
|
||||
-- OAuth providers rather than silently changing which remote identity they trust.
|
||||
UPDATE account SET issuer = 'local:credential' WHERE issuer IS NULL AND provider_id = 'credential';
|
||||
UPDATE account SET issuer = 'https://accounts.google.com' WHERE issuer IS NULL AND provider_id = 'google';
|
||||
UPDATE account SET issuer = 'local:oauth:github' WHERE issuer IS NULL AND provider_id = 'github';
|
||||
UPDATE account SET issuer = 'local:oauth:osu' WHERE issuer IS NULL AND provider_id = 'osu';
|
||||
ALTER TABLE account ALTER COLUMN issuer SET NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS account_issuer_account_id_unique ON account (issuer, account_id);
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS source_id integer;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS games_source_id_unique ON games (source_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS pokemon_number_unique ON pokemon (number);
|
||||
ALTER TYPE availability ADD VALUE IF NOT EXISTS 'unknown';
|
||||
CREATE TABLE IF NOT EXISTS catch_em_all_settings (
|
||||
user_id text PRIMARY KEY REFERENCES "user"(id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -1,96 +0,0 @@
|
||||
"""Build the species-level catalog from a pinned PokéAPI CSV snapshot.
|
||||
|
||||
Run with --source-dir to reuse downloaded CSVs. Default downloads into a temporary
|
||||
folder. Encounter absence is not evidence of trade/transfer/event exclusivity.
|
||||
"""
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import csv
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import urllib.request
|
||||
|
||||
REVISION = '575291cdb197a7e3a320297be276c9de4ef8401a'
|
||||
TABLES = ['pokemon', 'pokemon_species', 'pokemon_species_names', 'pokemon_types',
|
||||
'pokemon_stats', 'type_names', 'versions', 'version_names', 'version_groups',
|
||||
'pokemon_dex_numbers', 'pokedex_version_groups', 'pokemon_game_indices', 'encounters']
|
||||
|
||||
|
||||
def build(directory):
|
||||
def rows(name):
|
||||
with (directory / f'{name}.csv').open() as f:
|
||||
return list(csv.DictReader(f))
|
||||
|
||||
species = rows('pokemon_species')
|
||||
pokemon = rows('pokemon')
|
||||
species_for = {int(p['id']): int(p['species_id']) for p in pokemon}
|
||||
defaults = {int(p['species_id']): int(p['id']) for p in pokemon if p['is_default'] == '1'}
|
||||
names = {int(p['pokemon_species_id']): p['name'] for p in rows('pokemon_species_names') if p['local_language_id'] == '9'}
|
||||
type_names = {int(t['type_id']): t['name'] for t in rows('type_names') if t['local_language_id'] == '9'}
|
||||
types = {}
|
||||
for p in rows('pokemon_types'):
|
||||
types.setdefault(int(p['pokemon_id']), {})[int(p['slot'])] = type_names[int(p['type_id'])]
|
||||
stats = {}
|
||||
for p in rows('pokemon_stats'):
|
||||
stats.setdefault(int(p['pokemon_id']), {})[int(p['stat_id'])] = int(p['base_stat'])
|
||||
stat_fields = ['hp', 'attack', 'defense', 'specialAttack', 'specialDefense', 'speed']
|
||||
mons = []
|
||||
for s in species:
|
||||
number = int(s['id'])
|
||||
default = defaults[number]
|
||||
mons.append(dict(number=number, name=names[number], generation=int(s['generation_id']),
|
||||
type1=types[default][1], type2=types[default].get(2),
|
||||
**{name: stats[default][i + 1] for i, name in enumerate(stat_fields)}))
|
||||
|
||||
groups = {int(g['id']): g for g in rows('version_groups')}
|
||||
versions = rows('versions')
|
||||
game_names = {int(v['version_id']): v['name'] for v in rows('version_names') if v['local_language_id'] == '9'}
|
||||
games = [dict(sourceId=int(v['id']), name=game_names.get(int(v['id']), v['identifier'].replace('-', ' ').title()),
|
||||
generation=int(groups[int(v['version_group_id'])]['generation_id'])) for v in versions]
|
||||
for game, version in zip(games, versions):
|
||||
if version['identifier'].endswith('-japan'):
|
||||
game['name'] += ' (Japan)'
|
||||
versions_for_group = {}
|
||||
for v in versions:
|
||||
versions_for_group.setdefault(int(v['version_group_id']), []).append(int(v['id']))
|
||||
groups_for_dex = {}
|
||||
for d in rows('pokedex_version_groups'):
|
||||
groups_for_dex.setdefault(int(d['pokedex_id']), []).append(int(d['version_group_id']))
|
||||
entries = {}
|
||||
# Membership and internal game indices establish presence, not how it is obtained.
|
||||
for p in rows('pokemon_dex_numbers'):
|
||||
for group in groups_for_dex.get(int(p['pokedex_id']), []):
|
||||
for version in versions_for_group.get(group, []):
|
||||
entries[(int(p['species_id']), version)] = 'unknown'
|
||||
for p in rows('pokemon_game_indices'):
|
||||
if int(p['game_index']) > 0:
|
||||
entries[(species_for[int(p['pokemon_id'])], int(p['version_id']))] = 'unknown'
|
||||
for p in rows('encounters'):
|
||||
entries[(species_for[int(p['pokemon_id'])], int(p['version_id']))] = 'catchable'
|
||||
return dict(source={'repository': 'https://github.com/PokeAPI/pokeapi', 'revision': REVISION,
|
||||
'scope': 'All species and game versions in this snapshot; default-form types/stats. Unknown means presence without confirmed encounter data. Missing pairs have no source record, not proven absence.'},
|
||||
pokemon=mons, games=games, entries=[[p, g, availability] for (p, g), availability in sorted(entries.items())])
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--source-dir', type=Path)
|
||||
args = parser.parse_args()
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = args.source_dir or Path(temporary)
|
||||
if not args.source_dir:
|
||||
def download(table):
|
||||
url = f'https://raw.githubusercontent.com/PokeAPI/pokeapi/{REVISION}/data/v2/csv/{table}.csv'
|
||||
with urllib.request.urlopen(url, timeout=60) as response:
|
||||
(directory / f'{table}.csv').write_bytes(response.read())
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
|
||||
list(executor.map(download, TABLES))
|
||||
catalog = build(directory)
|
||||
target = Path(__file__).resolve().parent.parent / 'data/pokemon-catalog.json'
|
||||
target.write_text(json.dumps(catalog, ensure_ascii=False, separators=(',', ':')) + '\n')
|
||||
print(f"Built {len(catalog['pokemon'])} species, {len(catalog['games'])} versions, {len(catalog['entries'])} entries")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,70 +0,0 @@
|
||||
import postgres from 'postgres';
|
||||
|
||||
const expectedOrigin = process.argv[2];
|
||||
if (
|
||||
!expectedOrigin ||
|
||||
!['https://stanrunge.dev', 'https://staging.stanrunge.dev'].includes(expectedOrigin)
|
||||
) {
|
||||
throw new Error('Expected a known production or staging origin');
|
||||
}
|
||||
if (!process.env.DB_URL) throw new Error('DB_URL is missing');
|
||||
if (!process.env.BETTER_AUTH_SECRET || process.env.BETTER_AUTH_SECRET.length < 32) {
|
||||
throw new Error('BETTER_AUTH_SECRET must be at least 32 characters');
|
||||
}
|
||||
if (process.env.ORIGIN !== expectedOrigin || process.env.BETTER_AUTH_URL !== expectedOrigin) {
|
||||
throw new Error('ORIGIN and BETTER_AUTH_URL must both match the public site origin');
|
||||
}
|
||||
const client = postgres(process.env.DB_URL, { max: 1, connect_timeout: 10 });
|
||||
try {
|
||||
await client`select 1`;
|
||||
const [schema] = await client`
|
||||
select
|
||||
(select count(*) from information_schema.columns
|
||||
where table_name = 'user' and column_name = 'role') as user_role,
|
||||
(select count(*) from information_schema.columns
|
||||
where table_name = 'account' and column_name = 'issuer') as account_issuer,
|
||||
(select count(*) from information_schema.columns
|
||||
where table_name = 'blog_articles' and column_name = 'tags') as blog_tags,
|
||||
to_regclass('public.catch_em_all_settings') is not null as settings_ready,
|
||||
(select count(*) from information_schema.columns where table_name = 'lists' and column_name = 'slug') as list_slug,
|
||||
to_regclass('public.list_item_completions') is not null as list_completions,
|
||||
(select count(*) from information_schema.columns where table_name = 'tasks_to_topics' and column_name = 'id') as topic_link_id,
|
||||
(select count(*) from information_schema.columns
|
||||
where table_name in ('animals', 'sightings', 'photos')
|
||||
and column_name = 'id' and data_type = 'integer') as animal_serial_ids
|
||||
`;
|
||||
if (
|
||||
!Number(schema.user_role) ||
|
||||
!Number(schema.account_issuer) ||
|
||||
!Number(schema.blog_tags) ||
|
||||
!schema.settings_ready ||
|
||||
!Number(schema.list_slug) ||
|
||||
!schema.list_completions ||
|
||||
!Number(schema.topic_link_id) ||
|
||||
Number(schema.animal_serial_ids) !== 3
|
||||
) {
|
||||
throw new Error(
|
||||
'Required migrations or Animaldex ID conversion have not been applied to this database'
|
||||
);
|
||||
}
|
||||
const [catalog] = await client`
|
||||
select
|
||||
(select count(*) from pokemon) as pokemon,
|
||||
(select count(*) from games where source_id is not null) as games,
|
||||
(select count(*) from pokemon_game_entries) as entries
|
||||
`;
|
||||
if (
|
||||
Number(catalog.pokemon) < 1025 ||
|
||||
Number(catalog.games) < 53 ||
|
||||
Number(catalog.entries) < 27267
|
||||
) {
|
||||
throw new Error('Pokémon catalog has not been seeded in this database');
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
const google = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
||||
const osu = !!(process.env.OSU_CLIENT_ID && process.env.OSU_CLIENT_SECRET);
|
||||
console.log(
|
||||
`Preflight passed for ${expectedOrigin}; Google ${google ? 'configured' : 'disabled'}, osu! ${osu ? 'configured' : 'disabled'}`
|
||||
);
|
||||
@@ -1,50 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
sha=${1:?commit SHA required}
|
||||
directory=${2:?deployment directory required}
|
||||
origin=${3:?public origin required}
|
||||
case "$sha" in
|
||||
*[!0-9a-f]*|'') echo 'Invalid image tag' >&2; exit 2 ;;
|
||||
esac
|
||||
case "$directory:$origin" in
|
||||
/opt/personal-website:https://stanrunge.dev)
|
||||
environment=/etc/personal-website/prod.env ;;
|
||||
/opt/personal-website-staging:https://staging.stanrunge.dev)
|
||||
environment=/etc/personal-website-staging/staging.env ;;
|
||||
*) echo 'Unrecognized deployment target' >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
image="git.stanrunge.dev/stan/personal-website:$sha"
|
||||
compose="$directory/compose.prod.yml"
|
||||
previous_tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/.env" | head -n 1)
|
||||
switched=0
|
||||
rollback() {
|
||||
if [ "$switched" -eq 1 ] && [ -n "$previous_tag" ]; then
|
||||
echo "Health check failed; restoring prior image tag $previous_tag" >&2
|
||||
printf 'IMAGE_TAG=%s\n' "$previous_tag" | sudo tee "$directory/.env" >/dev/null
|
||||
sudo docker compose --env-file "$directory/.env" -f "$compose" up -d
|
||||
fi
|
||||
}
|
||||
trap rollback ERR
|
||||
|
||||
sudo docker run --rm --network backend --env-file "$environment" "$image" \
|
||||
bun scripts/deploy-preflight.ts "$origin"
|
||||
|
||||
printf 'IMAGE_TAG=%s\n' "$sha" | sudo tee "$directory/.env" >/dev/null
|
||||
switched=1
|
||||
sudo docker compose --env-file "$directory/.env" -f "$compose" up -d
|
||||
|
||||
container=$(sudo docker compose --env-file "$directory/.env" -f "$compose" ps -q app)
|
||||
expected_image=$(sudo docker image inspect -f '{{.Id}}' "$image")
|
||||
actual_image=$(sudo docker inspect -f '{{.Image}}' "$container")
|
||||
if [ "$actual_image" != "$expected_image" ]; then
|
||||
echo 'Compose did not start the intended image' >&2
|
||||
false
|
||||
fi
|
||||
curl --retry 12 --retry-delay 2 --retry-all-errors --fail --silent --show-error \
|
||||
"$origin/login" >/dev/null
|
||||
curl --retry 6 --retry-delay 2 --retry-all-errors --fail --silent --show-error \
|
||||
"$origin/projects/catch-em-all" >/dev/null
|
||||
trap - ERR
|
||||
echo "Deployed $sha to $origin"
|
||||
@@ -1,93 +0,0 @@
|
||||
-- Apply after auth-catalog-migration.sql and before deploying the foundation release.
|
||||
-- Additive domain-schema changes; existing records and numeric IDs are retained.
|
||||
|
||||
ALTER TABLE blog_articles ADD COLUMN IF NOT EXISTS tags text[] NOT NULL DEFAULT '{}'::text[];
|
||||
|
||||
ALTER TABLE lists ADD COLUMN IF NOT EXISTS slug text;
|
||||
UPDATE lists SET slug = COALESCE(NULLIF(trim(both '-' from regexp_replace(lower(name), '[^a-z0-9]+', '-', 'g')), ''), 'list') || '-' || id
|
||||
WHERE slug IS NULL;
|
||||
ALTER TABLE lists ALTER COLUMN slug SET NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS lists_slug_unique ON lists (slug);
|
||||
ALTER TABLE lists ADD COLUMN IF NOT EXISTS is_progress boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE list_items ADD COLUMN IF NOT EXISTS picture_url text;
|
||||
ALTER TABLE list_items ADD COLUMN IF NOT EXISTS description text;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS list_item_completions (
|
||||
id serial PRIMARY KEY,
|
||||
item_id integer NOT NULL REFERENCES list_items(id) ON DELETE CASCADE,
|
||||
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT list_item_completions_item_id_user_id_unique UNIQUE (item_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS list_item_completions_user_id_idx ON list_item_completions(user_id);
|
||||
|
||||
-- Join/setting records gain the same numeric identity as other site-owned rows.
|
||||
ALTER TABLE tasks_to_topics ADD COLUMN IF NOT EXISTS id serial;
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'tasks_to_topics'::regclass AND conname = 'tasks_to_topics_pkey'
|
||||
AND pg_get_constraintdef(oid) NOT LIKE 'PRIMARY KEY (id)') THEN
|
||||
ALTER TABLE tasks_to_topics DROP CONSTRAINT tasks_to_topics_pkey;
|
||||
END IF;
|
||||
END $$;
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'tasks_to_topics'::regclass AND contype = 'p') THEN
|
||||
ALTER TABLE tasks_to_topics ADD CONSTRAINT tasks_to_topics_pkey PRIMARY KEY (id);
|
||||
END IF;
|
||||
END $$;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS tasks_to_topics_task_id_topic_id_unique ON tasks_to_topics(task_id, topic_id);
|
||||
|
||||
ALTER TABLE catch_em_all_settings ADD COLUMN IF NOT EXISTS id serial;
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'catch_em_all_settings'::regclass AND conname = 'catch_em_all_settings_pkey'
|
||||
AND pg_get_constraintdef(oid) NOT LIKE 'PRIMARY KEY (id)') THEN
|
||||
ALTER TABLE catch_em_all_settings DROP CONSTRAINT catch_em_all_settings_pkey;
|
||||
END IF;
|
||||
END $$;
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'catch_em_all_settings'::regclass AND contype = 'p') THEN
|
||||
ALTER TABLE catch_em_all_settings ADD CONSTRAINT catch_em_all_settings_pkey PRIMARY KEY (id);
|
||||
END IF;
|
||||
END $$;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS catch_em_all_settings_user_id_unique ON catch_em_all_settings(user_id);
|
||||
|
||||
-- Every site-owned table records creation and latest update. Domain-specific
|
||||
-- event times (submitted_at, seen_at, uploaded_at) remain untouched.
|
||||
DO $$
|
||||
DECLARE table_name text;
|
||||
BEGIN
|
||||
FOREACH table_name IN ARRAY ARRAY[
|
||||
'blog_articles', 'lists', 'list_items', 'list_item_completions',
|
||||
'animals', 'sightings', 'photos', 'tasks', 'topics', 'tasks_to_topics',
|
||||
'pokemon', 'games', 'pokemon_game_entries', 'user_entries', 'user_games',
|
||||
'catch_em_all_settings'
|
||||
] LOOP
|
||||
EXECUTE format('ALTER TABLE %I ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now()', table_name);
|
||||
EXECUTE format('ALTER TABLE %I ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now()', table_name);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- Preserve the best available creation time for older Animaldex rows.
|
||||
UPDATE animals SET created_at = submitted_at WHERE created_at > submitted_at;
|
||||
UPDATE sightings SET created_at = submitted_at WHERE created_at > submitted_at;
|
||||
UPDATE photos SET created_at = uploaded_at WHERE created_at > uploaded_at;
|
||||
|
||||
CREATE OR REPLACE FUNCTION site_touch_updated_at() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
DECLARE table_name text;
|
||||
BEGIN
|
||||
FOREACH table_name IN ARRAY ARRAY[
|
||||
'blog_articles', 'lists', 'list_items', 'list_item_completions',
|
||||
'animals', 'sightings', 'photos', 'tasks', 'topics', 'tasks_to_topics',
|
||||
'pokemon', 'games', 'pokemon_game_entries', 'user_entries', 'user_games',
|
||||
'catch_em_all_settings'
|
||||
] LOOP
|
||||
EXECUTE format('DROP TRIGGER IF EXISTS site_touch_updated_at ON %I', table_name);
|
||||
EXECUTE format('CREATE TRIGGER site_touch_updated_at BEFORE UPDATE ON %I FOR EACH ROW EXECUTE FUNCTION site_touch_updated_at()', table_name);
|
||||
END LOOP;
|
||||
END $$;
|
||||
@@ -1,14 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import postgres from 'postgres';
|
||||
if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
||||
const sql = postgres(process.env.DB_URL, { max: 1 });
|
||||
try {
|
||||
await sql.begin(async (tx) => {
|
||||
await tx.unsafe(
|
||||
await readFile(new URL('./auth-catalog-migration.sql', import.meta.url), 'utf8')
|
||||
);
|
||||
});
|
||||
console.log('Authentication and catalog migration applied');
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import postgres from 'postgres';
|
||||
|
||||
if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
||||
const client = postgres(process.env.DB_URL, { max: 1 });
|
||||
try {
|
||||
await client.begin(async (tx) => {
|
||||
await tx.unsafe(await readFile(new URL('./foundation-migration.sql', import.meta.url), 'utf8'));
|
||||
});
|
||||
console.log('Foundation migration applied');
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||
import postgres from 'postgres';
|
||||
import { pokemon, games, pokemonGameEntries } from '../src/lib/server/db/schema/catch-em-all';
|
||||
|
||||
type Catalog = {
|
||||
source: { revision: string };
|
||||
pokemon: (typeof pokemon.$inferInsert)[];
|
||||
games: { sourceId: number; name: string; generation: number }[];
|
||||
entries: [number, number, 'catchable' | 'unknown'][];
|
||||
};
|
||||
if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
||||
const catalog: Catalog = JSON.parse(
|
||||
await readFile(new URL('../data/pokemon-catalog.json', import.meta.url), 'utf8')
|
||||
);
|
||||
const client = postgres(process.env.DB_URL, { max: 1 });
|
||||
const db = drizzle({ client });
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
const pokemonIds = new Map<number, number>();
|
||||
const gameIds = new Map<number, number>();
|
||||
for (const row of catalog.pokemon) {
|
||||
const [saved] = await tx
|
||||
.insert(pokemon)
|
||||
.values(row)
|
||||
.onConflictDoUpdate({ target: pokemon.number, set: row })
|
||||
.returning();
|
||||
pokemonIds.set(saved.number, saved.id);
|
||||
}
|
||||
for (const row of catalog.games) {
|
||||
// Adopt older rows by exact name to preserve existing progress and game IDs.
|
||||
const [imported] = await tx.select().from(games).where(eq(games.sourceId, row.sourceId));
|
||||
if (!imported) {
|
||||
const legacy = await tx
|
||||
.select()
|
||||
.from(games)
|
||||
.where(and(eq(games.name, row.name), isNull(games.sourceId)));
|
||||
if (legacy.length > 1) throw new Error(`Ambiguous legacy game: ${row.name}`);
|
||||
if (legacy[0])
|
||||
await tx.update(games).set({ sourceId: row.sourceId }).where(eq(games.id, legacy[0].id));
|
||||
}
|
||||
const [saved] = await tx
|
||||
.insert(games)
|
||||
.values(row)
|
||||
.onConflictDoUpdate({ target: games.sourceId, set: row })
|
||||
.returning();
|
||||
gameIds.set(row.sourceId, saved.id);
|
||||
}
|
||||
for (let offset = 0; offset < catalog.entries.length; offset += 1000) {
|
||||
const rows = catalog.entries
|
||||
.slice(offset, offset + 1000)
|
||||
.map(([number, version, availability]) => {
|
||||
const pokemonId = pokemonIds.get(number),
|
||||
gameId = gameIds.get(version);
|
||||
if (!pokemonId || !gameId)
|
||||
throw new Error(`Unknown catalog reference ${number}/${version}`);
|
||||
return {
|
||||
pokemonId,
|
||||
gameId,
|
||||
availability,
|
||||
note:
|
||||
availability === 'catchable'
|
||||
? 'PokéAPI: documented encounter; conditions, location, or a specific form may be required.'
|
||||
: 'PokéAPI: Pokédex or game-index record only; catch, evolution, gift, trade, transfer, and event availability is not confirmed.'
|
||||
};
|
||||
});
|
||||
await tx
|
||||
.insert(pokemonGameEntries)
|
||||
.values(rows)
|
||||
.onConflictDoUpdate({
|
||||
target: [pokemonGameEntries.pokemonId, pokemonGameEntries.gameId],
|
||||
set: { availability: sql`excluded.availability`, note: sql`excluded.note` },
|
||||
// Preserve hand-maintained availability and notes.
|
||||
setWhere: sql`${pokemonGameEntries.note} like 'PokéAPI:%'`
|
||||
});
|
||||
}
|
||||
});
|
||||
console.log(
|
||||
`Seeded ${catalog.pokemon.length} species, ${catalog.games.length} versions, ${catalog.entries.length} entries from ${catalog.source.revision}`
|
||||
);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import postgres from 'postgres';
|
||||
const [userId, role = 'admin'] = process.argv.slice(2);
|
||||
if (!userId || !['admin', 'user'].includes(role))
|
||||
throw new Error('Usage: bun scripts/set-admin.ts <user-id> [admin|user]');
|
||||
if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
||||
const sql = postgres(process.env.DB_URL, { max: 1 });
|
||||
try {
|
||||
const users =
|
||||
await sql`update "user" set role = ${role}, updated_at = now() where id = ${userId} returning id, role`;
|
||||
if (!users.length)
|
||||
throw new Error('User ID not found. Register first and copy the ID from your account page.');
|
||||
console.log(users[0]);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
sha=${1:?staging commit required}
|
||||
case "$sha" in
|
||||
*[!0-9a-f]*|'') echo 'Invalid image tag' >&2; exit 2 ;;
|
||||
esac
|
||||
directory=/opt/personal-website-staging
|
||||
tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/.env" | head -n 1)
|
||||
if [ "$tag" != "$sha" ]; then
|
||||
echo "Staging runs $tag, not $sha" >&2
|
||||
exit 1
|
||||
fi
|
||||
image="git.stanrunge.dev/stan/personal-website:$sha"
|
||||
container=$(sudo docker compose --env-file "$directory/.env" -f "$directory/compose.prod.yml" ps -q app)
|
||||
expected=$(sudo docker image inspect -f '{{.Id}}' "$image")
|
||||
actual=$(sudo docker inspect -f '{{.Image}}' "$container")
|
||||
if [ "$actual" != "$expected" ]; then
|
||||
echo 'Staging container differs from the tested image' >&2
|
||||
exit 1
|
||||
fi
|
||||
curl --fail --silent --show-error https://staging.stanrunge.dev/login >/dev/null
|
||||
echo "Verified staging image $sha"
|
||||
195
src/app.css
195
src/app.css
@@ -1,159 +1,78 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
@theme {
|
||||
--color-bg: #000;
|
||||
--color-surface: #0a0a0a;
|
||||
--color-surface-hover: #171717;
|
||||
--color-border: #262626;
|
||||
--color-border-light: #1a1a1a;
|
||||
--color-text: #ededed;
|
||||
--color-text-muted: #a1a1a1;
|
||||
--color-text-dim: #888;
|
||||
--color-text-ghost: #666;
|
||||
--color-accent: #ededed;
|
||||
--color-accent-hover: #fff;
|
||||
--color-danger: #b44;
|
||||
|
||||
--font-heading: Montserrat, Arial, Helvetica, sans-serif;
|
||||
--font-mono: 'IBM Plex Mono', 'Courier New', monospace;
|
||||
--font-body: Montserrat, Arial, Helvetica, sans-serif;
|
||||
}
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--site-bg: #000;
|
||||
--site-surface: #0a0a0a;
|
||||
--site-hover: #171717;
|
||||
--site-text: #ededed;
|
||||
--site-muted: #a1a1a1;
|
||||
--site-border: #292929;
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--destructive: 0 72.2% 50.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--ring: 222.2 84% 4.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
--site-bg: #fff;
|
||||
--site-surface: #fafafa;
|
||||
--site-hover: #f4f4f5;
|
||||
--site-text: #171717;
|
||||
--site-muted: #646464;
|
||||
--site-border: #e5e5e5;
|
||||
--color-bg: #fff;
|
||||
--color-surface: #fafafa;
|
||||
--color-surface-hover: #f4f4f5;
|
||||
--color-border: #e5e5e5;
|
||||
--color-border-light: #eee;
|
||||
--color-text: #171717;
|
||||
--color-text-muted: #646464;
|
||||
--color-text-dim: #777;
|
||||
--color-text-ghost: #999;
|
||||
--color-accent: #171717;
|
||||
--color-accent-hover: #000;
|
||||
}
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
|
||||
.article-content {
|
||||
--tw-prose-body: var(--site-text);
|
||||
--tw-prose-headings: var(--site-text);
|
||||
--tw-prose-lead: var(--site-text);
|
||||
--tw-prose-links: var(--site-text);
|
||||
--tw-prose-bold: var(--site-text);
|
||||
--tw-prose-counters: var(--site-muted);
|
||||
--tw-prose-bullets: var(--site-muted);
|
||||
--tw-prose-hr: var(--site-border);
|
||||
--tw-prose-quotes: var(--site-text);
|
||||
--tw-prose-quote-borders: var(--site-border);
|
||||
--tw-prose-captions: var(--site-muted);
|
||||
--tw-prose-code: var(--site-text);
|
||||
--tw-prose-th-borders: var(--site-border);
|
||||
--tw-prose-td-borders: var(--site-border);
|
||||
}
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
|
||||
/* Keep older project pages readable while their individual styles use light utility colors. */
|
||||
:root:not([data-theme='light']) .bg-white,
|
||||
:root:not([data-theme='light']) .bg-gray-50,
|
||||
:root:not([data-theme='light']) .bg-neutral-50 {
|
||||
background-color: #0a0a0a;
|
||||
}
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
|
||||
:root:not([data-theme='light']) .bg-gray-100,
|
||||
:root:not([data-theme='light']) .bg-neutral-100 {
|
||||
background-color: #171717;
|
||||
}
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
|
||||
:root:not([data-theme='light']) .bg-gray-200,
|
||||
:root:not([data-theme='light']) .bg-neutral-200 {
|
||||
background-color: #262626;
|
||||
}
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
|
||||
:root:not([data-theme='light']) .text-black,
|
||||
:root:not([data-theme='light']) .text-gray-900,
|
||||
:root:not([data-theme='light']) .text-gray-800 {
|
||||
color: #ededed;
|
||||
}
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
:root:not([data-theme='light']) .text-gray-700 {
|
||||
color: #d4d4d4;
|
||||
}
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
:root:not([data-theme='light']) .text-gray-600,
|
||||
:root:not([data-theme='light']) .text-gray-500 {
|
||||
color: #a1a1a1;
|
||||
}
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
:root:not([data-theme='light']) .border-gray-100,
|
||||
:root:not([data-theme='light']) .border-gray-200,
|
||||
:root:not([data-theme='light']) .border-gray-300 {
|
||||
border-color: #292929;
|
||||
}
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
:root:not([data-theme='light']) .hover\:bg-gray-50:hover,
|
||||
:root:not([data-theme='light']) .hover\:bg-gray-100:hover {
|
||||
background-color: #171717;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
section[id] {
|
||||
scroll-margin-top: 88px;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--site-bg);
|
||||
color: var(--site-text);
|
||||
font-family: var(--font-body);
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--site-text);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
7
src/app.d.ts
vendored
7
src/app.d.ts
vendored
@@ -1,14 +1,9 @@
|
||||
/// <reference types="unplugin-icons/types/svelte" />
|
||||
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
interface Locals {
|
||||
user: typeof import('$lib/server/auth').auth.$Infer.Session.user | null;
|
||||
session: typeof import('$lib/server/auth').auth.$Infer.Session.session | null;
|
||||
}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
|
||||
40
src/app.html
40
src/app.html
@@ -1,48 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<script>
|
||||
let themePreference = 'dark';
|
||||
try {
|
||||
const storedTheme = localStorage.getItem('theme');
|
||||
if (storedTheme === 'light' || storedTheme === 'system') themePreference = storedTheme;
|
||||
} catch {
|
||||
// Keep the dark default if storage is unavailable.
|
||||
}
|
||||
document.documentElement.dataset.themePreference = themePreference;
|
||||
document.documentElement.dataset.theme =
|
||||
themePreference === 'system'
|
||||
? window.matchMedia('(prefers-color-scheme: light)').matches
|
||||
? 'light'
|
||||
: 'dark'
|
||||
: themePreference;
|
||||
document.querySelector('meta[name="theme-color"]').content =
|
||||
document.documentElement.dataset.theme === 'light' ? '#ffffff' : '#000000';
|
||||
</script>
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-7K10F5HJMQ"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
function gtag() {dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', 'G-7K10F5HJMQ');
|
||||
</script>
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="%sveltekit.assets%/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="%sveltekit.assets%/favicon-16x16.png" />
|
||||
<link rel="manifest" href="%sveltekit.assets%/site.webmanifest" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="%sveltekit.assets%/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="%sveltekit.assets%/favicon-16x16.png">
|
||||
<link rel="manifest" href="%sveltekit.assets%/site.webmanifest">
|
||||
<title>Stan Runge</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
@@ -50,4 +25,5 @@
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { building } from '$app/environment';
|
||||
import { auth } from '$lib/server/auth';
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Match the endpoint path here: the dev proxy may terminate HTTPS before Vite.
|
||||
// Better Auth validates request origins against its configured public URL.
|
||||
if (!building && event.url.pathname.startsWith('/api/auth/')) return auth.handler(event.request);
|
||||
const session = building ? null : await auth.api.getSession({ headers: event.request.headers });
|
||||
event.locals.user = session?.user ?? null;
|
||||
event.locals.session = session?.session ?? null;
|
||||
return resolve(event);
|
||||
};
|
||||
7
src/index.test.ts
Executable file
7
src/index.test.ts
Executable file
@@ -0,0 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('sum test', () => {
|
||||
it('adds 1 + 2 to equal 3', () => {
|
||||
expect(1 + 2).toBe(3);
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 87 KiB |
@@ -1,2 +0,0 @@
|
||||
import { createAuthClient } from 'better-auth/svelte';
|
||||
export const authClient = createAuthClient();
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { safeReturnTo } from './auth-redirect';
|
||||
|
||||
describe('login return destinations', () => {
|
||||
it.each([
|
||||
null,
|
||||
'',
|
||||
'https://evil.example',
|
||||
'//evil.example',
|
||||
'/\\evil.example',
|
||||
'/\nevil.example',
|
||||
'/api/auth/callback/osu',
|
||||
'/logout',
|
||||
'/login?returnTo=/blog'
|
||||
])('rejects %j', (value) => {
|
||||
expect(safeReturnTo(value)).toBe('/account');
|
||||
});
|
||||
it('keeps a local project destination and its query', () => {
|
||||
expect(safeReturnTo('/projects/catch-em-all?game=red#progress')).toBe(
|
||||
'/projects/catch-em-all?game=red#progress'
|
||||
);
|
||||
});
|
||||
it('normalizes paths before rejecting auth endpoints', () => {
|
||||
expect(safeReturnTo('/projects/../logout')).toBe('/account');
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
export function safeReturnTo(value: string | null): string {
|
||||
if (!value || !value.startsWith('/') || value.startsWith('//') || /[\\\s]/.test(value)) {
|
||||
return '/account';
|
||||
}
|
||||
const url = new URL(value, 'https://local.invalid');
|
||||
if (
|
||||
url.origin !== 'https://local.invalid' ||
|
||||
/^\/(api\/auth|login|register|logout)(\/|$)/.test(url.pathname)
|
||||
) {
|
||||
return '/account';
|
||||
}
|
||||
return url.pathname + url.search + url.hash;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// How a Pokémon can be obtained in a game. Missing rows mean no source record;
|
||||
// they do not prove absence. Unknown rows establish presence, not availability.
|
||||
// Shared between the db schema and the client, which receives availability as
|
||||
// an index into this array to keep the page payload small.
|
||||
export const availabilityValues = [
|
||||
'catchable',
|
||||
'trade_only',
|
||||
'transfer_only',
|
||||
'event_only',
|
||||
'unknown'
|
||||
] as const;
|
||||
|
||||
export type Availability = (typeof availabilityValues)[number];
|
||||
@@ -1,149 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { authClient } from '$lib/auth-client';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
let {
|
||||
mode,
|
||||
providers,
|
||||
returnTo,
|
||||
oauthError
|
||||
}: {
|
||||
mode: 'login' | 'register';
|
||||
providers: { google: boolean; osu: boolean };
|
||||
returnTo: string;
|
||||
oauthError: boolean;
|
||||
} = $props();
|
||||
let name = $state('');
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let busy = $state(false);
|
||||
let ready = $state(false);
|
||||
onMount(() => {
|
||||
ready = true;
|
||||
});
|
||||
let message = $state('');
|
||||
async function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const destination = `${resolve('/')}${returnTo.slice(1)}`;
|
||||
busy = true;
|
||||
message = '';
|
||||
try {
|
||||
const result =
|
||||
mode === 'register'
|
||||
? await authClient.signUp.email({ name: name.trim(), email, password })
|
||||
: await authClient.signIn.email({ email, password });
|
||||
if (result.error) {
|
||||
message = result.error.message || 'Unable to sign in. Please try again.';
|
||||
return;
|
||||
}
|
||||
// Destination is a validated local path, prefixed by resolve('/') above.
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
await goto(destination, { invalidateAll: true });
|
||||
} catch {
|
||||
message = 'Unable to connect. Please try again.';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
async function social(provider: 'google' | 'osu') {
|
||||
busy = true;
|
||||
message = '';
|
||||
try {
|
||||
const result = await authClient.signIn.social({
|
||||
provider,
|
||||
callbackURL: returnTo,
|
||||
errorCallbackURL: `/login?returnTo=${encodeURIComponent(returnTo)}`
|
||||
});
|
||||
if (result.error) {
|
||||
message = result.error.message || 'Unable to connect to this provider.';
|
||||
busy = false;
|
||||
}
|
||||
} catch {
|
||||
message = 'Unable to connect. Please try again.';
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="mx-auto my-12 max-w-md px-4 sm:my-20">
|
||||
<h1 class="mb-8 text-3xl font-semibold tracking-tight">
|
||||
{mode === 'register' ? 'create account' : 'sign in'}
|
||||
</h1>
|
||||
{#if message || oauthError}
|
||||
<p role="alert" class="mb-4 rounded bg-red-50 p-3 text-red-800">
|
||||
{message ||
|
||||
'Provider sign-in failed or was cancelled. Try again, or use your existing login and link the provider from your account.'}
|
||||
</p>
|
||||
{/if}
|
||||
<form method="POST" onsubmit={submit} class="flex flex-col gap-4">
|
||||
{#if mode === 'register'}
|
||||
<label class="flex flex-col gap-1"
|
||||
>Name<input
|
||||
class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
|
||||
disabled={!ready || busy}
|
||||
name="name"
|
||||
autocomplete="name"
|
||||
required
|
||||
maxlength="100"
|
||||
bind:value={name}
|
||||
/></label
|
||||
>
|
||||
{/if}
|
||||
<label class="flex flex-col gap-1"
|
||||
>Email<input
|
||||
class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
|
||||
disabled={!ready || busy}
|
||||
name="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
required
|
||||
bind:value={email}
|
||||
/></label
|
||||
>
|
||||
<label class="flex flex-col gap-1"
|
||||
>Password<input
|
||||
class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
|
||||
disabled={!ready || busy}
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete={mode === 'register' ? 'new-password' : 'current-password'}
|
||||
required
|
||||
minlength={mode === 'register' ? 12 : undefined}
|
||||
maxlength="128"
|
||||
bind:value={password}
|
||||
/></label
|
||||
>
|
||||
{#if mode === 'register'}<p class="text-sm text-gray-600">Use at least 12 characters.</p>{/if}
|
||||
<button
|
||||
disabled={!ready || busy}
|
||||
class="rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)] disabled:opacity-50"
|
||||
>{busy ? 'please wait…' : mode === 'register' ? 'create account' : 'sign in'}</button
|
||||
>
|
||||
</form>
|
||||
{#if providers.google || providers.osu}
|
||||
<div class="my-6 flex flex-col gap-3 border-t border-[var(--site-border)] pt-4">
|
||||
{#if providers.google}<button
|
||||
disabled={!ready || busy}
|
||||
onclick={() => social('google')}
|
||||
class="rounded border border-[var(--site-border)] px-4 py-2 disabled:opacity-50"
|
||||
>Continue with Google</button
|
||||
>{/if}
|
||||
{#if providers.osu}<button
|
||||
disabled={!ready || busy}
|
||||
onclick={() => social('osu')}
|
||||
class="rounded border border-[var(--site-border)] px-4 py-2 disabled:opacity-50"
|
||||
>Continue with osu!</button
|
||||
>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<p class="mt-6 text-base">
|
||||
<a
|
||||
class="underline underline-offset-4"
|
||||
href={resolve(
|
||||
`${mode === 'register' ? '/login' : '/register'}?returnTo=${encodeURIComponent(returnTo)}`
|
||||
)}>{mode === 'register' ? 'sign in' : 'Create account'}</a
|
||||
>
|
||||
</p>
|
||||
<noscript><p class="mt-4">Enable JavaScript to securely sign in or register.</p></noscript>
|
||||
</section>
|
||||
@@ -1,51 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
let {
|
||||
icon: Icon,
|
||||
label,
|
||||
value
|
||||
}: {
|
||||
icon: Component;
|
||||
label: string;
|
||||
value: string;
|
||||
} = $props();
|
||||
|
||||
let copied = $state(false);
|
||||
|
||||
async function copy(): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
copied = true;
|
||||
} catch {
|
||||
// clipboard blocked
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="group relative"
|
||||
role="group"
|
||||
onmouseleave={(): void => {
|
||||
copied = false;
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onclick={copy}
|
||||
aria-label={`Copy ${label}: ${value}`}
|
||||
class="flex h-10 w-10 cursor-pointer items-center justify-center text-xl transition hover:text-[var(--site-text)]"
|
||||
>
|
||||
<Icon />
|
||||
</button>
|
||||
|
||||
<span
|
||||
role="tooltip"
|
||||
class="pointer-events-none absolute top-full left-1/2 mt-2 -translate-x-1/2
|
||||
rounded bg-neutral-800 px-2 py-1 text-xs whitespace-nowrap text-white
|
||||
opacity-0 transition group-hover:opacity-100"
|
||||
>
|
||||
{copied ? 'Copied!' : value}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div aria-live="polite" class="sr-only">{copied ? `${label} copied` : ''}</div>
|
||||
@@ -1,64 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { label = 'Picture (optional)' }: { label?: string } = $props();
|
||||
let input: HTMLInputElement;
|
||||
let fileName = $state('');
|
||||
let dragging = $state(false);
|
||||
|
||||
function selected() {
|
||||
fileName = input.files?.[0]?.name ?? '';
|
||||
}
|
||||
|
||||
function drop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
dragging = false;
|
||||
const file = event.dataTransfer?.files?.[0];
|
||||
if (!file) return;
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(file);
|
||||
input.files = transfer.files;
|
||||
selected();
|
||||
}
|
||||
|
||||
function clear() {
|
||||
input.value = '';
|
||||
fileName = '';
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const form = input.form;
|
||||
form?.addEventListener('reset', clear);
|
||||
return () => form?.removeEventListener('reset', clear);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<span class="block font-medium">{label}</span>
|
||||
<input
|
||||
bind:this={input}
|
||||
name="picture"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onchange={selected}
|
||||
class="sr-only"
|
||||
aria-label={label}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => input.click()}
|
||||
ondragover={(event) => {
|
||||
event.preventDefault();
|
||||
dragging = true;
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
||||
}}
|
||||
ondragleave={() => (dragging = false)}
|
||||
ondrop={drop}
|
||||
class={`mt-2 block w-full rounded border border-dashed p-5 text-center transition-colors ${dragging ? 'border-[var(--site-text)] bg-[var(--site-hover)]' : 'border-[var(--site-border)] hover:border-[var(--site-muted)]'}`}
|
||||
>
|
||||
{fileName ? `Selected: ${fileName}` : 'Drop an image here or click to browse'}
|
||||
</button>
|
||||
<p class="mt-2 text-base text-[var(--site-muted)]">JPEG, PNG, or WebP · up to 5 MB</p>
|
||||
{#if fileName}<button type="button" onclick={clear} class="mt-2 underline">Clear selection</button
|
||||
>{/if}
|
||||
</div>
|
||||
@@ -1,43 +0,0 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
value,
|
||||
max,
|
||||
label,
|
||||
small = false
|
||||
}: {
|
||||
value: number;
|
||||
max: number;
|
||||
label: string;
|
||||
small?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<progress class:small {value} max={max || 1} aria-label={label}></progress>
|
||||
|
||||
<style>
|
||||
progress {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 0.625rem;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
background: var(--site-border);
|
||||
}
|
||||
progress.small {
|
||||
height: 0.375rem;
|
||||
}
|
||||
progress::-webkit-progress-bar {
|
||||
background: var(--site-border);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
progress::-webkit-progress-value {
|
||||
background: var(--site-text);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
progress::-moz-progress-bar {
|
||||
background: var(--site-text);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { Button as ButtonPrimitive } from 'bits-ui';
|
||||
import { type Events, type Props, buttonVariants } from './index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { Button as ButtonPrimitive } from "bits-ui";
|
||||
import { type Events, type Props, buttonVariants } from "./index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = Props;
|
||||
type $$Events = Events;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export let variant: $$Props['variant'] = 'default';
|
||||
export let size: $$Props['size'] = 'default';
|
||||
export let builders: $$Props['builders'] = [];
|
||||
let className: $$Props["class"] = undefined;
|
||||
export let variant: $$Props["variant"] = "default";
|
||||
export let size: $$Props["size"] = "default";
|
||||
export let builders: $$Props["builders"] = [];
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,33 +1,34 @@
|
||||
import { type VariantProps, tv } from 'tailwind-variants';
|
||||
import type { Button as ButtonPrimitive } from 'bits-ui';
|
||||
import Root from './button.svelte';
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
import type { Button as ButtonPrimitive } from "bits-ui";
|
||||
import Root from "./button.svelte";
|
||||
|
||||
const buttonVariants = tv({
|
||||
base: 'ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
base: "ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border-input bg-background hover:bg-accent hover:text-accent-foreground border',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border-input bg-background hover:bg-accent hover:text-accent-foreground border",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10'
|
||||
}
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
|
||||
type Variant = VariantProps<typeof buttonVariants>['variant'];
|
||||
type Size = VariantProps<typeof buttonVariants>['size'];
|
||||
type Variant = VariantProps<typeof buttonVariants>["variant"];
|
||||
type Size = VariantProps<typeof buttonVariants>["size"];
|
||||
|
||||
type Props = ButtonPrimitive.Props & {
|
||||
variant?: Variant;
|
||||
@@ -44,5 +45,5 @@ export {
|
||||
Root as Button,
|
||||
type Props as ButtonProps,
|
||||
type Events as ButtonEvents,
|
||||
buttonVariants
|
||||
buttonVariants,
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
import type { Command as CommandPrimitive } from 'cmdk-sv';
|
||||
import Command from './command.svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import type { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import type { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import Command from "./command.svelte";
|
||||
import * as Dialog from "$lib/components/ui/dialog/index.js";
|
||||
|
||||
type $$Props = DialogPrimitive.Props & CommandPrimitive.CommandProps;
|
||||
|
||||
export let open: $$Props['open'] = false;
|
||||
export let value: $$Props['value'] = undefined;
|
||||
export let open: $$Props["open"] = false;
|
||||
export let value: $$Props["value"] = undefined;
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open {...$$restProps}>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from 'cmdk-sv';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = CommandPrimitive.EmptyProps;
|
||||
let className: string | undefined | null = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Empty class={cn('py-6 text-center text-sm', className)} {...$$restProps}>
|
||||
<CommandPrimitive.Empty class={cn("py-6 text-center text-sm", className)} {...$$restProps}>
|
||||
<slot />
|
||||
</CommandPrimitive.Empty>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from 'cmdk-sv';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import { cn } from "$lib/utils.js";
|
||||
type $$Props = CommandPrimitive.GroupProps;
|
||||
|
||||
let className: string | undefined | null = undefined;
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<CommandPrimitive.Group
|
||||
class={cn(
|
||||
'text-foreground [&_[data-cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[data-cmdk-group-heading]]:px-2 [&_[data-cmdk-group-heading]]:py-1.5 [&_[data-cmdk-group-heading]]:text-xs [&_[data-cmdk-group-heading]]:font-medium',
|
||||
"text-foreground [&_[data-cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[data-cmdk-group-heading]]:px-2 [&_[data-cmdk-group-heading]]:py-1.5 [&_[data-cmdk-group-heading]]:text-xs [&_[data-cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from 'cmdk-sv';
|
||||
import Search from 'lucide-svelte/icons/search';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import Search from "lucide-svelte/icons/search";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = CommandPrimitive.InputProps;
|
||||
|
||||
let className: string | undefined | null = undefined;
|
||||
export { className as class };
|
||||
export let value: string = '';
|
||||
export let value: string = "";
|
||||
</script>
|
||||
|
||||
<div class="flex items-center border-b px-2" data-cmdk-input-wrapper="">
|
||||
<Search class="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
class={cn(
|
||||
'placeholder:text-muted-foreground flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
"placeholder:text-muted-foreground flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from 'cmdk-sv';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = CommandPrimitive.ItemProps;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<CommandPrimitive.Item
|
||||
{asChild}
|
||||
class={cn(
|
||||
'aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from 'cmdk-sv';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = CommandPrimitive.ListProps;
|
||||
let className: string | undefined | null = undefined;
|
||||
@@ -8,7 +8,7 @@
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.List
|
||||
class={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
|
||||
class={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from 'cmdk-sv';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = CommandPrimitive.SeparatorProps;
|
||||
let className: string | undefined | null = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Separator class={cn('bg-border -mx-1 h-px', className)} {...$$restProps} />
|
||||
<CommandPrimitive.Separator class={cn("bg-border -mx-1 h-px", className)} {...$$restProps} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
|
||||
class={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from 'cmdk-sv';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = CommandPrimitive.CommandProps;
|
||||
|
||||
export let value: $$Props['value'] = undefined;
|
||||
export let value: $$Props["value"] = undefined;
|
||||
|
||||
let className: string | undefined | null = undefined;
|
||||
export { className as class };
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<CommandPrimitive.Root
|
||||
class={cn(
|
||||
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
bind:value
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Command as CommandPrimitive } from 'cmdk-sv';
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
|
||||
import Root from './command.svelte';
|
||||
import Dialog from './command-dialog.svelte';
|
||||
import Empty from './command-empty.svelte';
|
||||
import Group from './command-group.svelte';
|
||||
import Item from './command-item.svelte';
|
||||
import Input from './command-input.svelte';
|
||||
import List from './command-list.svelte';
|
||||
import Separator from './command-separator.svelte';
|
||||
import Shortcut from './command-shortcut.svelte';
|
||||
import Root from "./command.svelte";
|
||||
import Dialog from "./command-dialog.svelte";
|
||||
import Empty from "./command-empty.svelte";
|
||||
import Group from "./command-group.svelte";
|
||||
import Item from "./command-item.svelte";
|
||||
import Input from "./command-input.svelte";
|
||||
import List from "./command-list.svelte";
|
||||
import Separator from "./command-separator.svelte";
|
||||
import Shortcut from "./command-shortcut.svelte";
|
||||
|
||||
const Loading = CommandPrimitive.Loading;
|
||||
|
||||
@@ -33,5 +33,5 @@ export {
|
||||
List as CommandList,
|
||||
Separator as CommandSeparator,
|
||||
Shortcut as CommandShortcut,
|
||||
Loading as CommandLoading
|
||||
Loading as CommandLoading,
|
||||
};
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
import X from 'lucide-svelte/icons/x';
|
||||
import * as Dialog from './index.js';
|
||||
import { cn, flyAndScale } from '$lib/utils.js';
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import X from "lucide-svelte/icons/x";
|
||||
import * as Dialog from "./index.js";
|
||||
import { cn, flyAndScale } from "$lib/utils.js";
|
||||
|
||||
type $$Props = DialogPrimitive.ContentProps;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export let transition: $$Props['transition'] = flyAndScale;
|
||||
export let transitionConfig: $$Props['transitionConfig'] = {
|
||||
duration: 200
|
||||
let className: $$Props["class"] = undefined;
|
||||
export let transition: $$Props["transition"] = flyAndScale;
|
||||
export let transitionConfig: $$Props["transitionConfig"] = {
|
||||
duration: 200,
|
||||
};
|
||||
export { className as class };
|
||||
</script>
|
||||
@@ -20,7 +20,7 @@
|
||||
{transition}
|
||||
{transitionConfig}
|
||||
class={cn(
|
||||
'bg-background fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg md:w-full',
|
||||
"bg-background fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg md:w-full",
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = DialogPrimitive.DescriptionProps;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Description
|
||||
class={cn('text-muted-foreground text-sm', className)}
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<div class={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...$$restProps}>
|
||||
<div class={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...$$restProps}>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { fade } from "svelte/transition";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = DialogPrimitive.OverlayProps;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export let transition: $$Props['transition'] = fade;
|
||||
export let transitionConfig: $$Props['transitionConfig'] = {
|
||||
duration: 150
|
||||
let className: $$Props["class"] = undefined;
|
||||
export let transition: $$Props["transition"] = fade;
|
||||
export let transitionConfig: $$Props["transitionConfig"] = {
|
||||
duration: 150,
|
||||
};
|
||||
export { className as class };
|
||||
</script>
|
||||
@@ -16,6 +16,6 @@
|
||||
<DialogPrimitive.Overlay
|
||||
{transition}
|
||||
{transitionConfig}
|
||||
class={cn('bg-background/80 fixed inset-0 z-50 backdrop-blur-sm', className)}
|
||||
class={cn("bg-background/80 fixed inset-0 z-50 backdrop-blur-sm", className)}
|
||||
{...$$restProps}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
type $$Props = DialogPrimitive.PortalProps;
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = DialogPrimitive.TitleProps;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Title
|
||||
class={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
class={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
|
||||
import Title from './dialog-title.svelte';
|
||||
import Portal from './dialog-portal.svelte';
|
||||
import Footer from './dialog-footer.svelte';
|
||||
import Header from './dialog-header.svelte';
|
||||
import Overlay from './dialog-overlay.svelte';
|
||||
import Content from './dialog-content.svelte';
|
||||
import Description from './dialog-description.svelte';
|
||||
import Title from "./dialog-title.svelte";
|
||||
import Portal from "./dialog-portal.svelte";
|
||||
import Footer from "./dialog-footer.svelte";
|
||||
import Header from "./dialog-header.svelte";
|
||||
import Overlay from "./dialog-overlay.svelte";
|
||||
import Content from "./dialog-content.svelte";
|
||||
import Description from "./dialog-description.svelte";
|
||||
|
||||
const Root = DialogPrimitive.Root;
|
||||
const Trigger = DialogPrimitive.Trigger;
|
||||
@@ -33,5 +33,5 @@ export {
|
||||
Overlay as DialogOverlay,
|
||||
Content as DialogContent,
|
||||
Description as DialogDescription,
|
||||
Close as DialogClose
|
||||
Close as DialogClose,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||
import Content from './popover-content.svelte';
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
import Content from "./popover-content.svelte";
|
||||
const Root = PopoverPrimitive.Root;
|
||||
const Trigger = PopoverPrimitive.Trigger;
|
||||
const Close = PopoverPrimitive.Close;
|
||||
@@ -13,5 +13,5 @@ export {
|
||||
Root as Popover,
|
||||
Content as PopoverContent,
|
||||
Trigger as PopoverTrigger,
|
||||
Close as PopoverClose
|
||||
Close as PopoverClose,
|
||||
};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||
import { cn, flyAndScale } from '$lib/utils.js';
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
import { cn, flyAndScale } from "$lib/utils.js";
|
||||
|
||||
type $$Props = PopoverPrimitive.ContentProps;
|
||||
let className: $$Props['class'] = undefined;
|
||||
export let transition: $$Props['transition'] = flyAndScale;
|
||||
export let transitionConfig: $$Props['transitionConfig'] = undefined;
|
||||
let className: $$Props["class"] = undefined;
|
||||
export let transition: $$Props["transition"] = flyAndScale;
|
||||
export let transitionConfig: $$Props["transitionConfig"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
{transition}
|
||||
{transitionConfig}
|
||||
class={cn(
|
||||
'bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-md outline-none',
|
||||
"bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-md outline-none",
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import Root from './table.svelte';
|
||||
import Body from './table-body.svelte';
|
||||
import Caption from './table-caption.svelte';
|
||||
import Cell from './table-cell.svelte';
|
||||
import Footer from './table-footer.svelte';
|
||||
import Head from './table-head.svelte';
|
||||
import Header from './table-header.svelte';
|
||||
import Row from './table-row.svelte';
|
||||
import Root from "./table.svelte";
|
||||
import Body from "./table-body.svelte";
|
||||
import Caption from "./table-caption.svelte";
|
||||
import Cell from "./table-cell.svelte";
|
||||
import Footer from "./table-footer.svelte";
|
||||
import Head from "./table-head.svelte";
|
||||
import Header from "./table-header.svelte";
|
||||
import Row from "./table-row.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
@@ -24,5 +24,5 @@ export {
|
||||
Footer as TableFooter,
|
||||
Head as TableHead,
|
||||
Header as TableHeader,
|
||||
Row as TableRow
|
||||
Row as TableRow,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import type { WithElementRef } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -11,6 +11,6 @@
|
||||
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
|
||||
</script>
|
||||
|
||||
<tbody bind:this={ref} class={cn('[&_tr:last-child]:border-0', className)} {...restProps}>
|
||||
<tbody bind:this={ref} class={cn("[&_tr:last-child]:border-0", className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</tbody>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import type { WithElementRef } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -11,6 +11,6 @@
|
||||
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||
</script>
|
||||
|
||||
<caption bind:this={ref} class={cn('text-muted-foreground mt-4 text-sm', className)} {...restProps}>
|
||||
<caption bind:this={ref} class={cn("text-muted-foreground mt-4 text-sm", className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</caption>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLTdAttributes } from 'svelte/elements';
|
||||
import type { WithElementRef } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import type { HTMLTdAttributes } from "svelte/elements";
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
<td
|
||||
bind:this={ref}
|
||||
class={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
||||
class={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import type { WithElementRef } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -11,6 +11,6 @@
|
||||
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
|
||||
</script>
|
||||
|
||||
<tfoot bind:this={ref} class={cn('bg-muted/50 font-medium', className)} {...restProps}>
|
||||
<tfoot bind:this={ref} class={cn("bg-muted/50 font-medium", className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</tfoot>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLThAttributes } from 'svelte/elements';
|
||||
import type { WithElementRef } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import type { HTMLThAttributes } from "svelte/elements";
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -14,7 +14,7 @@
|
||||
<th
|
||||
bind:this={ref}
|
||||
class={cn(
|
||||
'text-muted-foreground h-12 px-4 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0',
|
||||
"text-muted-foreground h-12 px-4 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import type { WithElementRef } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -11,6 +11,6 @@
|
||||
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
|
||||
</script>
|
||||
|
||||
<thead bind:this={ref} class={cn('[&_tr]:border-b', className)} {...restProps}>
|
||||
<thead bind:this={ref} class={cn("[&_tr]:border-b", className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</thead>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import type { WithElementRef } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -14,7 +14,7 @@
|
||||
<tr
|
||||
bind:this={ref}
|
||||
class={cn(
|
||||
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLTableAttributes } from 'svelte/elements';
|
||||
import type { WithElementRef } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import type { HTMLTableAttributes } from "svelte/elements";
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -12,7 +12,7 @@
|
||||
</script>
|
||||
|
||||
<div class="relative w-full overflow-auto">
|
||||
<table bind:this={ref} class={cn('w-full caption-bottom text-sm', className)} {...restProps}>
|
||||
<table bind:this={ref} class={cn("w-full caption-bottom text-sm", className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { relations } from './relations';
|
||||
import * as schema from './schema';
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
|
||||
const client = postgres(env.DB_URL, { prepare: false });
|
||||
|
||||
export const db = drizzle({
|
||||
relations,
|
||||
schema,
|
||||
client
|
||||
});
|
||||
73
src/lib/db/schema.ts
Executable file
73
src/lib/db/schema.ts
Executable file
@@ -0,0 +1,73 @@
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { integer, pgSchema, serial, timestamp, varchar } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const personalWebsiteSchema = pgSchema('personal_website');
|
||||
|
||||
export const tasks = personalWebsiteSchema.table('tasks', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 256 }),
|
||||
currentPoints: integer('current_points').default(0),
|
||||
totalPoints: integer('total_points').default(1),
|
||||
notes: varchar('notes', { length: 8192 }),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow()
|
||||
});
|
||||
|
||||
export const tasksRelations = relations(tasks, ({ many }) => ({
|
||||
tasksToTopics: many(tasksToTopics)
|
||||
}));
|
||||
|
||||
export const topics = personalWebsiteSchema.table('topics', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 256 }),
|
||||
emoji: varchar('emoji', { length: 256 }),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow()
|
||||
});
|
||||
|
||||
export const topicsRelations = relations(topics, ({ many }) => ({
|
||||
topicsToTasks: many(tasksToTopics)
|
||||
}));
|
||||
|
||||
export const tasksToTopics = personalWebsiteSchema.table('tasks_to_topics', {
|
||||
taskId: integer('task_id')
|
||||
.notNull()
|
||||
.references(() => tasks.id),
|
||||
topicId: integer('topic_id')
|
||||
.notNull()
|
||||
.references(() => topics.id)
|
||||
});
|
||||
|
||||
export const taskToTopicsRelations = relations(tasksToTopics, ({ one }) => ({
|
||||
task: one(tasks, {
|
||||
fields: [tasksToTopics.taskId],
|
||||
references: [tasks.id]
|
||||
}),
|
||||
topic: one(topics, {
|
||||
fields: [tasksToTopics.topicId],
|
||||
references: [topics.id]
|
||||
})
|
||||
}));
|
||||
|
||||
export const universities = personalWebsiteSchema.table('universities', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 256 }),
|
||||
progress: integer('progress').default(0),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow()
|
||||
});
|
||||
|
||||
export const certificates = personalWebsiteSchema.table('certificates', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 256 }),
|
||||
progress: integer('progress').default(0),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow()
|
||||
});
|
||||
|
||||
export const publications = personalWebsiteSchema.table('publications', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 256 }),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow()
|
||||
});
|
||||
@@ -1,14 +1,11 @@
|
||||
import { topics } from './schema';
|
||||
import * as schema from './schema';
|
||||
import { topics } from "./schema"
|
||||
import * as schema from './schema'
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
|
||||
const client = postgres('postgresql://postgres:postgres@localhost:5432/db');
|
||||
const db = drizzle(client, { schema });
|
||||
|
||||
await db.insert(topics).values([
|
||||
{ name: 'Math', emoji: '👍' },
|
||||
{ name: 'Programming', emoji: '👍' }
|
||||
]);
|
||||
await db.insert(topics).values([{ name: 'Math', emoji: '👍' }, { name: 'Programming', emoji: '👍' }])
|
||||
|
||||
process.exit(0);
|
||||
process.exit(0)
|
||||
@@ -1,57 +0,0 @@
|
||||
// Derby R 2026 · Duindigt · zondag 20 september · 2600 m autostart
|
||||
// Bronnen: NDR deelnemerslijst, ZEturf koerskaart (records over 2600 m),
|
||||
// Geny uitslagen Derby Selectie R (heat 1) en Merrie Derby R (heat 2), 4 september 2026.
|
||||
|
||||
export type Result = number | 'D';
|
||||
|
||||
export type Horse = {
|
||||
n: number;
|
||||
name: string;
|
||||
driver: string;
|
||||
trainer: string;
|
||||
sex: 'M' | 'H' | 'R';
|
||||
/** laatste vijf uitslagen, nieuwste eerst; D = gediskwalificeerd */
|
||||
form: Result[];
|
||||
/** beste kilometertijd over 2600 m in seconden, null = onbekend */
|
||||
record: number | null;
|
||||
/** Derbyproef 4 september (2000 m autostart) */
|
||||
trial: { heat: 1 | 2; pos: number; time: number; odds: number };
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export const race = {
|
||||
name: 'Derby R 2026',
|
||||
date: 'zo 20 sep · 17:40',
|
||||
track: 'Duindigt',
|
||||
distance: 2600,
|
||||
start: 'Autostart',
|
||||
purse: 74000,
|
||||
surface: 'Sintelbaan · goed',
|
||||
sources: [
|
||||
{
|
||||
label: 'NDR deelnemerslijst',
|
||||
href: 'https://ndr.nl/wp-content/uploads/2026/09/Ingeschreven-paarden-in-de-Derby-R-Duindigt-zondag-20-september.pdf'
|
||||
},
|
||||
{
|
||||
label: 'ZEturf koerskaart',
|
||||
href: 'https://www.zeturf.be/nl/course-du-jour/2026-09-20/R14C6-duindigt-derby-r'
|
||||
},
|
||||
{ label: 'Proef heat 1', href: 'https://www.geny.com/course/1681375/arrivee-rapports' },
|
||||
{ label: 'Proef heat 2', href: 'https://www.geny.com/course/1681376/arrivee-rapports' }
|
||||
]
|
||||
};
|
||||
|
||||
const t = (m: number, s: number) => m * 60 + s;
|
||||
|
||||
export const horses: Horse[] = [
|
||||
{ n: 1, name: 'Rihane', driver: 'Micha Brouwer', trainer: 'Danny Brouwer', sex: 'M', form: [2, 3, 9, 7, 4], record: t(1, 13.7), trial: { heat: 2, pos: 2, time: t(1, 17.7), odds: 3.0 } },
|
||||
{ n: 2, name: 'Rose Starlake', driver: 'Jeffrey Mieras', trainer: 'Dion Tesselaar', sex: 'M', form: [1, 2, 2, 1, 'D'], record: t(1, 13.4), trial: { heat: 2, pos: 1, time: t(1, 17.3), odds: 3.5 } },
|
||||
{ n: 3, name: 'Rising Sun Boko', driver: 'Danny Brouwer', trainer: 'Danny Brouwer', sex: 'H', form: [2, 3, 1, 4, 1], record: t(1, 14.2), trial: { heat: 1, pos: 2, time: t(1, 13.8), odds: 23.1 } },
|
||||
{ n: 4, name: 'Roy Schermer', driver: 'Robin Bakker', trainer: 'Paul Hagoort', sex: 'R', form: [3, 2, 'D', 2, 1], record: t(1, 11.8), trial: { heat: 1, pos: 3, time: t(1, 14.5), odds: 7.8 } },
|
||||
{ n: 5, name: 'Rolleman Flevo', driver: 'Dion Tesselaar', trainer: 'Dion Tesselaar', sex: 'H', form: [1, 1, 1, 1, 1], record: null, trial: { heat: 1, pos: 1, time: t(1, 13.8), odds: 1.3 }, note: '13 uit 13' },
|
||||
{ n: 6, name: 'Royal Express', driver: 'Jim Veldman', trainer: 'Paul Hagoort', sex: 'M', form: [3, 1, 6, 1, 3], record: t(1, 12.4), trial: { heat: 2, pos: 3, time: t(1, 17.8), odds: 1.8 } },
|
||||
{ n: 7, name: 'Ram Jam', driver: 'Michel Rothengatter', trainer: 'Michel Rothengatter', sex: 'R', form: [4, 1, 6, 1, 1], record: t(1, 11.5), trial: { heat: 1, pos: 4, time: t(1, 14.6), odds: 8.8 } },
|
||||
{ n: 8, name: 'Rex Beuckenswijk', driver: 'Jaap van Rijn', trainer: 'Jeroen Engwerda', sex: 'R', form: [6, 4, 2, 1, 1], record: null, trial: { heat: 1, pos: 6, time: t(1, 15.2), odds: 34.9 } },
|
||||
{ n: 9, name: 'Ragna L Kite', driver: 'Rob de Vlieger', trainer: 'Rob de Vlieger', sex: 'M', form: [4, 2, 3, 1, 3], record: t(1, 14.8), trial: { heat: 2, pos: 4, time: t(1, 17.8), odds: 6.8 } },
|
||||
{ n: 10, name: 'Ritchy Diamond', driver: 'Patrick de Haan', trainer: 'Michel Rothengatter', sex: 'R', form: [5, 3, 1, 'D', 2], record: null, trial: { heat: 1, pos: 5, time: t(1, 14.7), odds: 20.9 } }
|
||||
];
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { defaultsFor, gPredict } from './generic';
|
||||
import { placings } from './model';
|
||||
import { races } from './races';
|
||||
|
||||
describe('generic race model', () => {
|
||||
for (const race of races) {
|
||||
it(`${race.slug}: probabilities sum to one and placings are consistent`, () => {
|
||||
const preds = gPredict(race.runners, defaultsFor(race));
|
||||
expect(preds.reduce((a, r) => a + r.p, 0)).toBeCloseTo(1, 9);
|
||||
expect(preds.length).toBe(race.runners.length);
|
||||
const { top, trio } = placings(preds, 0.8);
|
||||
expect(top.reduce((a, t) => a + t.win, 0)).toBeCloseTo(1, 9);
|
||||
expect(trio.reduce((a, t) => a + t.p, 0)).toBeCloseTo(1, 9);
|
||||
for (const r of preds) expect(Number.isFinite(r.logit)).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
it('debutants get the field average and a higher fail risk', () => {
|
||||
const race = races.find((r) => r.slug === 'south-holland')!;
|
||||
const preds = gPredict(race.runners, defaultsFor(race));
|
||||
const deb = preds.find((r) => r.horse.n === 1)!;
|
||||
expect(deb.features.estimated).toEqual(['form', 'speed', 'level']);
|
||||
expect(deb.features.pFail).toBeCloseTo(0.12);
|
||||
});
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
// Algemeen kansmodel voor de overige koersen: zelfde opzet als het Derby-model
|
||||
// (z-scores × gewichten → softmax), maar zonder proef- en marktsignaal.
|
||||
// Signalen: uitslagen, snelheid (beste recente km-tijd), niveau (prijzengeld
|
||||
// van de laatste starts), startpositie en uitvalrisico.
|
||||
|
||||
import type { Result } from './data';
|
||||
import type { Race, Runner } from './races';
|
||||
|
||||
export type GWeights = {
|
||||
form: number;
|
||||
speed: number;
|
||||
level: number;
|
||||
draw: number;
|
||||
risk: number;
|
||||
halfLife: number;
|
||||
temperature: number;
|
||||
lambda: number;
|
||||
};
|
||||
|
||||
export const gFeatureKeys = ['form', 'speed', 'level', 'draw'] as const;
|
||||
export type GFeatureKey = (typeof gFeatureKeys)[number];
|
||||
|
||||
export const gDefaults: GWeights = {
|
||||
form: 1,
|
||||
speed: 1,
|
||||
level: 0.6,
|
||||
draw: 0.4,
|
||||
risk: 1,
|
||||
halfLife: 2,
|
||||
temperature: 1,
|
||||
lambda: 0.8
|
||||
};
|
||||
|
||||
/** rensport: geen tijden, startbox minder belangrijk over 1900 m */
|
||||
export const defaultsFor = (race: Race): GWeights =>
|
||||
race.discipline === 'ren' ? { ...gDefaults, speed: 0, draw: 0.2, level: 0.8 } : { ...gDefaults };
|
||||
|
||||
export const gPresets: Record<string, Partial<GWeights>> = {
|
||||
Standaard: {},
|
||||
Vorm: { form: 1.6, speed: 0.5, level: 0.4 },
|
||||
Snelheid: { form: 0.5, speed: 1.6, level: 0.4 },
|
||||
Niveau: { form: 0.6, speed: 0.6, level: 1.5 }
|
||||
};
|
||||
|
||||
const SCALE = 0.55;
|
||||
const POSITION_POINTS: Record<number, number> = { 1: 1, 2: 0.75, 3: 0.6, 4: 0.45, 5: 0.35, 6: 0.25 };
|
||||
const positionPoints = (r: Result) => (r === 'D' ? 0 : (POSITION_POINTS[r] ?? 0.1));
|
||||
const DRAW_SCORE = [0, 0.6, 0.7, 0.6, 0.45, 0.4, 0.25, 0.1, 0, -0.2, -0.3];
|
||||
|
||||
export type GFeatures = {
|
||||
form: number;
|
||||
speed: number;
|
||||
level: number;
|
||||
draw: number;
|
||||
pFail: number;
|
||||
/** welke signalen ontbraken en het veldgemiddelde kregen */
|
||||
estimated: GFeatureKey[];
|
||||
};
|
||||
|
||||
const mean = (xs: number[]) => xs.reduce((a, b) => a + b, 0) / xs.length;
|
||||
|
||||
function rawForm(h: Runner, w: GWeights): number | null {
|
||||
if (h.form.length === 0) return null;
|
||||
const decay = h.form.map((_, i) => Math.pow(0.5, i / w.halfLife));
|
||||
return h.form.reduce((s: number, r, i) => s + decay[i] * positionPoints(r), 0) / decay.reduce((a, b) => a + b, 0);
|
||||
}
|
||||
|
||||
export function gFeatures(runners: Runner[], w: GWeights): GFeatures[] {
|
||||
const raw = runners.map((h) => ({
|
||||
form: rawForm(h, w),
|
||||
speed: h.times.length ? -Math.min(...h.times) : null,
|
||||
level: h.purses.length ? mean(h.purses.map((p) => Math.log(p))) : null
|
||||
}));
|
||||
const fill = (k: 'form' | 'speed' | 'level') => {
|
||||
const known = raw.map((r) => r[k]).filter((v): v is number => v !== null);
|
||||
return known.length ? mean(known) : 0;
|
||||
};
|
||||
const avg = { form: fill('form'), speed: fill('speed'), level: fill('level') };
|
||||
|
||||
return runners.map((h, i) => {
|
||||
const estimated: GFeatureKey[] = [];
|
||||
const pick = (k: 'form' | 'speed' | 'level') => {
|
||||
const v = raw[i][k];
|
||||
if (v === null) estimated.push(k);
|
||||
return v ?? avg[k];
|
||||
};
|
||||
const disq = h.form.filter((r) => r === 'D').length;
|
||||
const poor = h.form.filter((r) => r !== 'D' && r >= 7).length;
|
||||
// debutanten: geen bewijs dat ze in de draf blijven
|
||||
const base = h.form.length === 0 ? 0.12 : 0.03;
|
||||
const pFail = Math.min(0.5, base + 0.1 * disq + 0.03 * poor);
|
||||
return { form: pick('form'), speed: pick('speed'), level: pick('level'), draw: DRAW_SCORE[h.n] ?? 0, pFail, estimated };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ondergrens voor de spreiding per signaal, anders wordt 0,2 s verschil in een
|
||||
* veld met drie bijna gelijke tijden een enorme z-score
|
||||
*/
|
||||
const SD_FLOOR: Record<GFeatureKey, number> = { form: 0.08, speed: 1, level: 0.3, draw: 0.15 };
|
||||
|
||||
function zscores(values: number[], floor: number): number[] {
|
||||
const m = mean(values);
|
||||
const sd = Math.max(floor, Math.sqrt(mean(values.map((v) => (v - m) ** 2))));
|
||||
return values.map((v) => (v - m) / sd);
|
||||
}
|
||||
|
||||
export type GPrediction = {
|
||||
horse: Runner;
|
||||
features: GFeatures;
|
||||
contributions: Record<GFeatureKey, number>;
|
||||
riskPenalty: number;
|
||||
logit: number;
|
||||
p: number;
|
||||
};
|
||||
|
||||
export function gPredict(runners: Runner[], w: GWeights): GPrediction[] {
|
||||
if (runners.length === 0) return [];
|
||||
const feats = gFeatures(runners, w);
|
||||
const z = Object.fromEntries(gFeatureKeys.map((k) => [k, zscores(feats.map((f) => f[k]), SD_FLOOR[k])])) as Record<GFeatureKey, number[]>;
|
||||
const rows = runners.map((horse, i) => {
|
||||
const contributions = Object.fromEntries(gFeatureKeys.map((k) => [k, w[k] * z[k][i] * SCALE])) as Record<GFeatureKey, number>;
|
||||
const riskPenalty = w.risk * Math.log(1 - feats[i].pFail);
|
||||
const logit = Object.values(contributions).reduce((a, b) => a + b, 0) + riskPenalty;
|
||||
return { horse, features: feats[i], contributions, riskPenalty, logit, p: 0 };
|
||||
});
|
||||
const max = Math.max(...rows.map((r) => r.logit));
|
||||
const exp = rows.map((r) => Math.exp((r.logit - max) / w.temperature));
|
||||
const total = exp.reduce((a, b) => a + b, 0);
|
||||
rows.forEach((r, i) => (r.p = exp[i] / total));
|
||||
return rows.sort((a, b) => b.p - a.p);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { horses } from './data';
|
||||
import { betValue, defaultWeights, placings, predict } from './model';
|
||||
|
||||
describe('derby model', () => {
|
||||
it('produces a probability distribution', () => {
|
||||
const preds = predict(horses, defaultWeights);
|
||||
const sum = preds.reduce((a, r) => a + r.p, 0);
|
||||
expect(sum).toBeCloseTo(1, 9);
|
||||
expect(preds[0].horse.n).toBe(5);
|
||||
});
|
||||
|
||||
it('renormalises after scratching', () => {
|
||||
const preds = predict(horses.filter((h) => h.n !== 5), defaultWeights);
|
||||
expect(preds.reduce((a, r) => a + r.p, 0)).toBeCloseTo(1, 9);
|
||||
expect(preds.find((r) => r.horse.n === 5)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('place probabilities are consistent', () => {
|
||||
const preds = predict(horses, defaultWeights);
|
||||
const { top, exacta, koppel, trio } = placings(preds, defaultWeights.lambda);
|
||||
for (const key of ['win', 'place2', 'place3'] as const) {
|
||||
expect(top.reduce((a, t) => a + t[key], 0)).toBeCloseTo(1, 9);
|
||||
}
|
||||
expect(exacta.reduce((a, e) => a + e.p, 0)).toBeCloseTo(1, 9);
|
||||
expect(koppel.reduce((a, e) => a + e.p, 0)).toBeCloseTo(1, 9);
|
||||
expect(trio.reduce((a, e) => a + e.p, 0)).toBeCloseTo(1, 9);
|
||||
expect(koppel.length).toBe(45);
|
||||
expect(trio.length).toBe(720);
|
||||
});
|
||||
|
||||
it('kelly is zero without edge', () => {
|
||||
expect(betValue(0.2, 4).kelly).toBe(0);
|
||||
expect(betValue(0.5, 3).edge).toBeCloseTo(0.5);
|
||||
expect(betValue(0.5, 3).kelly).toBeCloseTo(0.25);
|
||||
});
|
||||
});
|
||||
@@ -1,184 +0,0 @@
|
||||
import type { Horse, Result } from './data';
|
||||
|
||||
export type Weights = {
|
||||
form: number;
|
||||
trial: number;
|
||||
record: number;
|
||||
draw: number;
|
||||
market: number;
|
||||
risk: number;
|
||||
/** halveringstijd van de vormweging, in koersen */
|
||||
halfLife: number;
|
||||
/** seconden die de (langzamere) merrieproef wordt bestraft t.o.v. heat 1 */
|
||||
heatGap: number;
|
||||
/** softmax-temperatuur: >1 vlakker, <1 scherper */
|
||||
temperature: number;
|
||||
/** Stern-demping voor plaatskansen: lagere posities gebruiken p^lambda */
|
||||
lambda: number;
|
||||
};
|
||||
|
||||
export const defaultWeights: Weights = {
|
||||
form: 1,
|
||||
trial: 1,
|
||||
record: 0.6,
|
||||
draw: 0.4,
|
||||
market: 0.6,
|
||||
risk: 1,
|
||||
halfLife: 2,
|
||||
heatGap: 0.8,
|
||||
temperature: 1,
|
||||
lambda: 0.8
|
||||
};
|
||||
|
||||
export const presets: Record<string, Partial<Weights>> = {
|
||||
Standaard: {},
|
||||
Proef: { form: 0.5, trial: 1.6, record: 0.3, draw: 0.4, market: 0 },
|
||||
Vorm: { form: 1.6, trial: 0.5, record: 0.5, draw: 0.3, market: 0 },
|
||||
Markt: { form: 0.3, trial: 0.3, record: 0.3, draw: 0.2, market: 1.6 }
|
||||
};
|
||||
|
||||
export const featureKeys = ['form', 'trial', 'record', 'draw', 'market'] as const;
|
||||
export type FeatureKey = (typeof featureKeys)[number];
|
||||
|
||||
/** logit-schaal: z-scores × gewichten worden hiermee vermenigvuldigd */
|
||||
const SCALE = 0.55;
|
||||
|
||||
const POSITION_POINTS: Record<number, number> = { 1: 1, 2: 0.75, 3: 0.6, 4: 0.45, 5: 0.35, 6: 0.25 };
|
||||
const positionPoints = (r: Result) => (r === 'D' ? 0 : (POSITION_POINTS[r] ?? 0.1));
|
||||
|
||||
/** binnenste startnummers zijn goud, 9 en 10 staan in de tweede rij */
|
||||
const DRAW_SCORE = [0, 0.6, 0.7, 0.6, 0.45, 0.4, 0.25, 0.1, 0, -0.2, -0.3];
|
||||
|
||||
export type Features = {
|
||||
form: number;
|
||||
trial: number;
|
||||
record: number;
|
||||
draw: number;
|
||||
market: number;
|
||||
pFail: number;
|
||||
recordEstimated: boolean;
|
||||
};
|
||||
|
||||
export function features(h: Horse, all: Horse[], w: Weights): Features {
|
||||
const decay = h.form.map((_, i) => Math.pow(0.5, i / w.halfLife));
|
||||
const form =
|
||||
h.form.reduce((sum: number, r, i) => sum + decay[i] * positionPoints(r), 0) /
|
||||
decay.reduce((a, b) => a + b, 0);
|
||||
|
||||
const heatWinner = Math.min(...all.filter((o) => o.trial.heat === h.trial.heat).map((o) => o.trial.time));
|
||||
const trial = -(h.trial.time - heatWinner) - (h.trial.heat === 2 ? w.heatGap : 0);
|
||||
|
||||
const known = all.filter((o) => o.record !== null).map((o) => o.record as number);
|
||||
const recordEstimated = h.record === null;
|
||||
const record = -(h.record ?? known.reduce((a, b) => a + b, 0) / known.length);
|
||||
|
||||
const disq = h.form.filter((r) => r === 'D').length;
|
||||
const poor = h.form.filter((r) => r !== 'D' && r >= 7).length;
|
||||
const pFail = Math.min(0.5, 0.03 + 0.1 * disq + 0.03 * poor);
|
||||
|
||||
return { form, trial, record, draw: DRAW_SCORE[h.n], market: -Math.log(h.trial.odds), pFail, recordEstimated };
|
||||
}
|
||||
|
||||
function zscores(values: number[]): number[] {
|
||||
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
||||
const sd = Math.sqrt(values.reduce((s, v) => s + (v - mean) ** 2, 0) / values.length);
|
||||
return values.map((v) => (sd === 0 ? 0 : (v - mean) / sd));
|
||||
}
|
||||
|
||||
export type Prediction = {
|
||||
horse: Horse;
|
||||
features: Features;
|
||||
/** bijdrage per feature aan de logit (gewicht × z × schaal) */
|
||||
contributions: Record<FeatureKey, number>;
|
||||
riskPenalty: number;
|
||||
logit: number;
|
||||
p: number;
|
||||
};
|
||||
|
||||
export function predict(runners: Horse[], w: Weights): Prediction[] {
|
||||
if (runners.length === 0) return [];
|
||||
const feats = runners.map((h) => features(h, runners, w));
|
||||
const z = Object.fromEntries(featureKeys.map((k) => [k, zscores(feats.map((f) => f[k]))])) as Record<FeatureKey, number[]>;
|
||||
|
||||
const rows = runners.map((horse, i) => {
|
||||
const contributions = Object.fromEntries(
|
||||
featureKeys.map((k) => [k, w[k] * z[k][i] * SCALE])
|
||||
) as Record<FeatureKey, number>;
|
||||
const riskPenalty = w.risk * Math.log(1 - feats[i].pFail);
|
||||
const logit = Object.values(contributions).reduce((a, b) => a + b, 0) + riskPenalty;
|
||||
return { horse, features: feats[i], contributions, riskPenalty, logit, p: 0 };
|
||||
});
|
||||
|
||||
const max = Math.max(...rows.map((r) => r.logit));
|
||||
const exp = rows.map((r) => Math.exp((r.logit - max) / w.temperature));
|
||||
const total = exp.reduce((a, b) => a + b, 0);
|
||||
rows.forEach((r, i) => (r.p = exp[i] / total));
|
||||
return rows.sort((a, b) => b.p - a.p);
|
||||
}
|
||||
|
||||
export type Placings = {
|
||||
/** kans op 1e, 2e, 3e per paard (index = positie in `preds`) */
|
||||
top: { win: number; place2: number; place3: number }[];
|
||||
exacta: { a: number; b: number; p: number }[];
|
||||
koppel: { a: number; b: number; p: number }[];
|
||||
trio: { a: number; b: number; c: number; p: number }[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Exacte Plackett–Luce-uitwerking over alle geordende drietallen, met
|
||||
* Stern-demping: voor de 2e en 3e plaats wordt p^lambda gebruikt, wat de
|
||||
* neiging van pure PL corrigeert om de favoriet te vaak tweede te laten worden.
|
||||
*/
|
||||
export function placings(preds: { p: number }[], lambda: number): Placings {
|
||||
const n = preds.length;
|
||||
const p1 = preds.map((r) => r.p);
|
||||
const p2 = p1.map((p) => Math.pow(p, lambda));
|
||||
const s2 = p2.reduce((a, b) => a + b, 0);
|
||||
|
||||
const top = preds.map(() => ({ win: 0, place2: 0, place3: 0 }));
|
||||
const exacta: Placings['exacta'] = [];
|
||||
const trio: Placings['trio'] = [];
|
||||
|
||||
for (let a = 0; a < n; a++) {
|
||||
top[a].win = p1[a];
|
||||
for (let b = 0; b < n; b++) {
|
||||
if (b === a) continue;
|
||||
const pab = p1[a] * (p2[b] / (s2 - p2[a]));
|
||||
exacta.push({ a, b, p: pab });
|
||||
top[b].place2 += pab;
|
||||
for (let c = 0; c < n; c++) {
|
||||
if (c === a || c === b) continue;
|
||||
const pabc = pab * (p2[c] / (s2 - p2[a] - p2[b]));
|
||||
trio.push({ a, b, c, p: pabc });
|
||||
top[c].place3 += pabc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const koppelMap = new Map<string, { a: number; b: number; p: number }>();
|
||||
for (const e of exacta) {
|
||||
const [a, b] = e.a < e.b ? [e.a, e.b] : [e.b, e.a];
|
||||
const key = `${a}-${b}`;
|
||||
const cur = koppelMap.get(key) ?? { a, b, p: 0 };
|
||||
cur.p += e.p;
|
||||
koppelMap.set(key, cur);
|
||||
}
|
||||
|
||||
const desc = <T extends { p: number }>(xs: T[]) => xs.sort((x, y) => y.p - x.p);
|
||||
return { top, exacta: desc(exacta), koppel: desc([...koppelMap.values()]), trio: desc(trio) };
|
||||
}
|
||||
|
||||
/** verwachte waarde per euro en Kelly-fractie voor een winweddenschap */
|
||||
export function betValue(p: number, odds: number) {
|
||||
const edge = p * odds - 1;
|
||||
const kelly = odds > 1 ? Math.max(0, edge / (odds - 1)) : 0;
|
||||
return { edge, kelly };
|
||||
}
|
||||
|
||||
export const fairOdds = (p: number) => (p > 0 ? 1 / p : Infinity);
|
||||
|
||||
export function formatTime(seconds: number) {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds - m * 60;
|
||||
return `${m}'${s.toFixed(1).padStart(4, '0').replace('.', '"')}`;
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
// Oefenrondes: verzonnen toto-noteringen om de pagina te leren lezen vóór de echte koers.
|
||||
|
||||
export type Scenario = {
|
||||
key: string;
|
||||
title: string;
|
||||
story: string;
|
||||
lesson: string;
|
||||
scratched: number[];
|
||||
/** win-noteringen per startnummer; ontbreekt = wordt uit de modelkans afgeleid */
|
||||
odds?: Record<number, number>;
|
||||
/** plaatsnoteringen per startnummer */
|
||||
place?: Record<number, number>;
|
||||
};
|
||||
|
||||
export const scenarios: Scenario[] = [
|
||||
{
|
||||
key: 'overladen',
|
||||
title: 'De favoriet wordt overladen',
|
||||
story: 'Iedereen op de baan zet op Rolleman Flevo. Zijn notering zakt naar 1,20 en de rest wordt duur.',
|
||||
lesson:
|
||||
'Rolleman winnend is rood: bij 1,20 moet hij meer dan 83% van de tijd winnen om quitte te spelen, en zo zeker is zelfs een ongeslagen paard niet. De waarde zit bij plaatsweddenschappen op paarden die de toto vergeet.',
|
||||
scratched: [],
|
||||
odds: { 5: 1.2, 3: 16, 2: 11, 4: 28, 7: 22, 6: 14, 1: 35, 9: 70, 10: 90, 8: 120 },
|
||||
place: { 3: 3.2, 2: 2.6, 7: 4.5, 4: 5 }
|
||||
},
|
||||
{
|
||||
key: 'eens',
|
||||
title: 'De toto is het eens met het model',
|
||||
story: 'De noteringen liggen precies in lijn met de kansen van het model, met de gebruikelijke marge van 20% erop.',
|
||||
lesson:
|
||||
'Alles is rood. Als de kansen kloppen, wint de marge van de toto altijd. Niet wedden is dan de juiste zet — en dat is een prima uitkomst van een middag kijken.',
|
||||
scratched: []
|
||||
},
|
||||
{
|
||||
key: 'nietstarter',
|
||||
title: 'Rolleman Flevo start niet',
|
||||
story: 'Vlak voor de start wordt nummer 5 teruggetrokken. De toto moet zich in een paar minuten herstellen.',
|
||||
lesson:
|
||||
'Zonder de favoriet worden de kansen herverdeeld en worden Rising Sun Boko en Rose Starlake de favorieten. Vink een niet-starter altijd meteen uit, anders klopt geen enkel getal meer.',
|
||||
scratched: [5],
|
||||
odds: { 3: 3.5, 2: 3.2, 4: 7, 7: 6, 6: 5, 1: 12, 9: 25, 10: 30, 8: 40 },
|
||||
place: { 3: 1.6, 2: 1.5, 7: 2.2 }
|
||||
}
|
||||
];
|
||||
@@ -1,128 +0,0 @@
|
||||
// Duindigt · zondag 20 september 2026 · koersen 3, 4 en 5
|
||||
// Bron: ZEturf koerskaarten en paardenfiches (laatste starts, kilometertijden, prijzengeld).
|
||||
|
||||
import type { Result } from './data';
|
||||
|
||||
export type Runner = {
|
||||
n: number;
|
||||
name: string;
|
||||
driver: string;
|
||||
trainer: string;
|
||||
sex: 'M' | 'H' | 'R';
|
||||
age: number;
|
||||
/** laatste vijf uitslagen, nieuwste eerst; D = gediskwalificeerd/gevallen; leeg = debutant */
|
||||
form: Result[];
|
||||
/** kilometertijden (s) van de laatste starts, nieuwste eerst; alleen bekende */
|
||||
times: number[];
|
||||
/** prijzengeld (€) van de laatste starts, nieuwste eerst: niveau waarop het paard uitkomt */
|
||||
purses: number[];
|
||||
/** beste kilometertijd ooit, null = onbekend */
|
||||
record: number | null;
|
||||
/** gedragen gewicht (alleen rensport) */
|
||||
kg?: number;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type Race = {
|
||||
slug: string;
|
||||
number: number;
|
||||
name: string;
|
||||
short: string;
|
||||
time: string;
|
||||
distance: number;
|
||||
start: string;
|
||||
purse: number;
|
||||
surface: string;
|
||||
discipline: 'draf' | 'ren';
|
||||
conditions: string;
|
||||
/** voorbeschouwing ZEturf (Kees Nakoersen) */
|
||||
preview: string;
|
||||
tips: number[];
|
||||
runners: Runner[];
|
||||
sources: { label: string; href: string }[];
|
||||
};
|
||||
|
||||
const t = (m: number, s: number) => m * 60 + s;
|
||||
const ze = (c: number, slug: string) => `https://www.zeturf.be/nl/course-du-jour/2026-09-20/R14C${c}-duindigt-${slug}`;
|
||||
|
||||
export const races: Race[] = [
|
||||
{
|
||||
slug: 'sheikh-mansour',
|
||||
number: 3,
|
||||
name: 'HH Sheikh Mansour bin Zayed Al Nahyan Prijs',
|
||||
short: 'Sheikh Mansour Prijs',
|
||||
time: '16:10',
|
||||
distance: 1900,
|
||||
start: 'Startboxen',
|
||||
purse: 10000,
|
||||
surface: 'Grasbaan · standaard',
|
||||
discipline: 'ren',
|
||||
conditions: 'Ren voor Arabische volbloeds, 4 jaar en ouder',
|
||||
preview:
|
||||
'Tex D.A. (3) kan mogelijk op herhaling op deze baan waar hij op 26 juli sterk won voor tweede Ektor D.A. (6) en met als vierde Orszana JR (5). Maar de ruin treft in El Zarka D.A. (1) en Farida P (2) twee sterke tegenstanders. Beide bleven weliswaar op iets meer dan 8 lengte op deze baan ongeplaatst, maar dat was tegen de top van Europa. Princess T (4) versloeg Tex D.A. met een neusje verschil op de grasbaan van Joure, dus ook deze merrie moeten we serieus nemen. Een koers waarin veel mogelijk is.',
|
||||
tips: [3, 1, 2, 6],
|
||||
sources: [{ label: 'ZEturf koerskaart', href: ze(3, 'hh-sheikh-mansour-bin-zayed-al-nahyan-prijs') }],
|
||||
runners: [
|
||||
{ n: 1, name: 'El Zarka D.A.', driver: 'Jurico Bogaerts', trainer: 'Johan Verstrepen', sex: 'R', age: 12, form: [7, 9, 5, 5, 3], times: [], purses: [250000, 100000, 26400, 92764, 6000], record: null, kg: 64, note: 'loopt tegen de Europese top' },
|
||||
{ n: 2, name: 'Farida P', driver: 'Anne Engels', trainer: 'Peter Deckers', sex: 'M', age: 7, form: ['D', 10, 3, 3, 1], times: [], purses: [250000, 100000, 26400, 92764, 6000], record: null, kg: 62, note: 'D = gevallen in Waregem' },
|
||||
{ n: 3, name: 'Tex D.A.', driver: 'Luca van Toor', trainer: 'Bianca Dunk', sex: 'R', age: 9, form: [10, 1, 2, 2, 2], times: [], purses: [250000, 5000, 4000, 6000, 6000], record: null, kg: 61, note: 'won 26 juli op Duindigt' },
|
||||
{ n: 4, name: 'Princess T', driver: 'Mirko Sanna', trainer: 'Gerard Zoetelief', sex: 'M', age: 6, form: [1, 4, 3, 5, 4], times: [], purses: [4000, 6000, 4000, 6000, 6000], record: null, kg: 57, note: 'versloeg Tex D.A. in Joure' },
|
||||
{ n: 5, name: 'Orszana JR', driver: 'Josephine Bruning', trainer: "Manouk van 't Slot", sex: 'M', age: 8, form: [4, 4, 4, 7], times: [], purses: [5000, 4000, 4200, 5100], record: null, kg: 56 },
|
||||
{ n: 6, name: 'Ektor D.A.', driver: 'Siem Bos', trainer: 'Johan Verstrepen', sex: 'H', age: 4, form: [3, 2, 5, 3, 7], times: [], purses: [6000, 5000, 6000, 6000, 7000], record: null, kg: 55 }
|
||||
]
|
||||
},
|
||||
{
|
||||
slug: 'schieweg',
|
||||
number: 4,
|
||||
name: 'Handelsonderneming Schieweg B.V. Prijs',
|
||||
short: 'Schieweg Prijs',
|
||||
time: '16:40',
|
||||
distance: 2000,
|
||||
start: 'Autostart',
|
||||
purse: 4750,
|
||||
surface: 'Sintelbaan · goed',
|
||||
discipline: 'draf',
|
||||
conditions: 'Draverij voor 4 jaar en ouder, winsom lager dan € 18.000',
|
||||
preview:
|
||||
'Paradepaard van Duindigt 2025 Louis Vuiton (7) wordt na deze koers gehuldigd, maar of dat als winnaar is staat niet vast. Pasion d’Oro (4) is waarschijnlijk het te kloppen paard: de hengst van Jacky Mollema verkeert in topvorm. Michel D (6) is eveneens in grote vorm, won tweemaal in Duitsland maar nog niet op Duindigt, al zat hij daar steeds dichtbij. Nobell Starro (5) werd in zijn laatste koers vierde op één lijn met tweede aankomende Louis Vuiton. Nolana Boko (8) won dit jaar al driemaal, laatst in Alkmaar. Otini Bianco (2) is hoogstwaarschijnlijk het Paradepaard van Duindigt 2026; met een juiste koers is de ruin er vandaag ook bij.',
|
||||
tips: [4, 7, 6, 5],
|
||||
sources: [{ label: 'ZEturf koerskaart', href: ze(4, 'handelsonderneming-schieweg-b-v-prijs') }],
|
||||
runners: [
|
||||
{ n: 1, name: 'Puck Ballaco', driver: 'Micha Brouwer', trainer: 'Rob de Vlieger', sex: 'M', age: 4, form: [4, 4, 1, 4, 3], times: [t(1, 17.9), t(1, 16.3), t(1, 16.6), t(1, 16.5), t(1, 17.8)], purses: [2750, 4000, 4000, 4400, 1100], record: t(1, 13.0) },
|
||||
{ n: 2, name: 'Otini Bianco', driver: 'Rob de Vlieger', trainer: 'Rob de Vlieger', sex: 'R', age: 5, form: ['D', 6, 1, 2, 'D'], times: [t(1, 15.5), t(1, 15.9), t(1, 16.4)], purses: [2750, 4400, 2750, 2750, 2750], record: t(1, 14.7), note: 'won 31 juli op Duindigt' },
|
||||
{ n: 3, name: 'Memory de Legume', driver: 'Patrick de Haan', trainer: 'Patrick de Haan', sex: 'M', age: 7, form: [6, 7, 8, 4, 4], times: [t(1, 19.7), t(1, 16.9), t(1, 15.6), t(1, 18.2), t(1, 14.1)], purses: [1600, 4400, 4400, 1500, 2200], record: t(1, 14.1) },
|
||||
{ n: 4, name: "Pasion d'Oro", driver: 'Jaap van Rijn', trainer: 'Jack Mollema', sex: 'H', age: 4, form: [1, 5, 1, 1, 5], times: [t(1, 16.6), t(1, 11.6), t(1, 12.2), t(1, 21.1), t(1, 14.0)], purses: [2750, 19567, 2200, 1650, 13806], record: t(1, 11.6), note: 'liep in Zweden tegen sterkere' },
|
||||
{ n: 5, name: 'Nobell Starro', driver: 'Robin Bakker', trainer: 'Fons Bakker', sex: 'R', age: 6, form: [4, 2, 1, 2, 'D'], times: [t(1, 14.0), t(1, 16.2), t(1, 11.1)], purses: [2750, 5000, 2000, 2200, 4400], record: t(1, 11.1) },
|
||||
{ n: 6, name: 'Michel D', driver: 'Jim Veldman', trainer: 'Huug van Dijk', sex: 'H', age: 7, form: [3, 2, 1, 2, 1], times: [t(1, 20.2), t(1, 15.6), t(1, 14.1), t(1, 16.9), t(1, 14.9)], purses: [5000, 2750, 2000, 5500, 1500], record: t(1, 13.8), note: 'laatste tijd over 3375 m' },
|
||||
{ n: 7, name: 'Louis Vuiton', driver: 'Hugo Langeweg', trainer: 'Hugo Langeweg', sex: 'R', age: 8, form: [2, 5, 4, 'D', 1], times: [t(1, 17.8), t(1, 12.1), t(1, 16.9)], purses: [2750, 11000, 2200, 1650, 2750], record: t(1, 12.1), note: 'Paradepaard 2025' },
|
||||
{ n: 8, name: 'Nolana Boko', driver: 'John Dekker', trainer: 'Carel Roodenrijs', sex: 'R', age: 6, form: [1, 'D', 5, 4, 4], times: [t(1, 19.1), t(1, 20.9), t(1, 13.9), t(1, 23.0)], purses: [1600, 2750, 1100, 2000, 1210], record: t(1, 13.4), note: 'vaak op tragere banen (Alkmaar)' }
|
||||
]
|
||||
},
|
||||
{
|
||||
slug: 'south-holland',
|
||||
number: 5,
|
||||
name: 'South Holland Prijs · TCT Tweejarigen Satellietkoers',
|
||||
short: 'South Holland Prijs',
|
||||
time: '17:10',
|
||||
distance: 1609,
|
||||
start: 'Autostart',
|
||||
purse: 4000,
|
||||
surface: 'Sintelbaan · goed',
|
||||
discipline: 'draf',
|
||||
conditions: 'Draverij voor tweejarigen (sprint, 1609 m)',
|
||||
preview:
|
||||
'Sisi Hazelaar (5) is op basis van haar laatste tweede plaats in Wolvega, waar ze een puike indruk maakte, kansrijk. Zo ook Seabiscuit Lobell (4), die vorige week bij haar debuut tweede werd achter Salina Meerswal; met een fijn stuk speed kan ze binnendoor plek twee veroveren. Spirit Meerswal (2) debuteerde met een derde plaats in Wolvega in 15,2 en is kanshebber nummer drie. Sophie Starlake (1) debuteert voor de stal van Dion Tesselaar, en dan zijn we sowieso gewaarschuwd.',
|
||||
tips: [5, 4, 2, 1],
|
||||
sources: [{ label: 'ZEturf koerskaart', href: ze(5, 'south-holland-prijs-tct-tweejarigen-satellietkoers') }],
|
||||
runners: [
|
||||
{ n: 1, name: 'Sophie Starlake', driver: 'Dion Tesselaar', trainer: 'Dion Tesselaar', sex: 'M', age: 2, form: [], times: [], purses: [], record: null, note: 'debutant · stal Tesselaar' },
|
||||
{ n: 2, name: 'Spirit Meerswal', driver: 'Jaap van Rijn', trainer: 'Jack Mollema', sex: 'H', age: 2, form: [3], times: [t(1, 15.2)], purses: [5500], record: t(1, 15.2) },
|
||||
{ n: 3, name: 'Shiva Jabi', driver: 'Jan Baas', trainer: 'Jan Baas', sex: 'H', age: 2, form: ['D', 'D'], times: [], purses: [3300, 5500], record: null, note: 'twee keer uit de draf' },
|
||||
{ n: 4, name: 'Seabiscuit Lobell', driver: 'Hugo Langeweg', trainer: 'Hugo Langeweg', sex: 'M', age: 2, form: [2], times: [t(1, 15.2)], purses: [3300], record: t(1, 15.2), note: '2e bij debuut, 10 september' },
|
||||
{ n: 5, name: 'Sisi Hazelaar', driver: 'Jeffrey Mieras', trainer: 'Dion Tesselaar', sex: 'M', age: 2, form: [2, 'D'], times: [t(1, 15.0)], purses: [5500, 5500], record: t(1, 15.0) },
|
||||
{ n: 6, name: 'Sir Bianco', driver: 'Frans van der Blonk', trainer: 'Frans van der Blonk', sex: 'R', age: 2, form: [], times: [], purses: [], record: null, note: 'debutant' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const raceBySlug = (slug: string) => races.find((r) => r.slug === slug);
|
||||
@@ -1,10 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { distanceMeters } from './geo';
|
||||
|
||||
describe('distanceMeters', () => {
|
||||
it('keeps nearby sightings within the 30 km limit and rejects distant ones', () => {
|
||||
expect(distanceMeters(52.009, 4.708, 52.009, 4.708)).toBe(0);
|
||||
expect(distanceMeters(52.009, 4.708, 52.01, 4.71)).toBeLessThan(30_000);
|
||||
expect(distanceMeters(52.009, 4.708, 52.37, 4.9)).toBeGreaterThan(30_000);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
const EARTH_RADIUS_METERS = 6_371_000;
|
||||
|
||||
export function distanceMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||
const toRad = (degrees: number) => (degrees * Math.PI) / 180;
|
||||
const deltaLat = toRad(lat2 - lat1);
|
||||
const deltaLng = toRad(lng2 - lng1);
|
||||
const a =
|
||||
Math.sin(deltaLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(deltaLng / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_METERS * Math.asin(Math.sqrt(a));
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<script lang="ts">
|
||||
let { src, class: className = '' }: { src?: string; class?: string } = $props();
|
||||
let failed = $state(false);
|
||||
$effect(() => {
|
||||
void src;
|
||||
failed = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class={`overflow-hidden rounded bg-gray-200 ${className}`}>
|
||||
{#if src?.startsWith('https://') && !failed}<img
|
||||
{src}
|
||||
alt=""
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
onerror={() => (failed = true)}
|
||||
/>{:else}<div
|
||||
class="flex h-full items-center justify-center text-2xl text-gray-500"
|
||||
aria-hidden="true"
|
||||
>
|
||||
♫
|
||||
</div>{/if}
|
||||
</div>
|
||||
@@ -1,26 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { ScoredBeatmapset } from './types';
|
||||
import Artwork from './Artwork.svelte';
|
||||
let { map }: { map: ScoredBeatmapset } = $props();
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={`https://osu.ppy.sh/beatmapsets/${map.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block h-full overflow-hidden rounded border border-gray-300 hover:border-blue-600 focus-visible:outline-2 focus-visible:outline-blue-600"
|
||||
>
|
||||
<Artwork src={map.covers?.card} class="h-24 rounded-none" />
|
||||
<div class="space-y-1 p-3">
|
||||
<h4 class="font-semibold">
|
||||
{map.artist_unicode || map.artist} – {map.title_unicode || map.title}
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600">{map.score}/100 · {map.confidence} confidence</p>
|
||||
<p class="text-xs text-gray-600">{map.reasons.join(' · ')}</p>
|
||||
{#if map.status || map.beatmaps}<p class="text-xs text-gray-600">
|
||||
{map.status ?? ''}{map.status && map.beatmaps ? ' · ' : ''}{map.beatmaps
|
||||
? `${map.beatmaps.length} difficulties`
|
||||
: ''}
|
||||
</p>{/if}
|
||||
</div>
|
||||
</a>
|
||||
@@ -1,90 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseSpotifyInput } from './input';
|
||||
import { normalize, cleanTitle, scoreBeatmapset, rankCandidates, MAX_CANDIDATES } from './matching';
|
||||
import type { SpotifyTrack } from './types';
|
||||
const id = '6PUzxtIHkv346yP89NzP9X';
|
||||
const track: SpotifyTrack = {
|
||||
id,
|
||||
name: 'Song Title',
|
||||
artists: [{ name: 'Beyoncé & Friends' }],
|
||||
album: { name: 'Album', images: [] }
|
||||
};
|
||||
const map = { id: 1, title: 'Song Title', artist: 'Beyonce and Friends' };
|
||||
describe('Spotify input', () => {
|
||||
it.each([
|
||||
`https://open.spotify.com/track/${id}`,
|
||||
` https://open.spotify.com/track/${id}?si=abc `,
|
||||
`http://www.open.spotify.com/track/${id}/`,
|
||||
`https://open.spotify.com/intl-nl/track/${id}/?si=abc`,
|
||||
`spotify:track:${id}`
|
||||
])('parses %s', (value) => expect(parseSpotifyInput(value)).toEqual({ kind: 'track', id }));
|
||||
it.each([`spotify:playlist:${id}`, `https://open.spotify.com/playlist/${id}?si=abc`])(
|
||||
'parses playlist %s',
|
||||
(value) => expect(parseSpotifyInput(value)).toEqual({ kind: 'playlist', id })
|
||||
);
|
||||
it.each(['artist', 'episode', 'show'])('rejects unsupported %s', (resource) =>
|
||||
expect(parseSpotifyInput(`https://open.spotify.com/${resource}/${id}`).kind).toBe('unsupported')
|
||||
);
|
||||
it.each([
|
||||
'',
|
||||
' ',
|
||||
'spotify:track:bad',
|
||||
'https://open.spotify.com/track/',
|
||||
'https://open.spotify.com/track/bad',
|
||||
'x'.repeat(501)
|
||||
])('validates %s', (value) => expect(() => parseSpotifyInput(value)).toThrow());
|
||||
it('preserves plain search and rejects lookalike host recognition', () => {
|
||||
expect(parseSpotifyInput(' Deftones Change ')).toEqual({
|
||||
kind: 'search',
|
||||
query: 'Deftones Change'
|
||||
});
|
||||
expect(parseSpotifyInput(`https://open.spotify.com.evil.test/track/${id}`).kind).toBe('search');
|
||||
});
|
||||
});
|
||||
describe('matching', () => {
|
||||
it.each([
|
||||
'Song Title',
|
||||
'Song Title (Remastered 2011)',
|
||||
'Song Title - Remaster',
|
||||
'Song Title (feat. Someone)',
|
||||
'Song Title [Radio Edit]',
|
||||
'Song Title - Album Version'
|
||||
])('cleans %s', (value) => expect(cleanTitle(value)).toBe('song title'));
|
||||
it('normalizes punctuation, diacritics, ampersands and whitespace', () =>
|
||||
expect(normalize(' Beyoncé & Friends! ')).toBe('beyonce and friends'));
|
||||
it('keeps non-Latin text', () => expect(normalize('世界の歌')).toBe('世界の歌'));
|
||||
it('scores exact and unicode aliases highly', () => {
|
||||
expect(scoreBeatmapset(track, map).score).toBe(100);
|
||||
expect(
|
||||
scoreBeatmapset(
|
||||
{ ...track, name: '世界の歌' },
|
||||
{ ...map, title: 'Sekai no uta', title_unicode: '世界の歌' }
|
||||
).confidence
|
||||
).toBe('high');
|
||||
});
|
||||
it('does not confidently match the wrong artist', () =>
|
||||
expect(scoreBeatmapset(track, { ...map, artist: 'Unrelated Singer' }).confidence).toBe('low'));
|
||||
it('penalizes remix mismatch', () =>
|
||||
expect(scoreBeatmapset(track, { ...map, title: 'Song Title (Club Remix)' }).confidence).toBe(
|
||||
'low'
|
||||
));
|
||||
it('sorts, merges duplicate IDs and limits results', () => {
|
||||
const candidates = rankCandidates(track, [
|
||||
{ ...map, title: 'Song Title remix' },
|
||||
...Array.from({ length: 10 }, (_, id) => ({ ...map, id }))
|
||||
]);
|
||||
expect(candidates).toHaveLength(MAX_CANDIDATES);
|
||||
expect(new Set(candidates.map((m) => m.id)).size).toBe(MAX_CANDIDATES);
|
||||
expect(candidates[0].score).toBe(100);
|
||||
expect(candidates.every((m, i) => !i || candidates[i - 1].score >= m.score)).toBe(true);
|
||||
});
|
||||
it('discards unrelated candidates', () =>
|
||||
expect(
|
||||
rankCandidates(track, [{ ...map, title: 'Nothing Alike', artist: 'Different' }])
|
||||
).toEqual([]));
|
||||
});
|
||||
|
||||
it.each([`spotify:album:${id}`, `https://www.open.spotify.com/intl-nl/album/${id}/?si=abc`])(
|
||||
'parses album %s',
|
||||
(value) => expect(parseSpotifyInput(value)).toEqual({ kind: 'album', id })
|
||||
);
|
||||
@@ -1,44 +0,0 @@
|
||||
export type SpotifyInput =
|
||||
| { kind: 'track'; id: string }
|
||||
| { kind: 'playlist'; id: string }
|
||||
| { kind: 'album'; id: string }
|
||||
| { kind: 'search'; query: string }
|
||||
| { kind: 'unsupported'; resource: string };
|
||||
export function parseSpotifyInput(value: string): SpotifyInput {
|
||||
const query = value.trim();
|
||||
if (!query || query.length > 500)
|
||||
throw new Error('Enter a Spotify URL, URI, or song search (up to 500 characters).');
|
||||
let resource: string | undefined;
|
||||
let id: string | undefined;
|
||||
if (/^spotify:/i.test(query)) {
|
||||
const match = /^spotify:([^:]+):([^:]+)$/i.exec(query);
|
||||
if (!match)
|
||||
throw new Error(
|
||||
'Invalid Spotify URI. Use spotify:track:ID or spotify:playlist:ID or spotify:album:ID.'
|
||||
);
|
||||
[, resource, id] = match;
|
||||
} else {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(query);
|
||||
} catch {
|
||||
if (/^(?:https?:\/\/)?(?:www\.)?open\.spotify\.com\b/i.test(query))
|
||||
throw new Error('Invalid Spotify URL. Include https://open.spotify.com/.');
|
||||
return { kind: 'search', query };
|
||||
}
|
||||
if (!/^(www\.)?open\.spotify\.com$/i.test(url.hostname)) return { kind: 'search', query };
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.port)
|
||||
throw new Error('Invalid Spotify URL.');
|
||||
const match = /^\/(?:intl-[a-z-]+\/)?([^/]+)(?:\/([^/]+))?\/?$/i.exec(url.pathname);
|
||||
if (!match) throw new Error('Invalid Spotify URL path.');
|
||||
[, resource, id] = match;
|
||||
}
|
||||
resource = resource.toLowerCase();
|
||||
if (resource !== 'track' && resource !== 'playlist' && resource !== 'album')
|
||||
return { kind: 'unsupported', resource };
|
||||
if (!id || !/^[a-zA-Z0-9]{22}$/.test(id))
|
||||
throw new Error(
|
||||
'Invalid Spotify ID. Track, playlist, and album IDs must contain 22 letters or digits.'
|
||||
);
|
||||
return { kind: resource, id };
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import type { OsuBeatmapset, ScoredBeatmapset, SpotifyTrack } from './types';
|
||||
// Medium requires a strong title plus artist evidence. High is reserved for near-exact pairs.
|
||||
export const HIGH_CONFIDENCE = 88;
|
||||
export const MEDIUM_CONFIDENCE = 72;
|
||||
export const MIN_CANDIDATE_SCORE = 40;
|
||||
export const MAX_CANDIDATES = 6;
|
||||
export function normalize(value: string): string {
|
||||
return value
|
||||
.normalize('NFKD')
|
||||
.replace(/\p{M}/gu, '')
|
||||
.toLowerCase()
|
||||
.replace(/&/g, ' and ')
|
||||
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
export function cleanTitle(value: string): string {
|
||||
return normalize(
|
||||
value
|
||||
.replace(
|
||||
/\s*[([](?:feat\.?|ft\.?|featuring|[^)\]]*(?:remaster|radio edit|album version|soundtrack|from the))[^)\]]*[)\]]/gi,
|
||||
''
|
||||
)
|
||||
.replace(/\s+(?:feat\.?|ft\.?|featuring)\s+.*$/i, '')
|
||||
.replace(/\s*[-–—]\s*(?:\d{4}\s*)?(?:remaster\w*|radio edit|album version|from\s+).*$/i, '')
|
||||
);
|
||||
}
|
||||
function similarity(a: string, b: string): number {
|
||||
if (!a || !b) return 0;
|
||||
if (a === b) return 1;
|
||||
const left = new Set(a.split(' ')),
|
||||
right = new Set(b.split(' '));
|
||||
const overlap = [...left].filter((token) => right.has(token)).length;
|
||||
return Math.max(
|
||||
(2 * overlap) / (left.size + right.size),
|
||||
a.length >= 5 && b.length >= 5 && (a.includes(b) || b.includes(a)) ? 0.8 : 0
|
||||
);
|
||||
}
|
||||
function versions(value: string): string {
|
||||
const normalized = normalize(value);
|
||||
return ['remix', 'live', 'acoustic', 'instrumental', 'sped up', 'nightcore']
|
||||
.filter((word) => normalized.includes(word))
|
||||
.join(' ');
|
||||
}
|
||||
export function scoreBeatmapset(track: SpotifyTrack, map: OsuBeatmapset): ScoredBeatmapset {
|
||||
const title = Math.max(
|
||||
...[map.title, map.title_unicode ?? ''].map((v) =>
|
||||
similarity(cleanTitle(track.name), cleanTitle(v))
|
||||
)
|
||||
);
|
||||
const artistNames = [
|
||||
...track.artists.map((a) => a.name),
|
||||
track.artists.map((a) => a.name).join(' and ')
|
||||
];
|
||||
const artist = Math.max(
|
||||
0,
|
||||
...artistNames.flatMap((a) =>
|
||||
[map.artist, map.artist_unicode ?? ''].map((b) => similarity(normalize(a), normalize(b)))
|
||||
)
|
||||
);
|
||||
const mismatch = versions(track.name) !== versions(map.title + ' ' + (map.title_unicode ?? ''));
|
||||
let score = Math.round(title * 65 + artist * 35 - (mismatch ? 24 : 0));
|
||||
if (artist < 0.3) score = Math.min(score, MEDIUM_CONFIDENCE - 1);
|
||||
score = Math.max(0, score);
|
||||
const reasons = [
|
||||
title === 1 ? 'title exact' : 'title similar',
|
||||
artist === 1 ? 'artist exact' : artist >= 0.3 ? 'artist similar' : 'artist differs'
|
||||
];
|
||||
if (mismatch) reasons.push('version differs');
|
||||
return {
|
||||
...map,
|
||||
score,
|
||||
confidence: score >= HIGH_CONFIDENCE ? 'high' : score >= MEDIUM_CONFIDENCE ? 'medium' : 'low',
|
||||
reasons
|
||||
};
|
||||
}
|
||||
export function rankCandidates(track: SpotifyTrack, maps: OsuBeatmapset[]): ScoredBeatmapset[] {
|
||||
const merged = new Map<number, ScoredBeatmapset>();
|
||||
for (const map of maps) {
|
||||
const scored = scoreBeatmapset(track, map);
|
||||
if (scored.score > (merged.get(map.id)?.score ?? -1)) merged.set(map.id, scored);
|
||||
}
|
||||
return [...merged.values()]
|
||||
.filter((m) => m.score >= MIN_CANDIDATE_SCORE)
|
||||
.sort((a, b) => b.score - a.score || a.id - b.id)
|
||||
.slice(0, MAX_CANDIDATES);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
export type SpotifyTrack = {
|
||||
id: string;
|
||||
name: string;
|
||||
artists: { name: string }[];
|
||||
album: { name: string; images: { url: string }[] };
|
||||
};
|
||||
export type SpotifyCollection = {
|
||||
kind: 'playlist' | 'album';
|
||||
id: string;
|
||||
name: string;
|
||||
owner: string;
|
||||
image?: string;
|
||||
};
|
||||
export type OsuBeatmapset = {
|
||||
id: number;
|
||||
artist: string;
|
||||
title: string;
|
||||
artist_unicode?: string;
|
||||
title_unicode?: string;
|
||||
covers?: { card?: string; 'card@2x'?: string };
|
||||
status?: string;
|
||||
beatmaps?: { id: number }[];
|
||||
};
|
||||
export type ScoredBeatmapset = OsuBeatmapset & {
|
||||
score: number;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
reasons: string[];
|
||||
};
|
||||
export type TrackMatchResult = {
|
||||
track: SpotifyTrack;
|
||||
matches: ScoredBeatmapset[];
|
||||
status: 'matched' | 'no_match' | 'error';
|
||||
error?: string;
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { relativeTime } from './relative-time';
|
||||
|
||||
describe('relativeTime', () => {
|
||||
it('uses readable singular and plural dates', () => {
|
||||
const now = Date.UTC(2026, 8, 23);
|
||||
expect(relativeTime(new Date(now - 1000), now)).toBe('just now');
|
||||
expect(relativeTime(new Date(now - 1000 * 30), now)).toBe('30 seconds ago');
|
||||
expect(relativeTime(new Date(now - 1000 * 60 * 60), now)).toBe('an hour ago');
|
||||
expect(relativeTime(new Date(now - 1000 * 60 * 60 * 24 * 365), now)).toBe('a year ago');
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
export function relativeTime(date: Date | string, now: number) {
|
||||
const elapsed = Math.max(0, Math.floor((now - new Date(date).getTime()) / 1000));
|
||||
if (elapsed < 5) return 'just now';
|
||||
const units: [number, string][] = [
|
||||
[31_536_000, 'year'],
|
||||
[2_592_000, 'month'],
|
||||
[604_800, 'week'],
|
||||
[86_400, 'day'],
|
||||
[3_600, 'hour'],
|
||||
[60, 'minute'],
|
||||
[1, 'second']
|
||||
];
|
||||
for (const [seconds, unit] of units) {
|
||||
if (elapsed >= seconds) {
|
||||
const count = Math.floor(elapsed / seconds);
|
||||
return `${count === 1 ? (unit === 'hour' ? 'an' : 'a') : count} ${unit}${count === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
}
|
||||
return 'just now';
|
||||
}
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('en', { dateStyle: 'long', timeZone: 'UTC' });
|
||||
|
||||
export function calendarDate(date: Date | string) {
|
||||
return dateFormatter.format(new Date(date));
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getSeo } from './seo';
|
||||
|
||||
describe('public page metadata', () => {
|
||||
it('gives the homepage a descriptive title and production canonical URL', () => {
|
||||
const seo = getSeo('/');
|
||||
expect(seo.title).toBe('Projects, lists and writing | Stan Runge');
|
||||
expect(seo.url).toBe('https://stanrunge.dev/');
|
||||
expect(seo.indexable).toBe(true);
|
||||
expect(seo.image).toBe('https://stanrunge.dev/moon.jpg');
|
||||
});
|
||||
|
||||
it('uses article content and its latest edit time', () => {
|
||||
const article = getSeo('/blog/my-post', {
|
||||
blogArticle: {
|
||||
title: 'My post',
|
||||
content: 'A **specific** article about lists.',
|
||||
updatedAt: '2026-09-02T00:00:00.000Z'
|
||||
}
|
||||
});
|
||||
expect(article.title).toBe('My post | Stan Runge');
|
||||
expect(article.description).toBe('A specific article about lists.');
|
||||
expect(article.type).toBe('article');
|
||||
expect(article.modifiedAt).toBe('2026-09-02T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('describes a list using its contents', () => {
|
||||
const seo = getSeo('/lists/vinyls-to-collect', {
|
||||
list: {
|
||||
name: 'Vinyls to collect',
|
||||
isProgress: true,
|
||||
items: [{ name: 'Album one' }, { name: 'Album two' }]
|
||||
}
|
||||
});
|
||||
expect(seo.title).toBe('Vinyls to collect | Stan Runge');
|
||||
expect(seo.description).toContain('Album one, Album two');
|
||||
expect(seo.indexable).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps private and staging pages out of search results', () => {
|
||||
expect(getSeo('/login').indexable).toBe(false);
|
||||
expect(getSeo('/projects/animaldex/register').indexable).toBe(false);
|
||||
expect(
|
||||
getSeo('/blog/my-post/edit', {
|
||||
blogArticle: {
|
||||
title: 'My post',
|
||||
content: '',
|
||||
updatedAt: ''
|
||||
}
|
||||
}).indexable
|
||||
).toBe(false);
|
||||
expect(getSeo('/projects/catch-em-all', {}, 'STAGING').indexable).toBe(false);
|
||||
});
|
||||
});
|
||||
118
src/lib/seo.ts
118
src/lib/seo.ts
@@ -1,118 +0,0 @@
|
||||
import { formatTitle, getTitle, type SiteEnvironment } from './title';
|
||||
|
||||
export const SITE_ORIGIN = 'https://stanrunge.dev';
|
||||
|
||||
type Article = {
|
||||
title: string;
|
||||
content: string;
|
||||
updatedAt: Date | string;
|
||||
};
|
||||
type List = { name: string; isProgress: boolean; items: { name: string }[] };
|
||||
type Race = { short: string; distance: number; discipline: string };
|
||||
type PageContent = { blogArticle?: Article; list?: List; race?: Race };
|
||||
|
||||
type Seo = {
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
indexable: boolean;
|
||||
type: 'website' | 'article';
|
||||
image: string;
|
||||
imageAlt: string;
|
||||
modifiedAt?: string;
|
||||
};
|
||||
|
||||
const publicPages: Record<string, { title: string; description: string }> = {
|
||||
'/': {
|
||||
title: 'Projects, lists and writing',
|
||||
description: 'Stan Runge’s projects, lists, writing and experience.'
|
||||
},
|
||||
'/projects': {
|
||||
title: 'Projects',
|
||||
description: 'Software projects by Stan Runge, including Catchemall, Animaldex and Derby Lens.'
|
||||
},
|
||||
'/blog': { title: 'Blog', description: 'Articles and notes by Stan Runge.' },
|
||||
'/lists': { title: 'Lists', description: 'Lists collected and maintained by Stan Runge.' },
|
||||
'/projects/catch-em-all': {
|
||||
title: 'Catchemall',
|
||||
description: 'Track which Pokémon you have caught across 53 games and 1,025 Pokémon.'
|
||||
},
|
||||
'/projects/animaldex': {
|
||||
title: 'Animaldex',
|
||||
description: 'Find and add neighborhood animals on a map.'
|
||||
},
|
||||
'/projects/derby': {
|
||||
title: 'Derby Lens',
|
||||
description: 'Estimate horse racing odds and compare them with market prices.'
|
||||
},
|
||||
'/projects/spotify-beatmap-finder': {
|
||||
title: 'osu! map finder',
|
||||
description: 'Find osu! beatmaps for Spotify songs, albums and playlists.'
|
||||
}
|
||||
};
|
||||
|
||||
function excerpt(markdown: string): string {
|
||||
return markdown
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/[`*_>#~|]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 160);
|
||||
}
|
||||
|
||||
function date(value: Date | string): string | undefined {
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
|
||||
}
|
||||
|
||||
export function getSeo(
|
||||
pathname: string,
|
||||
content: PageContent = {},
|
||||
environment: SiteEnvironment = null
|
||||
): Seo {
|
||||
const path = pathname.replace(/\/$/, '') || '/';
|
||||
const url = new URL(path, SITE_ORIGIN).href;
|
||||
let page = publicPages[path];
|
||||
let type: Seo['type'] = 'website';
|
||||
let modifiedAt: string | undefined;
|
||||
|
||||
if (/^\/blog\/[^/]+$/.test(path) && content.blogArticle) {
|
||||
const article = content.blogArticle;
|
||||
page = {
|
||||
title: article.title,
|
||||
description: excerpt(article.content) || `Read ${article.title} by Stan Runge.`
|
||||
};
|
||||
type = 'article';
|
||||
modifiedAt = date(article.updatedAt);
|
||||
} else if (/^\/blog\/[^/]+\/edit$/.test(path) && content.blogArticle) {
|
||||
page = { title: `Editing ${content.blogArticle.title}`, description: 'Edit a blog article.' };
|
||||
} else if (/^\/lists\/[^/]+$/.test(path) && content.list) {
|
||||
const list = content.list;
|
||||
const names = list.items.slice(0, 3).map((item) => item.name);
|
||||
page = {
|
||||
title: list.name,
|
||||
description: names.length
|
||||
? `${list.name}: ${names.join(', ')}${list.items.length > names.length ? ', and more' : ''}.`
|
||||
: `${list.name}, a ${list.isProgress ? 'progress ' : ''}list by Stan Runge.`
|
||||
};
|
||||
} else if (/^\/projects\/derby\/[^/]+$/.test(path) && content.race) {
|
||||
const race = content.race;
|
||||
page = {
|
||||
title: `${race.short} · Derby Lens`,
|
||||
description: `Estimate odds for the ${race.short}, a ${race.distance} m ${race.discipline} race.`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: page ? formatTitle(page.title, environment) : getTitle(path, environment),
|
||||
description: page?.description ?? 'Stan Runge’s personal website.',
|
||||
url,
|
||||
indexable: !!page && !environment && !/^\/blog\/[^/]+\/edit$/.test(path),
|
||||
type,
|
||||
image: `${SITE_ORIGIN}/moon.jpg`,
|
||||
imageAlt: 'Moon icon for Stan Runge’s website',
|
||||
modifiedAt
|
||||
};
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { building, dev } from '$app/environment';
|
||||
import { betterAuth } from 'better-auth/minimal';
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||
import { genericOAuth } from 'better-auth/plugins';
|
||||
import { APIError, createAuthMiddleware } from 'better-auth/api';
|
||||
import { sveltekitCookies } from 'better-auth/svelte-kit';
|
||||
import { getRequestEvent } from '$app/server';
|
||||
import { db } from '$lib/server/db';
|
||||
import * as schema from '$lib/server/db/schema';
|
||||
import { osuProfile } from './osu-profile';
|
||||
export { isAdmin, requireAdmin, requireUser } from './authorization';
|
||||
|
||||
if (!building && (!env.BETTER_AUTH_SECRET || env.BETTER_AUTH_SECRET.length < 32)) {
|
||||
throw new Error('Set BETTER_AUTH_SECRET to a random secret of at least 32 characters');
|
||||
}
|
||||
if (!building && !dev && !env.BETTER_AUTH_URL && !env.ORIGIN) {
|
||||
throw new Error('Set BETTER_AUTH_URL to the public site origin');
|
||||
}
|
||||
export const authProviders = {
|
||||
google: !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET),
|
||||
osu: !!(env.OSU_CLIENT_ID && env.OSU_CLIENT_SECRET)
|
||||
};
|
||||
export const auth = betterAuth({
|
||||
baseURL: env.BETTER_AUTH_URL || env.ORIGIN || 'http://localhost:5173',
|
||||
secret: env.BETTER_AUTH_SECRET || 'build-only-placeholder-not-a-runtime-secret',
|
||||
database: drizzleAdapter(db, { provider: 'pg', schema }),
|
||||
emailAndPassword: { enabled: true, minPasswordLength: 12, maxPasswordLength: 128 },
|
||||
user: {
|
||||
additionalFields: {
|
||||
role: { type: 'string', required: false, defaultValue: 'user', input: false }
|
||||
}
|
||||
},
|
||||
account: {
|
||||
accountLinking: {
|
||||
enabled: true,
|
||||
disableImplicitLinking: true,
|
||||
allowDifferentEmails: true,
|
||||
trustedProviders: ['osu']
|
||||
}
|
||||
},
|
||||
rateLimit: { enabled: true },
|
||||
socialProviders: authProviders.google
|
||||
? {
|
||||
google: { clientId: env.GOOGLE_CLIENT_ID!, clientSecret: env.GOOGLE_CLIENT_SECRET! }
|
||||
}
|
||||
: {},
|
||||
hooks: {
|
||||
before: createAuthMiddleware(async (ctx) => {
|
||||
// osu! has no email scope. Reserve its internal identifiers against credential signup.
|
||||
if (
|
||||
ctx.path === '/sign-up/email' &&
|
||||
typeof ctx.body?.email === 'string' &&
|
||||
ctx.body.email.toLowerCase().endsWith('.invalid')
|
||||
) {
|
||||
throw new APIError('BAD_REQUEST', { message: 'Please use a deliverable email address' });
|
||||
}
|
||||
})
|
||||
},
|
||||
plugins: [
|
||||
genericOAuth({
|
||||
config: authProviders.osu
|
||||
? [
|
||||
{
|
||||
providerId: 'osu',
|
||||
clientId: env.OSU_CLIENT_ID!,
|
||||
clientSecret: env.OSU_CLIENT_SECRET!,
|
||||
authorizationUrl: 'https://osu.ppy.sh/oauth/authorize',
|
||||
tokenUrl: 'https://osu.ppy.sh/oauth/token',
|
||||
scopes: ['identify'],
|
||||
getUserInfo: async (tokens) => {
|
||||
const response = await fetch('https://osu.ppy.sh/api/v2/me', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.accessToken}`,
|
||||
Accept: 'application/json'
|
||||
},
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
if (!response.ok) throw new Error('Could not load osu! profile');
|
||||
return osuProfile(await response.json());
|
||||
}
|
||||
}
|
||||
]
|
||||
: []
|
||||
}),
|
||||
sveltekitCookies(getRequestEvent)
|
||||
]
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user