Compare commits

..

28 Commits

Author SHA1 Message Date
997c54133f Merge pull request 'staging' (#21) from staging into main
All checks were successful
build / build (push) Successful in 33s
build / deploy (push) Successful in 13s
Reviewed-on: #21
2026-09-01 22:00:25 +00:00
ce5c1cc9ee Merge pull request 'add footer with git link' (#20) from dev into staging
All checks were successful
build / build (push) Successful in 35s
build / deploy (push) Successful in 3s
Reviewed-on: #20
2026-09-01 21:58:06 +00:00
739d8486b2 add footer with git link 2026-09-01 21:57:31 +00:00
bdc8ac24ac Merge pull request 'staging' (#15) from staging into main
All checks were successful
build / build (push) Successful in 41s
build / deploy (push) Successful in 14s
Reviewed-on: #15
2026-09-01 11:22:34 +00:00
b11c414130 Merge pull request 'add markdown formatting with code support' (#14) from dev into staging
All checks were successful
build / build (push) Successful in 1m16s
build / deploy (push) Successful in 13s
Reviewed-on: #14
2026-09-01 11:20:27 +00:00
0994f31281 add markdown formatting with code support 2026-09-01 11:19:13 +00:00
c688b874c8 Merge pull request 'staging' (#12) from staging into main
All checks were successful
build / build (push) Successful in 31s
build / deploy (push) Successful in 13s
Reviewed-on: #12
2026-08-31 16:43:42 +00:00
4328d81408 Merge pull request 'add blog editor' (#11) from dev into staging
All checks were successful
build / build (push) Successful in 43s
build / deploy (push) Successful in 13s
Reviewed-on: #11
2026-08-31 16:40:31 +00:00
4822125787 add blog editor 2026-08-31 16:39:56 +00:00
74f64c7457 Merge pull request 'base website' (#10) from staging into main
All checks were successful
build / build (push) Successful in 40s
build / deploy (push) Successful in 13s
Reviewed-on: #10
2026-08-31 14:47:50 +00:00
62ae672630 Merge pull request 'update api key imprts and change URLs' (#9) from dev into staging
All checks were successful
build / build (push) Successful in 43s
build / deploy (push) Successful in 3s
Reviewed-on: #9
2026-08-31 14:41:25 +00:00
d793400336 update api key imprts and change URLs 2026-08-31 14:40:31 +00:00
f94d68f539 Merge pull request 'dynamically import the maptiler api key' (#8) from dev into staging
All checks were successful
build / build (push) Successful in 46s
build / deploy (push) Successful in 13s
Reviewed-on: #8
2026-08-31 14:38:24 +00:00
20dddaa263 dynamically import the maptiler api key 2026-08-31 14:38:02 +00:00
3c2b626ea2 Merge pull request 'update dockerfile' (#7) from dev into staging
All checks were successful
build / build (push) Successful in 1m17s
build / deploy (push) Successful in 3s
Reviewed-on: #7
2026-08-31 14:24:48 +00:00
07f6a77fc6 update dockerfile 2026-08-31 14:24:03 +00:00
eba89011aa Merge pull request 'smaller docker image' (#6) from dev into staging
All checks were successful
build / build (push) Successful in 38s
build / deploy (push) Successful in 4s
Reviewed-on: #6
2026-08-30 22:25:18 +00:00
fe225f8a3d smaller docker image 2026-08-30 22:24:55 +00:00
c76a137090 Merge pull request 'removed auth dependency at build time' (#5) from dev into staging
Some checks failed
build / build (push) Failing after 1m19s
build / deploy (push) Has been skipped
Reviewed-on: #5
2026-08-30 22:07:07 +00:00
0e8c3c70b8 removed auth dependency at build time 2026-08-30 22:06:42 +00:00
99e97ce750 Merge pull request 'fix dockerfile with docker lockfile' (#4) from dev into staging
Some checks failed
build / build (push) Failing after 38s
build / deploy (push) Has been skipped
Reviewed-on: #4
2026-08-30 21:43:01 +00:00
077e9f54b9 fix dockerfile with docker lockfile 2026-08-30 21:42:33 +00:00
b005a39f7c Merge pull request 'fix: build errors and merging projects and homepage' (#3) from dev into staging
Some checks failed
build / build (push) Failing after 16s
build / deploy (push) Has been skipped
Reviewed-on: #3
2026-08-30 21:39:46 +00:00
c9a7294d13 fix: build errors and merging projects and homepage 2026-08-30 21:38:59 +00:00
95514c8eea Merge pull request 'dev' (#2) from dev into staging
Some checks failed
build / build (push) Failing after 23s
build / deploy (push) Has been skipped
Reviewed-on: #2
2026-08-29 11:39:42 +00:00
97ff9852cd update homepage draft and other pages 2026-08-29 11:38:16 +00:00
6452331ee7 update tailwind to v4 2026-08-27 00:50:55 +00:00
245137c65d Merge pull request 'chore: add basic readme' (#1) from staging into main
All checks were successful
build / build (push) Successful in 41s
build / deploy (push) Successful in 17s
Reviewed-on: #1
2026-08-26 04:36:48 +00:00
111 changed files with 16027 additions and 5935 deletions

View File

@@ -9,16 +9,11 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: git.stanrunge.dev
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- uses: docker/build-push-action@v5
with:
context: .
push: true
push: false
load: true
tags: git.stanrunge.dev/stan/personal-website:${{ github.sha }}
deploy:

View File

@@ -1,25 +1,15 @@
FROM oven/bun AS builder
RUN bun
WORKDIR /app
COPY package.json bun.lockb ./
COPY package.json bun.lock ./
RUN bun install
COPY . .
RUN bun run build
FROM oven/bun
WORKDIR /app
COPY --from=builder /app ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/build ./build
COPY package.json ./
EXPOSE 3000
ENV NODE_ENV=production
CMD ["bun", "build/index.js"]
CMD ["bun", "build/index.js"]

View File

@@ -1,6 +1,7 @@
# Stan Website
This is my personal website, including:
- Contact info / social links
- About me
- Blog posts + comments

1128
bun.lock Normal file

File diff suppressed because it is too large Load Diff

BIN
bun.lockb

Binary file not shown.

View File

@@ -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:

View File

@@ -2,12 +2,11 @@ import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
schema: './src/lib/db/schema.ts',
schema: './src/lib/server/db/schema.ts',
out: './drizzle',
verbose: true,
strict: true,
dbCredentials: {
url: process.env.DB_URL
},
schemaFilter: 'personal_website'
}
});

View File

@@ -0,0 +1,198 @@
CREATE TYPE "availability" AS ENUM('catchable', 'trade_only', 'transfer_only', 'event_only');--> statement-breakpoint
CREATE TABLE "animals" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"species" text NOT NULL,
"breed" text,
"animal_name" text,
"description" text,
"ai_breed_suggestion" text,
"ai_breed_confidence" real,
"submitted_at" timestamp with time zone DEFAULT now() NOT NULL,
"accepted_at" timestamp with time zone,
"denied_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "blog_articles" (
"id" serial PRIMARY KEY,
"title" varchar(256),
"slug" varchar(256),
"content" varchar(8192),
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "certificates" (
"id" serial PRIMARY KEY,
"name" varchar(256),
"progress" integer DEFAULT 0,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "game" (
"id" serial PRIMARY KEY,
"name" varchar(256) NOT NULL,
"generation" integer DEFAULT 1 NOT NULL
);
--> statement-breakpoint
CREATE TABLE "photos" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"sighting_id" uuid NOT NULL,
"r2_key" text NOT NULL,
"url" text NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"uploaded_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "pokemon" (
"id" serial PRIMARY KEY,
"number" integer NOT NULL,
"generation" integer DEFAULT 1 NOT NULL,
"name" varchar(256) NOT NULL,
"type1" varchar(256) NOT NULL,
"type2" varchar(256),
"hp" integer DEFAULT 0 NOT NULL,
"attack" integer DEFAULT 0 NOT NULL,
"defense" integer DEFAULT 0 NOT NULL,
"special_attack" integer DEFAULT 0 NOT NULL,
"special_defense" integer DEFAULT 0 NOT NULL,
"speed" integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
CREATE TABLE "pokemon_game_entry" (
"id" serial PRIMARY KEY,
"pokemon_id" integer NOT NULL,
"game_id" integer NOT NULL,
"availability" "availability" DEFAULT 'catchable'::"availability" NOT NULL,
"note" varchar(256),
CONSTRAINT "pokemon_game_entry_pokemon_id_game_id_unique" UNIQUE("pokemon_id","game_id")
);
--> statement-breakpoint
CREATE TABLE "publications" (
"id" serial PRIMARY KEY,
"name" varchar(256),
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "sightings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"animal_id" uuid NOT NULL,
"reporter_name" text,
"seen_at" timestamp with time zone NOT NULL,
"lat" double precision NOT NULL,
"lng" double precision NOT NULL,
"submitted_at" timestamp with time zone DEFAULT now() NOT NULL,
"accepted_at" timestamp with time zone,
"denied_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "tasks" (
"id" serial PRIMARY KEY,
"name" varchar(256),
"current_points" integer DEFAULT 0,
"total_points" integer DEFAULT 1,
"notes" varchar(8192),
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "tasks_to_topics" (
"task_id" integer NOT NULL,
"topic_id" integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE "topics" (
"id" serial PRIMARY KEY,
"name" varchar(256),
"emoji" varchar(256),
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "universities" (
"id" serial PRIMARY KEY,
"name" varchar(256),
"progress" integer DEFAULT 0,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "user_entry" (
"id" serial PRIMARY KEY,
"pokemon_game_entry_id" integer NOT NULL,
"user_id" text NOT NULL,
CONSTRAINT "user_entry_pokemon_game_entry_id_user_id_unique" UNIQUE("pokemon_game_entry_id","user_id")
);
--> statement-breakpoint
CREATE TABLE "user_game" (
"id" serial PRIMARY KEY,
"user_id" text NOT NULL,
"game_id" integer NOT NULL,
CONSTRAINT "user_game_user_id_game_id_unique" UNIQUE("user_id","game_id")
);
--> statement-breakpoint
CREATE TABLE "account" (
"id" text PRIMARY KEY,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "session" (
"id" text PRIMARY KEY,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL UNIQUE,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" text PRIMARY KEY,
"name" text NOT NULL,
"email" text NOT NULL UNIQUE,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "verification" (
"id" text PRIMARY KEY,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX "pokemon_game_entry_game_id_index" ON "pokemon_game_entry" ("game_id");--> statement-breakpoint
CREATE INDEX "user_entry_user_id_index" ON "user_entry" ("user_id");--> statement-breakpoint
CREATE INDEX "user_game_user_id_index" ON "user_game" ("user_id");--> statement-breakpoint
CREATE INDEX "account_userId_idx" ON "account" ("user_id");--> statement-breakpoint
CREATE INDEX "session_userId_idx" ON "session" ("user_id");--> statement-breakpoint
CREATE INDEX "verification_identifier_idx" ON "verification" ("identifier");--> statement-breakpoint
ALTER TABLE "photos" ADD CONSTRAINT "photos_sighting_id_sightings_id_fkey" FOREIGN KEY ("sighting_id") REFERENCES "sightings"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "pokemon_game_entry" ADD CONSTRAINT "pokemon_game_entry_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("pokemon_id") REFERENCES "pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "pokemon_game_entry" ADD CONSTRAINT "pokemon_game_entry_game_id_game_id_fkey" FOREIGN KEY ("game_id") REFERENCES "game"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "sightings" ADD CONSTRAINT "sightings_animal_id_animals_id_fkey" FOREIGN KEY ("animal_id") REFERENCES "animals"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "tasks_to_topics" ADD CONSTRAINT "tasks_to_topics_task_id_tasks_id_fkey" FOREIGN KEY ("task_id") REFERENCES "tasks"("id");--> statement-breakpoint
ALTER TABLE "tasks_to_topics" ADD CONSTRAINT "tasks_to_topics_topic_id_topics_id_fkey" FOREIGN KEY ("topic_id") REFERENCES "topics"("id");--> statement-breakpoint
ALTER TABLE "user_entry" ADD CONSTRAINT "user_entry_pokemon_game_entry_id_pokemon_game_entry_id_fkey" FOREIGN KEY ("pokemon_game_entry_id") REFERENCES "pokemon_game_entry"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "user_entry" ADD CONSTRAINT "user_entry_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "user_game" ADD CONSTRAINT "user_game_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "user_game" ADD CONSTRAINT "user_game_game_id_game_id_fkey" FOREIGN KEY ("game_id") REFERENCES "game"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE;

File diff suppressed because it is too large Load Diff

View File

@@ -21,17 +21,6 @@ export default [
},
{
files: ['**/*.svelte'],
rules: {
'@typescript-eslint/explicit-function-return-type': [
'error',
{
allowExpressions: false,
allowTypedFunctionExpressions: true
}
]
},
languageOptions: {
parserOptions: {
parser: ts.parser
@@ -42,15 +31,6 @@ export default [
files: ['**/*.ts'],
languageOptions: {
globals: globals.node
},
rules: {
'@typescript-eslint/explicit-function-return-type': [
'error',
{
allowExpressions: false,
allowTypedFunctionExpressions: true
}
]
}
},
{

View File

@@ -1,52 +0,0 @@
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 $$;

View File

@@ -1,279 +0,0 @@
{
"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": {}
}
}

View File

@@ -1,13 +0,0 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1725668865024,
"tag": "0000_famous_random",
"breakpoints": true
}
]
}

13654
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,57 +1,75 @@
{
"name": "web",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"test": "npm run test:integration && npm run test:unit",
"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",
"generate": "drizzle-kit generate",
"migrate": "drizzle-kit migrate",
"studio": "drizzle-kit studio",
"seed": "bun src/lib/db/seed.ts"
},
"devDependencies": {
"@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.11.10",
"autoprefixer": "^10.4.20",
"bits-ui": "^1.4.7",
"clsx": "^2.1.1",
"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": {
"@sveltejs/adapter-node": "^5.2.11",
"drizzle-orm": "^0.38.3",
"mode-watcher": "^0.5.0",
"postgres": "^3.4.5"
}
"name": "web",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"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",
"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.ts",
"db:reset": "bun scripts/reset.ts && drizzle-kit push --force && bun scripts/seed.ts",
"auth:schema": "bunx @better-auth/cli generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes"
},
"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",
"@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",
"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"
},
"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"
}
}

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -1,78 +1,56 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--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;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--ring: 212.7 26.8% 83.9%;
}
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
@theme {
--color-bg: #f6f4f0;
--color-surface: #edeae4;
--color-surface-hover: #e4e0d8;
--color-border: #d6d2ca;
--color-border-light: #e8e5de;
--color-text: #2c2c2c;
--color-text-muted: #6b6b6b;
--color-text-dim: #999999;
--color-text-ghost: #c5c0b8;
--color-accent: #3d6b8e;
--color-accent-hover: #4d7fa6;
--color-danger: #b44;
--font-heading: 'DM Serif Display', Georgia, serif;
--font-mono: 'IBM Plex Mono', 'Courier New', monospace;
--font-body: 'Source Sans 3', 'Helvetica Neue', sans-serif;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
scroll-behavior: smooth;
}
section[id] {
scroll-margin-top: 88px;
}
body {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-body);
font-size: 15px;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
::selection {
background: color-mix(in srgb, var(--color-accent) 20%, transparent);
}
a {
color: inherit;
text-decoration: none;
}
}

2
src/app.d.ts vendored
View File

@@ -1,3 +1,5 @@
/// <reference types="unplugin-icons/types/svelte" />
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {

View File

@@ -1,29 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<!-- 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);
}
gtag('js', new Date());
<head>
<!-- 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);}
gtag('js', new Date());
gtag('config', 'G-7K10F5HJMQ');
</script>
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">
<title>Stan Runge</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
<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" />
<title>Stan Runge</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -1,7 +0,0 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});

BIN
src/lib/assets/stan.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

12
src/lib/availability.ts Normal file
View File

@@ -0,0 +1,12 @@
// how a pokemon can be obtained in a game. no pokemon_game_entry row at all
// means the pokemon doesn't exist in that game (e.g. a gen 2 mon in gen 1).
// 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'
] as const;
export type Availability = (typeof availabilityValues)[number];

View File

@@ -0,0 +1,51 @@
<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="text-2xl transition hover:opacity-60 cursor-pointer"
>
<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>

View File

@@ -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>

View File

@@ -1,34 +1,33 @@
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;
@@ -45,5 +44,5 @@ export {
Root as Button,
type Props as ButtonProps,
type Events as ButtonEvents,
buttonVariants,
buttonVariants
};

View File

@@ -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}>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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 />

View File

@@ -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} />

View File

@@ -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 />

View File

@@ -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

View File

@@ -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
};

View File

@@ -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}

View File

@@ -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 />

View File

@@ -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 />

View File

@@ -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>

View File

@@ -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}
/>

View File

@@ -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>

View File

@@ -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 />

View File

@@ -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
};

View File

@@ -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
};

View File

@@ -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}

View File

@@ -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
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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?.()}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View File

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

35
src/lib/server/auth.ts Normal file
View File

@@ -0,0 +1,35 @@
import { env } from '$env/dynamic/private';
import { betterAuth } from 'better-auth/minimal';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
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 { error, type RequestEvent } from '@sveltejs/kit';
export const auth = betterAuth({
baseURL: env.ORIGIN ?? 'http://localhost:3000',
secret: env.BETTER_AUTH_SECRET ?? 'build-time-placeholder',
database: drizzleAdapter(db, { provider: 'pg', schema }),
emailAndPassword: { enabled: true },
socialProviders: {
github: {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET
}
},
plugins: [
sveltekitCookies(getRequestEvent) // make sure this is the last plugin in the array
]
});
export function isAdmin(event: RequestEvent): boolean {
const token = event.cookies.get('admin_token');
return !!token && token === env.ADMIN_SECRET;
}
export function requireAdmin(event: RequestEvent): void {
if (!isAdmin(event)) {
error(401, { message: 'Unauthorized' });
}
}

View File

@@ -0,0 +1,50 @@
import { env } from '$env/dynamic/private';
import { createAnthropic } from '@ai-sdk/anthropic';
import { generateText } from 'ai';
const anthropic = createAnthropic({
apiKey: env.ANTHROPIC_API_KEY
});
export interface BreedDetectionResult {
breed: string;
confidence: number;
}
const PROMPT = `You are an expert at identifying animal breeds and species from photos.
Look at the animal in this image and identify its breed or species as specifically as possible.
Respond ONLY with a JSON object — no markdown, no explanation:
{"breed": "Golden Retriever", "confidence": 0.92}
Rules:
- "breed" should be the most specific correct identification (e.g. "Siberian Husky", not just "dog")
- "confidence" is your certainty from 0.0 to 1.0
- If you genuinely cannot identify the animal, respond: {"breed": null, "confidence": 0}`;
export async function detectBreed(imageUrl: string): Promise<BreedDetectionResult | null> {
try {
const { text } = await generateText({
model: anthropic('claude-haiku-4-5'),
messages: [
{
role: 'user',
content: [
{ type: 'image', image: new URL(imageUrl) },
{ type: 'text', text: PROMPT }
]
}
]
});
const parsed = JSON.parse(text.trim());
return {
breed: parsed.breed,
confidence: Math.min(1, Math.max(0, Number(parsed.confidence) || 0))
};
} catch {
return null;
}
}

View File

@@ -0,0 +1,73 @@
import { pgTable, text, timestamp, boolean, index } from 'drizzle-orm/pg-core';
export const user = pgTable('user', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: boolean('email_verified').default(false).notNull(),
image: text('image'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull()
});
export const session = pgTable(
'session',
{
id: text('id').primaryKey(),
expiresAt: timestamp('expires_at').notNull(),
token: text('token').notNull().unique(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' })
},
(table) => [index('session_userId_idx').on(table.userId)]
);
export const account = pgTable(
'account',
{
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
scope: text('scope'),
password: text('password'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull()
},
(table) => [index('account_userId_idx').on(table.userId)]
);
export const verification = pgTable(
'verification',
{
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull()
},
(table) => [index('verification_identifier_idx').on(table.identifier)]
);

View File

@@ -1,10 +1,11 @@
import { env } from '$env/dynamic/private';
import * as schema from './schema';
import { relations } from './relations';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
const client = postgres(env.DB_URL, { prepare: false });
export const db = drizzle({
schema,
relations,
client
});

View File

@@ -0,0 +1,99 @@
import { defineRelations } from 'drizzle-orm';
import * as schema from './schema';
export const relations = defineRelations(schema, (r) => ({
// ── Progress ──────────────────────────────────
tasks: {
tasksToTopics: r.many.tasksToTopics()
},
topics: {
topicsToTasks: r.many.tasksToTopics()
},
tasksToTopics: {
task: r.one.tasks({
from: r.tasksToTopics.taskId,
to: r.tasks.id
}),
topic: r.one.topics({
from: r.tasksToTopics.topicId,
to: r.topics.id
})
},
// ── CatchEmAll ────────────────────────────────
game: {
entries: r.many.pokemonGameEntry()
},
pokemon: {
entry: r.many.pokemonGameEntry()
},
pokemonGameEntry: {
game: r.one.game({
from: r.pokemonGameEntry.gameId,
to: r.game.id
}),
pokemon: r.one.pokemon({
from: r.pokemonGameEntry.pokemonId,
to: r.pokemon.id
}),
userEntries: r.many.userEntry()
},
userEntry: {
pokemonGameEntry: r.one.pokemonGameEntry({
from: r.userEntry.pokemonGameEntryId,
to: r.pokemonGameEntry.id
}),
user: r.one.user({
from: r.userEntry.userId,
to: r.user.id
})
},
userGame: {
user: r.one.user({
from: r.userGame.userId,
to: r.user.id
}),
game: r.one.game({
from: r.userGame.gameId,
to: r.game.id
})
},
// ── Auth ──────────────────────────────────────
user: {
sessions: r.many.session(),
accounts: r.many.account(),
entries: r.many.userEntry(),
games: r.many.userGame()
},
session: {
user: r.one.user({
from: r.session.userId,
to: r.user.id
})
},
account: {
user: r.one.user({
from: r.account.userId,
to: r.user.id
})
},
// ── Animaldex ─────────────────────────────────
animals: {
sightings: r.many.sightings()
},
sightings: {
animal: r.one.animals({
from: r.sightings.animalId,
to: r.animals.id
}),
photos: r.many.photos()
},
photos: {
sighting: r.one.sightings({
from: r.photos.sightingId,
to: r.sightings.id
})
}
}));

239
src/lib/server/db/schema.ts Executable file
View File

@@ -0,0 +1,239 @@
import {
doublePrecision,
index,
integer,
pgEnum,
pgTable,
real,
serial,
text,
timestamp,
unique,
uuid,
varchar
} from 'drizzle-orm/pg-core';
import { user } from './auth.schema';
import { availabilityValues } from '../../availability';
export const tasks = pgTable('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 topics = pgTable('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 tasksToTopics = pgTable('tasks_to_topics', {
taskId: integer('task_id')
.notNull()
.references(() => tasks.id),
topicId: integer('topic_id')
.notNull()
.references(() => topics.id)
});
export const universities = pgTable('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 = pgTable('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 = pgTable('publications', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 256 }),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow()
});
// Legacy ^
// New
export const blogArticleStatus = pgEnum('blog_article_status', ['draft', 'published']);
export const blogArticles = pgTable(
'blog_articles',
{
id: serial('id').primaryKey(),
title: varchar('title', { length: 256 }).notNull(),
slug: varchar('slug', { length: 256 }).notNull(),
content: text('content').default(''),
status: blogArticleStatus('status').notNull().default('draft'),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow()
},
(table) => [unique().on(table.slug)]
);
// CatchEmAll
export const availability = pgEnum('availability', availabilityValues);
export const pokemon = pgTable('pokemon', {
id: serial('id').primaryKey(),
number: integer('number').notNull(),
generation: integer('generation').notNull().default(1),
name: varchar('name', { length: 256 }).notNull(),
type1: varchar('type1', { length: 256 }).notNull(),
type2: varchar('type2', { length: 256 }),
hp: integer('hp').notNull().default(0),
attack: integer('attack').notNull().default(0),
defense: integer('defense').notNull().default(0),
specialAttack: integer('special_attack').notNull().default(0),
specialDefense: integer('special_defense').notNull().default(0),
speed: integer('speed').notNull().default(0)
});
export const game = pgTable('game', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 256 }).notNull(),
generation: integer('generation').notNull().default(1)
});
export const pokemonGameEntry = pgTable(
'pokemon_game_entry',
{
id: serial('id').primaryKey(),
pokemonId: integer('pokemon_id')
.notNull()
.references(() => pokemon.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => game.id, { onDelete: 'cascade' }),
availability: availability('availability').notNull().default('catchable'),
// optional extra detail for the tooltip, e.g. "trade with a Blue version owner"
note: varchar('note', { length: 256 })
},
(table) => [unique().on(table.pokemonId, table.gameId), index().on(table.gameId)]
);
export const userEntry = pgTable(
'user_entry',
{
id: serial('id').primaryKey(),
pokemonGameEntryId: integer('pokemon_game_entry_id')
.notNull()
.references(() => pokemonGameEntry.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' })
},
(table) => [unique().on(table.pokemonGameEntryId, table.userId), index().on(table.userId)]
);
// which games a user has chosen to track ("rules")
export const userGame = pgTable(
'user_game',
{
id: serial('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => game.id, { onDelete: 'cascade' })
},
(table) => [unique().on(table.userId, table.gameId), index().on(table.userId)]
);
// Animaldex
export const animals = pgTable('animals', {
id: uuid('id').primaryKey().defaultRandom(),
species: text('species').notNull(),
breed: text('breed'),
animalName: text('animal_name'),
description: text('description'),
// AI detection stored on the animal (from the first/primary photo)
aiBreedSuggestion: text('ai_breed_suggestion'),
aiBreedConfidence: real('ai_breed_confidence'),
// Moderation — the animal entity is moderated once.
// Accepted = visible on map (as long as it has accepted sightings too).
// Denied animals are hidden but not deleted.
submittedAt: timestamp('submitted_at', { withTimezone: true }).notNull().defaultNow(),
acceptedAt: timestamp('accepted_at', { withTimezone: true }),
deniedAt: timestamp('denied_at', { withTimezone: true })
});
// The PostGIS `location geography(Point, 4326)` column is managed outside Drizzle
// via a trigger in the migration — it auto-syncs from lat/lng on insert/update.
// Never write to `location` directly from application code.
export const sightings = pgTable('sightings', {
id: uuid('id').primaryKey().defaultRandom(),
animalId: uuid('animal_id')
.notNull()
.references(() => animals.id, { onDelete: 'cascade' }),
reporterName: text('reporter_name'),
seenAt: timestamp('seen_at', { withTimezone: true }).notNull(),
lat: doublePrecision('lat').notNull(),
lng: doublePrecision('lng').notNull(),
// `location` geography column exists in DB but not mapped in Drizzle — use sql`` for spatial queries
submittedAt: timestamp('submitted_at', { withTimezone: true }).notNull().defaultNow(),
acceptedAt: timestamp('accepted_at', { withTimezone: true }),
deniedAt: timestamp('denied_at', { withTimezone: true })
});
export const photos = pgTable('photos', {
id: uuid('id').primaryKey().defaultRandom(),
sightingId: uuid('sighting_id')
.notNull()
.references(() => sightings.id, { onDelete: 'cascade' }),
r2Key: text('r2_key').notNull(),
url: text('url').notNull(),
sortOrder: integer('sort_order').notNull().default(0),
uploadedAt: timestamp('uploaded_at', { withTimezone: true }).notNull().defaultNow()
});
export type Pokemon = typeof pokemon.$inferSelect;
export type Game = typeof game.$inferSelect;
export type PokemonGameEntry = typeof pokemonGameEntry.$inferSelect;
export type UserEntry = typeof userEntry.$inferSelect;
export type UserGame = typeof userGame.$inferSelect;
export type Availability = (typeof availability.enumValues)[number];
export type Animal = typeof animals.$inferSelect;
export type NewAnimal = typeof animals.$inferInsert;
export type Sighting = typeof sightings.$inferSelect;
export type NewSighting = typeof sightings.$inferInsert;
export type Photo = typeof photos.$inferSelect;
// ── Helpers ───────────────────────────────────────────────────────────────────
export type ModerationStatus = 'pending' | 'accepted' | 'denied';
export function getModerationStatus(
row: Pick<Animal | Sighting, 'acceptedAt' | 'deniedAt'>
): ModerationStatus {
if (row.deniedAt) return 'denied';
if (row.acceptedAt) return 'accepted';
return 'pending';
}
export * from './auth.schema';

View File

@@ -1,11 +1,14 @@
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);

View File

@@ -0,0 +1,18 @@
import { Marked } from 'marked';
import { markedHighlight } from 'marked-highlight';
import hljs from 'highlight.js';
const marked = new Marked(
markedHighlight({
emptyLangClass: 'hljs',
langPrefix: 'hljs language-',
highlight(code, lang) {
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
return hljs.highlight(code, { language }).value;
}
})
);
export function renderMarkdown(content: string): string {
return marked.parse(content, { async: false });
}

86
src/lib/server/r2.ts Normal file
View File

@@ -0,0 +1,86 @@
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { env } from '$env/dynamic/private';
import { randomUUID } from 'crypto';
function isConfigured(): boolean {
return !!(
env.R2_ACCESS_KEY_ID &&
env.R2_SECRET_ACCESS_KEY &&
env.R2_ACCOUNT_ID &&
env.R2_BUCKET &&
env.R2_PUBLIC_URL
);
}
let _client: S3Client | null = null;
function getClient(): S3Client {
if (!_client) {
if (!isConfigured()) throw new Error('R2 is not configured — check your .env');
_client = new S3Client({
region: 'auto',
endpoint: `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: env.R2_ACCESS_KEY_ID!,
secretAccessKey: env.R2_SECRET_ACCESS_KEY!
}
});
}
return _client;
}
const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic'] as const;
export type AllowedMimeType = (typeof ALLOWED_MIME_TYPES)[number];
export function isAllowedMimeType(mime: string): mime is AllowedMimeType {
return (ALLOWED_MIME_TYPES as readonly string[]).includes(mime);
}
const EXT: Record<AllowedMimeType, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/heic': 'heic'
};
export interface PresignedUpload {
/** R2 object key — store this in the DB */
key: string;
/** One-time presigned PUT URL — browser PUTs file directly here */
uploadUrl: string;
/** Permanent public URL to store in photos table */
publicUrl: string;
}
/**
* Generate a presigned PUT URL for a single photo.
*
* Flow:
* 1. Client POSTs to /api/upload/presign → gets { key, uploadUrl, publicUrl }
* 2. Client PUTs file to uploadUrl (direct to R2, server never sees the bytes)
* 3. Client submits sighting with photo keys included
* 4. Server stores key + publicUrl in photos table
*/
export async function createPresignedUpload(mimeType: AllowedMimeType): Promise<PresignedUpload> {
const key = `sightings/${randomUUID()}.${EXT[mimeType]}`;
const uploadUrl = await getSignedUrl(
getClient(),
new PutObjectCommand({
Bucket: env.R2_BUCKET,
Key: key,
ContentType: mimeType
}),
{ expiresIn: 3600 }
);
return { key, uploadUrl, publicUrl: `${env.R2_PUBLIC_URL}/${key}` };
}
/**
* Delete a photo from R2 by its key.
*/
export async function deleteObject(key: string): Promise<void> {
await getClient().send(new DeleteObjectCommand({ Bucket: env.R2_BUCKET, Key: key }));
}

29
src/lib/server/slugify.ts Normal file
View File

@@ -0,0 +1,29 @@
import { db } from '$lib/server/db';
import { blogArticles } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
export function slugify(title: string): string {
return (
title
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'article'
);
}
export async function uniqueSlug(title: string): Promise<string> {
const base = slugify(title);
let slug = base;
let suffix = 2;
while (
(await db.select({ id: blogArticles.id }).from(blogArticles).where(eq(blogArticles.slug, slug)))
.length > 0
) {
slug = `${base}-${suffix}`;
suffix += 1;
}
return slug;
}

View File

@@ -1,7 +1,7 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { cubicOut } from "svelte/easing";
import type { TransitionConfig } from "svelte/transition";
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { cubicOut } from 'svelte/easing';
import type { TransitionConfig } from 'svelte/transition';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -19,13 +19,9 @@ export const flyAndScale = (
params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 }
): TransitionConfig => {
const style = getComputedStyle(node);
const transform = style.transform === "none" ? "" : style.transform;
const transform = style.transform === 'none' ? '' : style.transform;
const scaleConversion = (
valueA: number,
scaleA: [number, number],
scaleB: [number, number]
) => {
const scaleConversion = (valueA: number, scaleA: [number, number], scaleB: [number, number]) => {
const [minA, maxA] = scaleA;
const [minB, maxB] = scaleB;
@@ -35,13 +31,11 @@ export const flyAndScale = (
return valueB;
};
const styleToString = (
style: Record<string, number | string | undefined>
): string => {
const styleToString = (style: Record<string, number | string | undefined>): string => {
return Object.keys(style).reduce((str, key) => {
if (style[key] === undefined) return str;
return str + `${key}:${style[key]};`;
}, "");
}, '');
};
return {
@@ -59,4 +53,4 @@ export const flyAndScale = (
},
easing: cubicOut
};
};
};

View File

@@ -0,0 +1,10 @@
import { z } from 'zod';
export const createArticleSchema = z.object({
title: z.string().min(1).max(256)
});
export const updateArticleSchema = z.object({
title: z.string().min(1).max(256),
content: z.string().max(50000)
});

View File

@@ -0,0 +1,76 @@
import { z } from 'zod';
// ── Presigned upload ──────────────────────────────────────────────────────────
export const presignedUploadSchema = z.object({
mimeType: z.enum(['image/jpeg', 'image/png', 'image/webp', 'image/heic'])
});
// ── Register new animal (+ first sighting) ────────────────────────────────────
export const registerAnimalSchema = z.object({
// Animal identity
species: z.string().min(1).max(100),
breed: z.string().max(100).optional(),
animalName: z.string().max(100).optional(),
description: z.string().max(1000).optional(),
// AI suggestion to persist for admin review
aiBreedSuggestion: z.string().max(100).optional(),
aiBreedConfidence: z.number().min(0).max(1).optional(),
// First sighting details
reporterName: z.string().max(100).optional(),
seenAt: z.iso.datetime(),
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
// Device GPS — used server-side for 30km check, never stored
deviceLat: z.number().min(-90).max(90),
deviceLng: z.number().min(-180).max(180),
// Already-uploaded R2 keys (110 photos, browser uploaded directly)
photoKeys: z.array(z.string().min(1)).min(1).max(10)
});
export type RegisterAnimalInput = z.infer<typeof registerAnimalSchema>;
// ── Add sighting to existing animal ──────────────────────────────────────────
export const addSightingSchema = z.object({
animalId: z.string().uuid(),
reporterName: z.string().max(100).optional(),
seenAt: z.iso.datetime(),
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
deviceLat: z.number().min(-90).max(90),
deviceLng: z.number().min(-180).max(180),
photoKeys: z.array(z.string().min(1)).max(10).optional()
});
export type AddSightingInput = z.infer<typeof addSightingSchema>;
// ── Map viewport query ────────────────────────────────────────────────────────
export const mapQuerySchema = z.object({
minLat: z.coerce.number().min(-90).max(90),
minLng: z.coerce.number().min(-180).max(180),
maxLat: z.coerce.number().min(-90).max(90),
maxLng: z.coerce.number().min(-180).max(180),
// Optional filters
species: z.string().optional(),
breed: z.string().optional(),
name: z.string().optional(),
reporter: z.string().optional(),
fromDate: z.iso.datetime().optional(),
toDate: z.iso.datetime().optional()
});
export type MapQuery = z.infer<typeof mapQuerySchema>;
// ── Admin ─────────────────────────────────────────────────────────────────────
export const moderationActionSchema = z.object({
action: z.enum(['accept', 'deny'])
});

View File

@@ -1,108 +1,78 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { ModeWatcher } from 'mode-watcher';
import '../app.css';
interface Props {
children?: import('svelte').Snippet;
}
const { children, data }: Props = $props();
import SiOsu from '~icons/simple-icons/osu';
import SiSteam from '~icons/simple-icons/steam';
import SiTwitch from '~icons/simple-icons/twitch';
import SiSpotify from '~icons/simple-icons/spotify';
import SiGithub from '~icons/simple-icons/github';
import SiYoutube from '~icons/simple-icons/youtube';
import SiLinkedin from '~icons/simple-icons/linkedin';
import SiInstagram from '~icons/simple-icons/instagram';
import SiX from '~icons/simple-icons/x';
// import SiBluesky from '~icons/simple-icons/bluesky';
// import SiThreads from '~icons/simple-icons/threads';
import SiMail from '~icons/simple-icons/gmail';
let editModalVisible = $state(false);
import CopyButton from '$lib/components/CopyButton.svelte';
import SiDiscord from '~icons/simple-icons/discord';
import SiSignal from '~icons/simple-icons/signal';
import SiWhatsapp from '~icons/simple-icons/whatsapp';
import { resolve } from '$app/paths';
let { children } = $props();
const links = [
{ icon: SiMail, label: 'Email', href: 'mailto:stan@stanrunge.dev' },
{ icon: SiGithub, label: 'GitHub', href: 'https://github.com/stanrunge' },
{ icon: SiLinkedin, label: 'LinkedIn', href: 'https://linkedin.com/in/stanrunge' },
{ icon: SiX, label: 'X', href: 'https://x.com/stanrunge' },
{ icon: SiYoutube, label: 'YouTube', href: 'https://youtube.com/@stanrunge' },
{ icon: SiInstagram, label: 'Instagram', href: 'https://instagram.com/stan_runge' },
// { icon: SiBluesky, label: 'Bluesky', href: 'https://bsky.app/profile/stanrunge.bsky.social' },
// { icon: SiThreads, label: 'Threads', href: 'https://www.threads.com/@stan_runge' },
{ icon: SiTwitch, label: 'Twitch', href: 'https://twitch.tv/stanrunge_' },
{
icon: SiSpotify,
label: 'Spotify',
href: 'https://open.spotify.com/user/e3o3qr9z7fpsu4gur9zz4y1ld'
},
{ icon: SiSteam, label: 'Steam', href: 'https://steamcommunity.com/id/stanrunge' },
{ icon: SiOsu, label: 'osu!', href: 'https://osu.ppy.sh/users/11212255' }
];
const copies = [
{ icon: SiWhatsapp, label: 'WhatsApp', value: 'stanrunge' },
{ icon: SiDiscord, label: 'Discord', value: 'stanrunge' },
{ icon: SiSignal, label: 'Signal', value: 'stan.443' }
];
</script>
<div
class="min-h-screen bg-black text-white bg-center bg-[url('/moon.jpg')] bg-auto bg-no-repeat flex flex-col"
>
<nav class="flex justify-between bg-gray-800 items-center px-4 py-2">
<div class="m-2">
<a href="/" class="font-bold text-xl sm:text-2xl">Stan Runge</a>
</div>
<div class="m-2 flex gap-4 items-center">
<a href="/progress" class="hover:underline">Progress</a>
<span>|</span>
<div class="flex gap-2 items-center">
<a href="mailto:stan@stanrunge.dev" class="hover:text-gray-300" aria-label="Mail">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h16q.825 0 1.413.588T22 6v12q0 .825-.587 1.413T20 20zm8-7L4 8v10h16V8zm0-2l8-5H4zM4 8V6v12z"
/>
</svg>
<div class="flex flex-col min-h-screen">
<header class="flex justify-between items-center gap-4 py-4 px-4">
<a href={resolve('/')} class="text-2xl font-bold">Stan Runge</a>
<nav class="flex flex-wrap justify-center gap-4" aria-label="Social links">
{#each copies as c (c.label)}
<CopyButton {...c} />
{/each}
{#each links as { icon: Icon, label, href } (label)}
<a {href} aria-label={label} class="text-2xl transition hover:opacity-60">
<Icon />
</a>
<a href="https://github.com/stanrunge" class="hover:text-gray-300" aria-label="GitHub">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34
c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83
c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64
c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02
c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21
c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2"
/>
</svg>
</a>
<a href="https://x.com/stanrunge" class="hover:text-gray-300" aria-label="X">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path
fill="currentColor"
d="m17.687 3.063l-4.996 5.711l-4.32-5.711H2.112l7.477 9.776l-7.086 8.099h3.034l5.469-6.25l4.78 6.25h6.102
l-7.794-10.304l6.625-7.571zm-1.064 16.06L5.654 4.782h1.803l10.846 14.34z"
/>
</svg>
</a>
<a
href="https://linkedin.com/in/stanrunge"
class="hover:text-gray-300"
aria-label="LinkedIn"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M19 3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zm-.5 15.5v-5.3
a3.26 3.26 0 0 0-3.26-3.26c-.85 0-1.84.52-2.32 1.3v-1.11h-2.79v8.37h2.79v-4.93c0-.77.62-1.4 1.39-1.4a1.4 1.4 0 0 1
1.4 1.4v4.93zM6.88 8.56a1.68 1.68 0 0 0 1.68-1.68c0-.93-.75-1.69-1.68-1.69a1.69 1.69 0 0 0-1.69
1.69c0 .93.76 1.68 1.69 1.68m1.39 9.94v-8.37H5.5v8.37z"
/>
</svg>
</a>
</div>
</div>
</nav>
{/each}
</nav>
</header>
<!-- page content goes here -->
<main class="mx-auto py-8 px-4 max-w-5xl flex-grow">
<ModeWatcher />
{@render children?.()}
<main class="flex-1">
{@render children()}
</main>
{#if !data.editable}
<div class="flex justify-end">
<button
class="p-3 rounded border border-white m-4"
onclick={() => (editModalVisible = !editModalVisible)}>Edit</button
<footer class="flex justify-center py-8 bg-gray-200">
<p>
Hosted on <a
class="underline text-blue-500"
href="https://git.stanrunge.dev/stan/personal-website">my git server :)</a
>
</div>
{/if}
</p>
</footer>
</div>
{#if editModalVisible}
<div
class="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50"
onclick={() => (editModalVisible = false)}
>
<form
class="bg-white rounded-lg shadow-lg p-6 max-w-md w-full flex flex-col items-center"
onclick={(e) => e.stopPropagation()}
action="/?/authenticate"
method="post"
>
<h2 class="text-lg font-semibold m-4 text-black">WHO ARE YOU?!?!?!??!</h2>
<input type="text" class="border border-black text-center rounded my-4" name="token" />
<button class="px-4 py-2 bg-gray-800 text-white rounded hover:bg-gray-700">
Authenticate
</button>
</form>
</div>
{/if}

View File

@@ -1,10 +1,106 @@
<h1 class="my-8 text-4xl font-bold text-center">Stan Runge</h1>
<img src="/moon.jpg" alt="Moon" />
<script lang="ts">
import { resolve } from '$app/paths';
</script>
<div class="flex flex-col">
<div>Vash Software</div>
<div>Hogeschool Inholland</div>
<div>Junior</div>
<div class="text-center py-2 font-bold text-xl underline">
<a href={resolve('/blog')}>Blog</a>
</div>
<div class="flex justify-around py-8">
<div class="max-w-100">
<h2 class="font-bold text-2xl underline text-center">experience</h2>
<div class="flex gap-2">
<p class="min-w-24 text-right">2026 - ???</p>
<p class="font-bold">???</p>
</div>
<div class="flex gap-2">
<p class="min-w-24 text-right">2021 - 2026</p>
<div>
<p class="font-bold">BSc. Computer Science (Software Engineering)</p>
<p>The Hague University of Applied Sciences</p>
</div>
</div>
<div class="flex gap-2">
<p class="min-w-24 text-right">2026</p>
<p class="font-bold">internship at 3webapps</p>
</div>
<div class="flex gap-2">
<p class="min-w-24 text-right">2023</p>
<p class="font-bold">internship at h5mag</p>
</div>
<div class="flex gap-2">
<p class="min-w-24 text-right">2021 - 2023</p>
<p class="font-bold">junior IT specialist at Finance Plus</p>
</div>
<div class="flex gap-2">
<p class="min-w-24 text-right">2020 - current</p>
<p class="font-bold">300+ IT problems solved in Netherlands</p>
</div>
</div>
<div class="pt-4 px-8">
<h1 class="font-black text-center text-4xl py-6">Stan Runge</h1>
<enhanced:img
class="rounded-2xl"
src="$lib/assets/stan.jpg?enhanced"
alt="stan runge"
sizes="(min-width: 768px) 760px, 90vw"
fetchpriority="high"
/>
<p class="italic text-sm text-center py-2">
me preparing for my presentation about <a href="https://arxiv.org/abs/2209.08167"
>quantum vision transformers</a
>
</p>
</div>
<div class="max-w-100">
<div>
<h2 class="font-bold text-2xl underline text-center">projects</h2>
</div>
<div class="flex flex-col gap-4">
<a href="https://esports.vash.software">
<p class="font-bold text-xl">vash esports</p>
<p>automated osu! matchmaking/tournament platform</p>
</a>
<div>
<p class="font-bold text-xl">eudi-auth for 3webapps</p>
<p>
plugin for WooCommerce/Magento2 that adds authentication with the European Digital
Identity (EUDI) wallet
</p>
</div>
<div>
<p class="font-bold text-xl">realtime collaboration for h5mag</p>
<p>
socket.io server for h5mag (magazine web editor) that broadcasts user actions to other
editors, similar to writing a Google Doc together
</p>
</div>
<div>
<p class="font-bold text-xl">infra</p>
<p>server, project deployments and dev config as code with setup script</p>
</div>
<a href={resolve('/projects/animaldex')}>
<p class="font-bold text-xl">animaldex</p>
<p>world map with pets & other animals around the neighborhood 😼</p>
</a>
<a href={resolve('/projects/catch-em-all')}>
<p class="font-bold text-xl">catch em all</p>
<p>table of all 1025 pokemon across 38 games with checkboxes for catching them all</p>
</a>
<a href={resolve('/projects/spotify-beatmap-finder')}>
<p class="font-bold text-xl">spotify osu! map finder</p>
<p>small tool for finding osu! maps using a spotify url of a song or playlist</p>
</a>
<a href="https://beeriokart.stanrunge.dev">
<p class="font-bold text-xl">beerio kart</p>
<div>Go</div>
<p>webserver in go with a double-elim bracket for a one-time mario kart tournament</p>
</a>
<p>+ a lot more planned (soon™)</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,25 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private';
import { z } from 'zod';
const loginSchema = z.object({ password: z.string().min(1) });
export const POST: RequestHandler = async ({ request, cookies }) => {
const body = await request.json().catch(() => null);
const parsed = loginSchema.safeParse(body);
if (!parsed.success || parsed.data.password !== env.ADMIN_SECRET) {
throw error(401, { message: 'Invalid password' });
}
cookies.set('admin_token', env.ADMIN_SECRET, {
path: '/',
httpOnly: true,
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7
});
return json({ ok: true });
};

View File

@@ -0,0 +1,50 @@
import { db } from '$lib/server/db';
import { blogArticles } from '$lib/server/db/schema';
import { isAdmin, requireAdmin } from '$lib/server/auth';
import { uniqueSlug } from '$lib/server/slugify';
import { createArticleSchema } from '$lib/validation/blog';
import { desc, eq } from 'drizzle-orm';
import { fail, redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types.js';
export const load: PageServerLoad = async (event) => {
const admin = isAdmin(event);
const articles = admin
? await db.select().from(blogArticles).orderBy(desc(blogArticles.createdAt))
: await db
.select()
.from(blogArticles)
.where(eq(blogArticles.status, 'published'))
.orderBy(desc(blogArticles.createdAt));
return {
blogArticles: articles,
isAdmin: admin
};
};
export const actions = {
default: async (event) => {
requireAdmin(event);
const formData = await event.request.formData();
const parsed = createArticleSchema.safeParse({
title: formData.get('title')?.toString()
});
if (!parsed.success) {
return fail(400, { message: 'Title is required' });
}
const slug = await uniqueSlug(parsed.data.title);
await db.insert(blogArticles).values({
title: parsed.data.title,
slug,
status: 'draft'
});
return redirect(303, `/blog/${slug}/edit`);
}
};

View File

@@ -0,0 +1,131 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import { resolve } from '$app/paths';
let { data } = $props();
let createNewArticlePopup = $state(false);
let loginPopup = $state(false);
let password = $state('');
let loginError = $state('');
let loginLoading = $state(false);
async function login() {
loginLoading = true;
loginError = '';
const res = await fetch('/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (res.ok) {
loginPopup = false;
password = '';
await invalidateAll();
} else {
loginError = 'Wrong password';
}
loginLoading = false;
}
</script>
<div class="text-center py-8 gap-2 flex align-center justify-center">
<h1 class="text-2xl">Blog (Stan's yapping corner)</h1>
{#if data.isAdmin}
<button
onclick={() => (createNewArticlePopup = true)}
class="bg-gray-800 rounded px-4 py-2 text-white font-bold cursor-pointer"
>Write new article</button
>
{:else}
<button
onclick={() => (loginPopup = true)}
class="text-xs text-gray-400 underline cursor-pointer">Admin login</button
>
{/if}
</div>
{#each data.blogArticles as article (article.slug)}
<div class="flex gap-2 px-4">
<a href={resolve('/blog/[slug]', { slug: article.slug })}>{article.title}</a>
{#if data.isAdmin && article.status === 'draft'}
<span class="text-xs uppercase text-orange-500">draft</span>
<a href={resolve('/blog/[slug]/edit', { slug: article.slug })} class="text-xs underline"
>edit</a
>
{/if}
<p>{article.createdAt?.toDateString()}</p>
</div>
{/each}
{#if createNewArticlePopup}
<div class="fixed inset-0 flex items-center justify-center bg-black/40">
<form
method="POST"
use:enhance={() => {
return async ({ result }) => {
if (result.type !== 'redirect') {
createNewArticlePopup = false;
}
};
}}
class="w-80 rounded bg-white p-6 shadow-xl"
>
<h2 class="mb-4 text-lg font-bold">Create New Article</h2>
<input
name="title"
placeholder="Article title"
required
class="mb-4 w-full rounded border border-gray-300 px-3 py-2 text-sm outline-none"
/>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={() => (createNewArticlePopup = false)}
class="rounded px-3 py-2 text-sm cursor-pointer">Cancel</button
>
<button
type="submit"
class="rounded bg-gray-800 px-3 py-2 text-sm font-bold text-white cursor-pointer"
>Create draft</button
>
</div>
</form>
</div>
{/if}
{#if loginPopup}
<div class="fixed inset-0 flex items-center justify-center bg-black/40">
<div class="w-80 rounded bg-white p-6 shadow-xl">
<h2 class="mb-4 text-lg font-bold">Admin login</h2>
<input
type="password"
bind:value={password}
placeholder="Password"
onkeydown={(e) => e.key === 'Enter' && login()}
class="mb-3 w-full rounded border border-gray-300 px-3 py-2 text-sm outline-none"
/>
{#if loginError}
<p class="mb-3 text-xs text-red-500">{loginError}</p>
{/if}
<div class="flex justify-end gap-2">
<button
type="button"
onclick={() => {
loginPopup = false;
loginError = '';
}}
class="rounded px-3 py-2 text-sm cursor-pointer">Cancel</button
>
<button
onclick={login}
disabled={loginLoading}
class="rounded bg-gray-800 px-3 py-2 text-sm font-bold text-white disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
>
{loginLoading ? 'Logging in…' : 'Log in'}
</button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,37 @@
import { db } from '$lib/server/db';
import { blogArticles } from '$lib/server/db/schema';
import { isAdmin } from '$lib/server/auth';
import { renderMarkdown } from '$lib/server/markdown';
import { eq } from 'drizzle-orm';
import type { PageServerLoad } from './$types.js';
import { error, redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async (event) => {
const [article] = await db
.select()
.from(blogArticles)
.where(eq(blogArticles.slug, event.params.slug));
if (!article) {
return error(404, { message: 'Article not found' });
}
const admin = isAdmin(event);
if (article.status !== 'published') {
if (!admin) {
return error(404, { message: 'Article not found' });
}
// Drafts are only ever viewed/edited through the editor.
return redirect(303, `/blog/${article.slug}/edit`);
}
return {
blogArticle: article,
contentHtml: renderMarkdown(article.content ?? ''),
isAdmin: admin
};
};
export const actions = {};

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { resolve } from '$app/paths';
import 'highlight.js/styles/github-dark.css';
let { data } = $props();
</script>
<svelte:head><title>{data.blogArticle.title} - Blog</title></svelte:head>
<article class="mx-auto max-w-2xl px-4 py-8">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold">{data.blogArticle.title}</h1>
{#if data.isAdmin}
<a
href={resolve('/blog/[slug]/edit', { slug: data.blogArticle.slug })}
class="text-sm underline">Edit</a
>
{/if}
</div>
<p class="mb-6 text-sm text-gray-500">{data.blogArticle.createdAt?.toDateString()}</p>
<div class="prose prose-neutral max-w-none">
{@html data.contentHtml}
</div>
</article>

View File

@@ -0,0 +1,90 @@
import { db } from '$lib/server/db';
import { blogArticles } from '$lib/server/db/schema';
import { isAdmin, requireAdmin } from '$lib/server/auth';
import { updateArticleSchema } from '$lib/validation/blog';
import { eq } from 'drizzle-orm';
import { error, fail, redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types.js';
export const load: PageServerLoad = async (event) => {
if (!isAdmin(event)) {
return redirect(303, `/blog/${event.params.slug}`);
}
const [article] = await db
.select()
.from(blogArticles)
.where(eq(blogArticles.slug, event.params.slug));
if (!article) {
return error(404, { message: 'Article not found' });
}
return {
blogArticle: article
};
};
export const actions = {
save: async (event) => {
requireAdmin(event);
const formData = await event.request.formData();
const parsed = updateArticleSchema.safeParse({
title: formData.get('title')?.toString(),
content: formData.get('content')?.toString()
});
if (!parsed.success) {
return fail(400, { message: 'Title and content are required' });
}
const [updated] = await db
.update(blogArticles)
.set({
title: parsed.data.title,
content: parsed.data.content,
updatedAt: new Date()
})
.where(eq(blogArticles.slug, event.params.slug))
.returning();
if (!updated) {
return error(404, { message: 'Article not found' });
}
return { saved: true };
},
publish: async (event) => {
requireAdmin(event);
const formData = await event.request.formData();
const parsed = updateArticleSchema.safeParse({
title: formData.get('title')?.toString(),
content: formData.get('content')?.toString()
});
if (!parsed.success) {
return fail(400, { message: 'Title and content are required' });
}
const [updated] = await db
.update(blogArticles)
.set({
title: parsed.data.title,
content: parsed.data.content,
status: 'published',
updatedAt: new Date()
})
.where(eq(blogArticles.slug, event.params.slug))
.returning();
if (!updated) {
return error(404, { message: 'Article not found' });
}
return redirect(303, `/blog/${updated.slug}`);
}
};

View File

@@ -0,0 +1,75 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
let { data, form } = $props();
let title = $state(data.blogArticle.title);
let content = $state(data.blogArticle.content ?? '');
let saving = $state(false);
</script>
<svelte:head><title>Editing {data.blogArticle.title}</title></svelte:head>
<div class="mx-auto max-w-2xl px-4 py-8">
<div class="mb-4 flex items-center justify-between">
<h1 class="text-xl font-bold">
Editing article
<span
class="ml-2 rounded px-2 py-0.5 text-xs uppercase {data.blogArticle.status === 'published'
? 'bg-green-100 text-green-700'
: 'bg-orange-100 text-orange-700'}">{data.blogArticle.status}</span
>
</h1>
<a href={resolve('/blog/[slug]', { slug: data.blogArticle.slug })} class="text-sm underline"
>View</a
>
</div>
{#if form?.message}
<p class="mb-3 text-sm text-red-500">{form.message}</p>
{/if}
<form
method="POST"
action="?/save"
use:enhance={() => {
saving = true;
return async ({ update }) => {
await update();
saving = false;
};
}}
class="flex flex-col gap-3"
>
<input
name="title"
bind:value={title}
required
class="rounded border border-gray-300 px-3 py-2 text-lg font-bold outline-none"
/>
<textarea
name="content"
bind:value={content}
rows="20"
class="rounded border border-gray-300 px-3 py-2 font-body text-base leading-relaxed text-gray-900 outline-none"
></textarea>
<p class="-mt-2 text-xs text-gray-400">Supports Markdown formatting.</p>
<div class="flex justify-end gap-2">
<button
type="submit"
disabled={saving}
class="rounded bg-gray-800 px-4 py-2 text-sm font-bold text-white disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
>Save draft</button
>
<button
type="submit"
formaction="?/publish"
disabled={saving}
class="rounded bg-green-700 px-4 py-2 text-sm font-bold text-white disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
>Publish</button
>
</div>
</form>
</div>

View File

@@ -1,17 +0,0 @@
import { db } from "$lib/db"
import { tasks } from "$lib/db/schema"
import { redirect } from "@sveltejs/kit"
export const actions = {
default: async ({ cookies, request }) => {
if (!cookies.get('token')) {
return { status: 401 };
}
const data = await request.formData()
const task = await db.insert(tasks).values({ name: data.get('name') }).returning()
redirect(303, '/progress/' + task[0].id)
}
}

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import './layout.css';
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Animaldex" />
<link rel="manifest" href="/site.webmanifest" />
</svelte:head>
{@render children()}

View File

@@ -0,0 +1,357 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { resolve } from '$app/paths';
import type { Map as MaptilerMap, Marker as MaptilerMarker } from '@maptiler/sdk';
interface TrailPoint {
id: string;
lat: number;
lng: number;
seenAt: string;
reporterName: string | null;
photoUrl: string | null;
}
interface AnimalFeatureProps {
animalId: string;
species: string;
breed: string | null;
animalName: string | null;
sightingCount: number;
trail: string | TrailPoint[];
}
interface GeoJSONFeature {
type: 'Feature';
geometry: { type: 'Point'; coordinates: [number, number] };
properties: AnimalFeatureProps;
}
let mapContainer: HTMLDivElement;
let map: MaptilerMap | null = null;
let htmlMarkers: MaptilerMarker[] = [];
let MapMarker: typeof MaptilerMarker | null = null;
let filterSpecies = $state('');
let filterBreed = $state('');
let filterName = $state('');
let filterReporter = $state('');
let filterFrom = $state('');
let filterTo = $state('');
let showFilters = $state(false);
let selected = $state<{
animalId: string;
species: string;
breed: string | null;
animalName: string | null;
sightingCount: number;
trail: TrailPoint[];
} | null>(null);
function parseTrail(raw: string | TrailPoint[]): TrailPoint[] {
return typeof raw === 'string' ? JSON.parse(raw) : raw;
}
onMount(async () => {
const { Map, Marker, config } = await import('@maptiler/sdk');
const { env } = await import('$env/dynamic/public');
config.apiKey = env.PUBLIC_MAPTILER_API_KEY;
MapMarker = Marker;
const initMap = (center: [number, number], zoom: number) => {
map = new Map({
container: mapContainer,
style:
'https://api.maptiler.com/maps/satellite/style.json?key=' + env.PUBLIC_MAPTILER_API_KEY,
center,
zoom
});
map.on('load', () => {
map!.addSource('trails', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] }
});
map!.addLayer({
id: 'trails',
type: 'line',
source: 'trails',
paint: {
'line-color': '#f97316',
'line-width': 2,
'line-dasharray': [2, 2],
'line-opacity': 0.7
}
});
loadMarkers(Marker);
});
map.on('moveend', () => {
if (MapMarker) loadMarkers(MapMarker);
});
};
navigator.geolocation.getCurrentPosition(
(pos) => initMap([pos.coords.longitude, pos.coords.latitude], 13),
() => initMap([4.708, 52.009], 15.5),
{ enableHighAccuracy: true, timeout: 5000 }
);
});
onDestroy(() => {
htmlMarkers.forEach((m) => m.remove());
map?.remove();
});
async function loadMarkers(Marker: typeof MaptilerMarker) {
if (!map) return;
const bounds = map.getBounds();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const params = new URLSearchParams({
minLat: String(bounds.getSouth()),
minLng: String(bounds.getWest()),
maxLat: String(bounds.getNorth()),
maxLng: String(bounds.getEast())
});
if (filterSpecies) params.set('species', filterSpecies);
if (filterBreed) params.set('breed', filterBreed);
if (filterName) params.set('name', filterName);
if (filterReporter) params.set('reporter', filterReporter);
if (filterFrom) params.set('fromDate', new Date(filterFrom).toISOString());
if (filterTo) params.set('toDate', new Date(filterTo).toISOString());
const res = await fetch(`/api/map?${params}`);
if (!res.ok) return;
const geojson: { features: GeoJSONFeature[] } = await res.json();
htmlMarkers.forEach((m) => m.remove());
htmlMarkers = [];
const lineFeatures = geojson.features
.filter((f) => parseTrail(f.properties.trail).length > 1)
.map((f) => {
const trail = parseTrail(f.properties.trail);
return {
type: 'Feature' as const,
geometry: {
type: 'LineString' as const,
coordinates: [...trail]
.sort((a, b) => new Date(a.seenAt).getTime() - new Date(b.seenAt).getTime())
.map((s) => [s.lng, s.lat])
},
properties: {}
};
});
const trailSource = map?.getSource('trails');
// @ts-expect-error setData exists on GeoJSONSource
trailSource?.setData({ type: 'FeatureCollection', features: lineFeatures });
for (const feature of geojson.features) {
const props = feature.properties;
const trail = parseTrail(props.trail);
const photoUrl = trail[0]?.photoUrl ?? null;
const el = document.createElement('div');
el.style.cssText = `
width: 44px; height: 44px; border-radius: 50%;
border: 3px solid white;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
cursor: pointer; overflow: hidden;
background: #f97316;
`;
if (photoUrl) {
el.style.backgroundImage = `url(${photoUrl})`;
el.style.backgroundSize = 'cover';
el.style.backgroundPosition = 'center';
} else {
el.innerHTML = `<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:18px">🐾</div>`;
}
el.onclick = () => {
selected = {
animalId: props.animalId,
species: props.species,
breed: props.breed,
animalName: props.animalName,
sightingCount: props.sightingCount,
trail
};
};
const marker = new Marker({ element: el })
.setLngLat(feature.geometry.coordinates)
.addTo(map!);
htmlMarkers.push(marker);
}
}
function applyFilters() {
showFilters = false;
if (MapMarker) loadMarkers(MapMarker);
}
function clearFilters() {
filterSpecies = '';
filterBreed = '';
filterName = '';
filterReporter = '';
filterFrom = '';
filterTo = '';
if (MapMarker) loadMarkers(MapMarker);
}
const activeFilterCount = $derived(
[filterSpecies, filterBreed, filterName, filterReporter, filterFrom, filterTo].filter(Boolean)
.length
);
</script>
<svelte:head>
<title>Animaldex</title>
<link rel="stylesheet" href="https://cdn.maptiler.com/maptiler-sdk-js/latest/maptiler-sdk.css" />
</svelte:head>
<div class="relative h-screen w-full overflow-hidden">
<div bind:this={mapContainer} class="h-full w-full"></div>
<div class="absolute top-4 left-1/2 z-10 flex -translate-x-1/2 gap-2">
<a
href={resolve('/projects/animaldex/register')}
class="flex items-center gap-2 rounded-full bg-orange-500 px-4 py-2 text-sm font-semibold text-white shadow-lg hover:bg-orange-600"
>
+ Log a sighting
</a>
<button
onclick={() => (showFilters = !showFilters)}
class="relative rounded-full bg-white px-4 py-2 text-sm font-semibold text-gray-700 shadow-lg hover:bg-gray-50"
>
Filters
{#if activeFilterCount > 0}
<span
class="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-orange-500 text-[10px] text-white"
>{activeFilterCount}</span
>
{/if}
</button>
</div>
{#if showFilters}
<div
class="absolute top-16 left-1/2 z-20 w-80 -translate-x-1/2 rounded-2xl bg-white p-4 shadow-xl"
>
<div class="mb-3 flex items-center justify-between">
<h2 class="font-semibold text-gray-800">Filter sightings</h2>
<button onclick={() => (showFilters = false)} class="text-gray-400 hover:text-gray-600"
></button
>
</div>
<div class="space-y-2">
<input
bind:value={filterSpecies}
placeholder="Species"
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
/>
<input
bind:value={filterBreed}
placeholder="Breed"
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
/>
<input
bind:value={filterName}
placeholder="Animal name"
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
/>
<input
bind:value={filterReporter}
placeholder="Reporter name"
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
/>
<div class="grid grid-cols-2 gap-2">
<div>
<label for="filter-from" class="mb-1 block text-xs text-gray-500">From</label>
<input
id="filter-from"
type="date"
bind:value={filterFrom}
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
/>
</div>
<div>
<label for="filter-to" class="mb-1 block text-xs text-gray-500">To</label>
<input
id="filter-to"
type="date"
bind:value={filterTo}
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
/>
</div>
</div>
</div>
<div class="mt-3 flex gap-2">
<button
onclick={applyFilters}
class="flex-1 rounded-lg bg-orange-500 py-2 text-sm font-semibold text-white hover:bg-orange-600"
>Apply</button
>
<button
onclick={clearFilters}
class="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50"
>Clear</button
>
</div>
</div>
{/if}
{#if selected}
<div class="absolute right-4 bottom-8 z-10 w-80 overflow-hidden rounded-2xl bg-white shadow-xl">
{#if selected.trail[0]?.photoUrl}
<div class="relative h-40 bg-gray-100">
<img src={selected.trail[0].photoUrl} alt="" class="h-full w-full object-cover" />
<button
onclick={() => (selected = null)}
class="absolute top-2 right-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/50 text-white"
></button
>
</div>
{/if}
<div class="p-4">
<div class="mb-1 flex items-start justify-between">
<div>
<p class="text-lg font-semibold text-gray-900">
{selected.animalName ?? selected.species}
</p>
<p class="text-sm text-gray-500">
{selected.species}{selected.breed ? ` · ${selected.breed}` : ''}
</p>
</div>
<span class="rounded-full bg-orange-100 px-2 py-0.5 text-xs font-medium text-orange-700"
>{selected.sightingCount}
{selected.sightingCount === 1 ? 'sighting' : 'sightings'}</span
>
</div>
<div class="mt-3 max-h-36 space-y-1.5 overflow-y-auto">
{#each [...selected.trail].sort((a, b) => new Date(b.seenAt).getTime() - new Date(a.seenAt).getTime()) as s (s.id)}
<div class="flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2 text-xs">
<span class="text-gray-600"
>{new Date(s.seenAt).toLocaleDateString('nl-NL', {
day: 'numeric',
month: 'short',
year: 'numeric'
})}</span
>
{#if s.reporterName}<span class="text-gray-400">by {s.reporterName}</span>{/if}
</div>
{/each}
</div>
<a
href={resolve(`/projects/animaldex/register?animalId=${selected.animalId}`)}
class="mt-3 block w-full rounded-lg bg-orange-500 py-2 text-center text-sm font-semibold text-white hover:bg-orange-600"
>
I saw this animal too!
</a>
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1 @@
export const ssr = false;

View File

@@ -0,0 +1,53 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { animals, sightings } from '$lib/server/db/schema';
import { isAdmin } from '$lib/server/auth';
import { and, eq, isNull, sql } from 'drizzle-orm';
export const load: PageServerLoad = async (event) => {
const admin = isAdmin(event);
if (!admin) return { admin: false, queue: [] };
const pending = await db
.select({
animalId: animals.id,
species: animals.species,
breed: animals.breed,
animalName: animals.animalName,
description: animals.description,
aiBreedSuggestion: animals.aiBreedSuggestion,
aiBreedConfidence: animals.aiBreedConfidence,
submittedAt: animals.submittedAt,
acceptedAt: animals.acceptedAt,
deniedAt: animals.deniedAt,
sightingId: sightings.id,
sightingLat: sightings.lat,
sightingLng: sightings.lng,
seenAt: sightings.seenAt,
reporterName: sightings.reporterName,
sightingAcceptedAt: sightings.acceptedAt,
sightingDeniedAt: sightings.deniedAt,
photoUrl: sql<string>`(
SELECT url FROM photos
WHERE sighting_id = ${sightings.id}
ORDER BY sort_order ASC
LIMIT 1
)`
})
.from(animals)
.innerJoin(
sightings,
and(
eq(sightings.animalId, animals.id),
sql`${sightings.id} = (
SELECT id FROM sightings
WHERE animal_id = ${animals.id}
ORDER BY submitted_at ASC
LIMIT 1
)`
)
)
.orderBy(animals.submittedAt);
return { admin: true, queue: pending };
};

View File

@@ -0,0 +1,170 @@
<script lang="ts">
import type { PageData } from './$types';
import { invalidateAll } from '$app/navigation';
import { resolve } from '$app/paths';
let { data }: { data: PageData } = $props();
let password = $state('');
let loginError = $state('');
let loginLoading = $state(false);
async function login() {
loginLoading = true;
loginError = '';
const res = await fetch('/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (res.ok) {
await invalidateAll();
} else {
loginError = 'Wrong password';
}
loginLoading = false;
}
let actionLoading = $state<string | null>(null);
async function moderate(type: 'animals' | 'sightings', id: string, action: 'accept' | 'deny') {
actionLoading = `${type}-${id}-${action}`;
await fetch(`/api/${type}/${id}/moderate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action })
});
actionLoading = null;
await invalidateAll();
}
const queue = $derived(data.queue ?? []);
</script>
<svelte:head><title>Animaldex - Admin</title></svelte:head>
{#if !data.admin}
<div class="flex min-h-screen items-center justify-center bg-gray-50">
<div class="w-80 rounded-2xl bg-white p-8 shadow-xl">
<h1 class="mb-6 text-xl font-semibold text-gray-900">Admin login</h1>
<input
type="password"
bind:value={password}
placeholder="Password"
onkeydown={(e) => e.key === 'Enter' && login()}
class="mb-3 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
/>
{#if loginError}
<p class="mb-3 text-xs text-red-500">{loginError}</p>
{/if}
<button
onclick={login}
disabled={loginLoading}
class="w-full rounded-lg bg-orange-500 py-2 text-sm font-semibold text-white hover:bg-orange-600 disabled:opacity-50"
>
{loginLoading ? 'Logging in…' : 'Log in'}
</button>
</div>
</div>
{:else}
<div class="min-h-screen bg-gray-50">
<div
class="sticky top-0 z-10 flex items-center justify-between border-b border-gray-100 bg-white px-6 py-3"
>
<div class="flex items-center gap-3">
<a href={resolve('/')} class="text-gray-400 hover:text-gray-600"></a>
<h1 class="font-semibold text-gray-900">Animaldex admin</h1>
</div>
<span class="rounded-full bg-orange-100 px-2 py-0.5 text-xs font-medium text-orange-700">
{queue.filter((r) => !r.acceptedAt && !r.deniedAt).length} pending
</span>
</div>
<div class="mx-auto max-w-3xl space-y-4 px-4 py-6">
{#if queue.length === 0}
<div class="rounded-2xl bg-white p-8 text-center text-gray-400 shadow-sm">
No submissions yet
</div>
{/if}
{#each queue as item (item.animalId)}
{@const status = item.acceptedAt ? 'accepted' : item.deniedAt ? 'denied' : 'pending'}
<div
class="overflow-hidden rounded-2xl bg-white shadow-sm {status === 'denied'
? 'opacity-60'
: ''}"
>
<div class="flex gap-4 p-4">
<div class="h-24 w-24 shrink-0 overflow-hidden rounded-xl bg-gray-100">
{#if item.photoUrl}
<img src={item.photoUrl} alt="" class="h-full w-full object-cover" />
{:else}
<div class="flex h-full items-center justify-center text-2xl">🐾</div>
{/if}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<div>
<p class="font-semibold text-gray-900">{item.animalName ?? item.species}</p>
<p class="text-sm text-gray-500">
{item.species}{item.breed ? ` · ${item.breed}` : ''}
</p>
</div>
<span
class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium
{status === 'accepted'
? 'bg-green-100 text-green-700'
: status === 'denied'
? 'bg-red-100 text-red-700'
: 'bg-yellow-100 text-yellow-700'}"
>
{status}
</span>
</div>
{#if item.aiBreedSuggestion}
<p class="mt-1 text-xs text-gray-400">
🤖 AI: {item.aiBreedSuggestion} ({Math.round(
(item.aiBreedConfidence ?? 0) * 100
)}%)
</p>
{/if}
<p class="mt-1 text-xs text-gray-400">
{new Date(item.submittedAt).toLocaleDateString('nl-NL', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
{#if item.reporterName}· by {item.reporterName}{/if}
</p>
<p class="mt-0.5 text-xs text-gray-400">
📍 {item.sightingLat.toFixed(5)}, {item.sightingLng.toFixed(5)}
</p>
</div>
</div>
<div class="flex gap-2 border-t border-gray-100 px-4 py-3">
<button
onclick={() => moderate('animals', item.animalId, 'accept')}
disabled={actionLoading !== null || status === 'accepted'}
class="flex-1 rounded-lg py-1.5 text-sm font-medium {status === 'accepted'
? 'cursor-default bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-700 hover:bg-green-100 hover:text-green-700'}"
>
{actionLoading === `animals-${item.animalId}-accept` ? '…' : '✓ Accept'}
</button>
<button
onclick={() => moderate('animals', item.animalId, 'deny')}
disabled={actionLoading !== null || status === 'denied'}
class="flex-1 rounded-lg py-1.5 text-sm font-medium {status === 'denied'
? 'cursor-default bg-red-100 text-red-700'
: 'bg-gray-100 text-gray-700 hover:bg-red-100 hover:text-red-700'}"
>
{actionLoading === `animals-${item.animalId}-deny` ? '…' : '✕ Deny'}
</button>
</div>
</div>
{/each}
</div>
</div>
{/if}

View File

@@ -0,0 +1,25 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private';
import { z } from 'zod';
const loginSchema = z.object({ password: z.string().min(1) });
export const POST: RequestHandler = async ({ request, cookies }) => {
const body = await request.json().catch(() => null);
const parsed = loginSchema.safeParse(body);
if (!parsed.success || parsed.data.password !== env.ADMIN_SECRET) {
throw error(401, { message: 'Invalid password' });
}
cookies.set('admin_token', env.ADMIN_SECRET, {
path: '/',
httpOnly: true,
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7
});
return json({ ok: true });
};

View File

@@ -0,0 +1,73 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { animals, sightings, photos } from '$lib/server/db/schema';
import { registerAnimalSchema } from '$lib/validation/sighting';
import { sql } from 'drizzle-orm';
import { env } from '$env/dynamic/private';
const MAX_DISTANCE_METERS = 30_000;
export const POST: RequestHandler = async ({ request }) => {
const body = await request.json().catch(() => null);
const parsed = registerAnimalSchema.safeParse(body);
if (!parsed.success) {
throw error(400, { message: parsed.error.issues[0]?.message ?? 'Invalid request' });
}
const data = parsed.data;
const [distRow] = await db.execute(sql`
SELECT ST_Distance(
ST_MakePoint(${data.deviceLng}, ${data.deviceLat})::geography,
ST_MakePoint(${data.lng}, ${data.lat})::geography
) AS dist
`);
const dist = Number((distRow as Record<string, unknown>).dist);
if (dist > MAX_DISTANCE_METERS) {
throw error(422, {
message: `Location is ${Math.round(dist / 1000)}km from your device. Maximum is 30km.`
});
}
const result = await db.transaction(async (tx) => {
const [animal] = await tx
.insert(animals)
.values({
species: data.species,
breed: data.breed,
animalName: data.animalName,
description: data.description,
aiBreedSuggestion: data.aiBreedSuggestion,
aiBreedConfidence: data.aiBreedConfidence
})
.returning({ id: animals.id });
const [sighting] = await tx
.insert(sightings)
.values({
animalId: animal.id,
reporterName: data.reporterName,
seenAt: new Date(data.seenAt),
lat: data.lat,
lng: data.lng
})
.returning({ id: sightings.id });
await tx.insert(photos).values(
data.photoKeys.map((key, i) => ({
sightingId: sighting.id,
r2Key: key,
url: `${env.R2_PUBLIC_URL}/${key}`,
sortOrder: i
}))
);
return { animalId: animal.id, sightingId: sighting.id };
});
console.log(`[animals] created animal ${result.animalId}, sighting ${result.sightingId}`);
return json(result, { status: 201 });
};

View File

@@ -0,0 +1,51 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { animals, sightings } from '$lib/server/db/schema';
import { moderationActionSchema } from '$lib/validation/sighting';
import { eq, isNull } from 'drizzle-orm';
import { requireAdmin } from '$lib/server/auth';
export const POST: RequestHandler = async (event) => {
requireAdmin(event);
const body = await event.request.json().catch(() => null);
const parsed = moderationActionSchema.safeParse(body);
if (!parsed.success) {
throw error(400, { message: 'action must be "accept" or "deny"' });
}
const now = new Date();
const isAccept = parsed.data.action === 'accept';
const [updated] = await db.transaction(async (tx) => {
const result = await tx
.update(animals)
.set({
acceptedAt: isAccept ? now : null,
deniedAt: isAccept ? null : now
})
.where(eq(animals.id, event.params.id))
.returning({ id: animals.id });
if (result.length && isAccept) {
await tx
.update(sightings)
.set({ acceptedAt: now, deniedAt: null })
.where(eq(sightings.animalId, event.params.id));
}
return result;
});
if (!updated) {
throw error(404, { message: 'Animal not found' });
}
console.log(
`[moderate] animal ${event.params.id}${parsed.data.action}, updated ${updated?.id}`
);
return json({ ok: true, action: parsed.data.action });
};

View File

@@ -0,0 +1,78 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { animals, sightings, photos } from '$lib/server/db/schema';
import { addSightingSchema } from '$lib/validation/sighting';
import { eq, sql } from 'drizzle-orm';
import { env } from '$env/dynamic/private';
const MAX_DISTANCE_METERS = 30_000;
export const POST: RequestHandler = async ({ request, params }) => {
const body = await request.json().catch(() => null);
const parsed = addSightingSchema.safeParse({ ...body, animalId: params.id });
if (!parsed.success) {
throw error(400, { message: parsed.error.issues[0]?.message ?? 'Invalid request' });
}
const data = parsed.data;
const [animal] = await db
.select({ id: animals.id, acceptedAt: animals.acceptedAt })
.from(animals)
.where(eq(animals.id, params.id))
.limit(1);
if (!animal) {
throw error(404, { message: 'Animal not found' });
}
const [distRow] = await db.execute(sql`
SELECT ST_Distance(
ST_MakePoint(${data.deviceLng}, ${data.deviceLat})::geography,
ST_MakePoint(${data.lng}, ${data.lat})::geography
) AS dist
`);
const dist = Number((distRow as Record<string, unknown>).dist);
if (dist > MAX_DISTANCE_METERS) {
throw error(422, {
message: `Location is ${Math.round(dist / 1000)}km from your device. Maximum is 30km.`
});
}
const animalAlreadyAccepted = animal.acceptedAt !== null;
const result = await db.transaction(async (tx) => {
const [sighting] = await tx
.insert(sightings)
.values({
animalId: data.animalId,
reporterName: data.reporterName,
seenAt: new Date(data.seenAt),
lat: data.lat,
lng: data.lng,
...(animalAlreadyAccepted ? { acceptedAt: new Date() } : {})
})
.returning({ id: sightings.id });
if (data.photoKeys && data.photoKeys.length > 0) {
await tx.insert(photos).values(
data.photoKeys.map((key, i) => ({
sightingId: sighting.id,
r2Key: key,
url: `${env.R2_PUBLIC_URL}/${key}`,
sortOrder: i
}))
);
}
return { sightingId: sighting.id };
});
console.log(
`[sightings] created sighting ${result.sightingId} for animal ${params.id} (auto-accepted: ${animalAlreadyAccepted})`
);
return json(result, { status: 201 });
};

View File

@@ -0,0 +1,36 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { sightings } from '$lib/server/db/schema';
import { moderationActionSchema } from '$lib/validation/sighting';
import { eq } from 'drizzle-orm';
import { requireAdmin } from '$lib/server/auth';
export const POST: RequestHandler = async (event) => {
requireAdmin(event);
const body = await event.request.json().catch(() => null);
const parsed = moderationActionSchema.safeParse(body);
if (!parsed.success) {
throw error(400, { message: 'action must be "accept" or "deny"' });
}
const now = new Date();
const isAccept = parsed.data.action === 'accept';
const [updated] = await db
.update(sightings)
.set({
acceptedAt: isAccept ? now : null,
deniedAt: isAccept ? null : now
})
.where(eq(sightings.id, event.params.id))
.returning({ id: sightings.id });
if (!updated) {
throw error(404, { message: 'Sighting not found' });
}
return json({ ok: true, action: parsed.data.action });
};

View File

@@ -0,0 +1,17 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { z } from 'zod';
const schema = z.object({ imageUrl: z.string().url() });
export const POST: RequestHandler = async ({ request }) => {
const body = await request.json().catch(() => null);
const parsed = schema.safeParse(body);
if (!parsed.success) {
throw error(400, { message: 'imageUrl is required' });
}
// const result = await detectBreed(parsed.data.imageUrl);
return json(result ?? { breed: null, confidence: 0 });
};

View File

@@ -0,0 +1,127 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { animals, sightings } from '$lib/server/db/schema';
import { mapQuerySchema } from '$lib/validation/sighting';
import { and, eq, gte, ilike, isNotNull, isNull, lte, sql } from 'drizzle-orm';
export const GET: RequestHandler = async ({ url }) => {
const params = Object.fromEntries(url.searchParams);
const parsed = mapQuerySchema.safeParse(params);
if (!parsed.success) {
throw error(400, { message: 'Invalid query parameters' });
}
const q = parsed.data;
const conditions = [
isNotNull(animals.acceptedAt),
isNull(animals.deniedAt),
isNotNull(sightings.acceptedAt),
isNull(sightings.deniedAt),
sql`ST_Within(
${sightings}.location::geometry,
ST_MakeEnvelope(${q.minLng}, ${q.minLat}, ${q.maxLng}, ${q.maxLat}, 4326)
)`
];
if (q.species) conditions.push(ilike(animals.species, `%${q.species}%`));
if (q.breed) conditions.push(ilike(animals.breed, `%${q.breed}%`));
if (q.name) conditions.push(ilike(animals.animalName, `%${q.name}%`));
if (q.reporter) conditions.push(ilike(sightings.reporterName, `%${q.reporter}%`));
if (q.fromDate) conditions.push(gte(sightings.seenAt, new Date(q.fromDate)));
if (q.toDate) conditions.push(lte(sightings.seenAt, new Date(q.toDate)));
const rows = await db
.select({
animalId: animals.id,
species: animals.species,
breed: animals.breed,
animalName: animals.animalName,
sightingId: sightings.id,
seenAt: sightings.seenAt,
reporterName: sightings.reporterName,
lat: sightings.lat,
lng: sightings.lng,
photoUrl: sql<string>`(
SELECT url FROM photos
WHERE sighting_id = ${sightings.id}
ORDER BY sort_order ASC
LIMIT 1
)`
})
.from(sightings)
.innerJoin(animals, eq(sightings.animalId, animals.id))
.where(and(...conditions))
.orderBy(sightings.seenAt);
const animalMap = new Map<
string,
{
animalId: string;
species: string;
breed: string | null;
animalName: string | null;
sightings: Array<{
id: string;
lat: number;
lng: number;
seenAt: Date;
reporterName: string | null;
photoUrl: string | null;
}>;
}
>();
for (const row of rows) {
if (!animalMap.has(row.animalId)) {
animalMap.set(row.animalId, {
animalId: row.animalId,
species: row.species,
breed: row.breed,
animalName: row.animalName,
sightings: []
});
}
animalMap.get(row.animalId)!.sightings.push({
id: row.sightingId,
lat: row.lat,
lng: row.lng,
seenAt: row.seenAt,
reporterName: row.reporterName,
photoUrl: row.photoUrl
});
}
const features = Array.from(animalMap.values()).map((animal) => {
const sorted = [...animal.sightings].sort((a, b) => b.seenAt.getTime() - a.seenAt.getTime());
const latest = sorted[0];
return {
type: 'Feature' as const,
geometry: { type: 'Point' as const, coordinates: [latest.lng, latest.lat] },
properties: {
animalId: animal.animalId,
species: animal.species,
breed: animal.breed,
animalName: animal.animalName,
sightingCount: animal.sightings.length,
trail: animal.sightings.map((s) => ({
id: s.id,
lng: s.lng,
lat: s.lat,
seenAt: s.seenAt.toISOString(),
reporterName: s.reporterName,
photoUrl: s.photoUrl
}))
}
};
});
console.log(
`[map] query returned ${rows.length} rows for bbox ${q.minLng},${q.minLat}${q.maxLng},${q.maxLat}`
);
console.log(`[map] built ${features.length} features`);
return json({ type: 'FeatureCollection', features });
};

View File

@@ -0,0 +1,22 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { createPresignedUpload, isAllowedMimeType } from '$lib/server/r2';
import { presignedUploadSchema } from '$lib/validation/sighting';
export const POST: RequestHandler = async ({ request }) => {
const body = await request.json().catch(() => null);
const parsed = presignedUploadSchema.safeParse(body);
if (!parsed.success) {
throw error(400, { message: 'Invalid request body' });
}
const { mimeType } = parsed.data;
if (!isAllowedMimeType(mimeType)) {
throw error(400, { message: 'Unsupported image type' });
}
const result = await createPresignedUpload(mimeType);
return json(result);
};

View File

@@ -0,0 +1 @@
@import 'tailwindcss';

View File

@@ -0,0 +1,445 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { Map as MaptilerMap, Marker as MaptilerMarker } from '@maptiler/sdk';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
const animalId = $derived($page.url.searchParams.get('animalId'));
const isNewSighting = $derived(!!animalId);
let species = $state('');
let customSpecies = $state('');
let breed = $state('');
let animalName = $state('');
let description = $state('');
let reporterName = $state('');
let seenAt = $state(new Date().toISOString().slice(0, 16));
let aiSuggestion = $state<{ breed: string; confidence: number } | null>(null);
let aiLoading = $state(false);
let aiDismissed = $state(false);
let photoFiles = $state<File[]>([]);
let photoPreviews = $state<string[]>([]);
let lat = $state<number | null>(null);
let lng = $state<number | null>(null);
let deviceLat = $state<number | null>(null);
let deviceLng = $state<number | null>(null);
let locationError = $state('');
let locationLoading = $state(false);
let mapContainer = $state<HTMLDivElement>(null!);
let map: MaptilerMap | null = null;
let marker: MaptilerMarker | null = null;
let submitting = $state(false);
let submitError = $state('');
$effect(() => {
if (mapContainer && lat !== null && lng !== null && !map) {
initMap();
}
});
const COMMON_SPECIES = ['Dog', 'Cat', 'Fox', 'Bird', 'Rabbit', 'Deer', 'Squirrel', 'Hedgehog'];
const finalSpecies = $derived(species === '__custom__' ? customSpecies : species);
onMount(async () => {
reporterName = localStorage.getItem('reporterName') ?? '';
locationLoading = true;
navigator.geolocation.getCurrentPosition(
(pos) => {
deviceLat = pos.coords.latitude;
deviceLng = pos.coords.longitude;
lat = pos.coords.latitude;
lng = pos.coords.longitude;
locationLoading = false;
},
() => {
locationLoading = false;
locationError = 'Location access denied. GPS is required to register a sighting.';
},
{ enableHighAccuracy: true, timeout: 10000 }
);
});
async function initMap() {
if (!mapContainer || lat === null || lng === null) return;
const { Map, Marker, config } = await import('@maptiler/sdk');
const { env } = await import('$env/dynamic/public');
config.apiKey = env.PUBLIC_MAPTILER_API_KEY;
map = new Map({
container: mapContainer,
style:
'https://api.maptiler.com/maps/satellite/style.json?key=' + env.PUBLIC_MAPTILER_API_KEY,
center: [lng!, lat!],
zoom: 15
});
map.on('load', () => {
marker = new Marker({ draggable: true }).setLngLat([lng!, lat!]).addTo(map!);
marker.on('dragend', () => {
const lngLat = marker!.getLngLat();
lat = lngLat.lat;
lng = lngLat.lng;
});
});
}
function handlePhotoChange(e: Event) {
const input = e.target as HTMLInputElement;
const newFiles = Array.from(input.files ?? []).slice(0, 10 - photoFiles.length);
photoFiles = [...photoFiles, ...newFiles].slice(0, 10);
photoPreviews = photoFiles.map((f) => URL.createObjectURL(f));
if (photoFiles.length === 1 && !isNewSighting) runAIDetection(photoFiles[0]);
}
function removePhoto(index: number) {
photoFiles = photoFiles.filter((_, i) => i !== index);
photoPreviews = photoPreviews.filter((_, i) => i !== index);
}
async function runAIDetection(file: File) {
aiLoading = true;
aiDismissed = false;
try {
const presignRes = await fetch('/api/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mimeType: file.type })
});
if (!presignRes.ok) return;
const { uploadUrl, publicUrl } = await presignRes.json();
await fetch(uploadUrl, { method: 'PUT', body: file, headers: { 'Content-Type': file.type } });
const aiRes = await fetch('/api/detect-breed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageUrl: publicUrl })
});
if (!aiRes.ok) return;
const result = await aiRes.json();
if (result.breed) {
aiSuggestion = result;
if (!breed) breed = result.breed;
}
} catch {
/* silent */
} finally {
aiLoading = false;
}
}
async function submit() {
if (submitting) return;
submitError = '';
if (!finalSpecies && !isNewSighting) {
submitError = 'Please select or enter a species.';
return;
}
if (photoFiles.length === 0 && !isNewSighting) {
submitError = 'At least one photo is required.';
return;
}
if (lat === null || lng === null || deviceLat === null || deviceLng === null) {
submitError = 'Location is required.';
return;
}
submitting = true;
try {
const photoKeys: string[] = [];
if (photoFiles.length > 0) {
for (const file of photoFiles) {
const presignRes = await fetch('/api/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mimeType: file.type })
});
if (!presignRes.ok) throw new Error('Failed to get upload URL');
const { key, uploadUrl } = await presignRes.json();
const uploadRes = await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type }
});
if (!uploadRes.ok) throw new Error('Photo upload failed');
photoKeys.push(key);
}
}
if (reporterName) localStorage.setItem('reporterName', reporterName);
const endpoint = isNewSighting ? `/api/animals/${animalId}/sightings` : '/api/animals';
const body = isNewSighting
? {
animalId,
reporterName: reporterName || undefined,
seenAt: new Date(seenAt).toISOString(),
lat,
lng,
deviceLat,
deviceLng,
photoKeys
}
: {
species: finalSpecies,
breed: breed || undefined,
animalName: animalName || undefined,
description: description || undefined,
reporterName: reporterName || undefined,
aiBreedSuggestion: aiSuggestion?.breed,
aiBreedConfidence: aiSuggestion?.confidence,
seenAt: new Date(seenAt).toISOString(),
lat,
lng,
deviceLat,
deviceLng,
photoKeys
};
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.message ?? 'Submission failed');
}
goto(resolve('/?submitted=1'));
} catch (e: unknown) {
submitError = e instanceof Error ? e.message : 'Something went wrong.';
} finally {
submitting = false;
}
}
</script>
<svelte:head>
<title>Animaldex - Log a sighting</title>
<link rel="stylesheet" href="https://cdn.maptiler.com/maptiler-sdk-js/latest/maptiler-sdk.css" />
</svelte:head>
<div class="min-h-screen bg-gray-50">
<div
class="sticky top-0 z-10 flex items-center gap-3 border-b border-gray-100 bg-white px-4 py-3"
>
<a href={resolve('/')} class="text-gray-400 hover:text-gray-600"></a>
<h1 class="font-semibold text-gray-900">
{isNewSighting ? 'Log another sighting' : 'Register an animal'}
</h1>
</div>
<div class="mx-auto max-w-lg space-y-5 px-4 py-6">
{#if locationError}
<div class="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
<p class="font-medium">📍 Location required</p>
<p class="mt-1">{locationError}</p>
<p class="mt-2 text-xs">
Go to <strong>Settings → Site permissions → Location</strong> and allow this site.
</p>
</div>
{/if}
<section class="rounded-2xl bg-white p-4 shadow-sm">
<h2 class="mb-3 text-sm font-semibold text-gray-700">
Photos
{#if isNewSighting}
<span class="text-xs font-normal text-gray-400">(optional)</span>
{:else}
<span class="text-red-400">*</span>
{/if}
</h2>
<div class="flex flex-wrap gap-2">
{#each photoPreviews as preview, i (i)}
<div class="relative h-20 w-20 overflow-hidden rounded-xl">
<img src={preview} alt="" class="h-full w-full object-cover" />
<button
onclick={() => removePhoto(i)}
class="absolute top-1 right-1 flex h-5 w-5 items-center justify-center rounded-full bg-black/60 text-xs text-white"
></button
>
</div>
{/each}
{#if photoFiles.length < 10}
<label
class="flex h-20 w-20 cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-gray-200 text-gray-400 hover:border-orange-300"
>
<span class="text-2xl">+</span>
<span class="text-xs">Photo</span>
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/heic"
multiple
class="hidden"
onchange={handlePhotoChange}
/>
</label>
{/if}
</div>
<p class="mt-2 text-xs text-gray-400">Max 10 photos · JPEG, PNG, WebP, HEIC</p>
</section>
{#if aiLoading}
<div class="flex items-center gap-2 rounded-xl bg-orange-50 p-3 text-sm text-orange-700">
<span class="animate-spin"></span> Identifying breed…
</div>
{:else if aiSuggestion && !aiDismissed}
<div class="rounded-xl border border-orange-200 bg-orange-50 p-3">
<p class="text-sm font-medium text-orange-800">
🤖 AI thinks this is a <strong>{aiSuggestion.breed}</strong>
<span class="text-xs font-normal text-orange-600"
>({Math.round(aiSuggestion.confidence * 100)}% confident)</span
>
</p>
<div class="mt-2 flex gap-2">
<button
onclick={() => {
if (aiSuggestion) breed = aiSuggestion.breed;
}}
class="rounded-lg bg-orange-500 px-3 py-1 text-xs font-semibold text-white"
>Use this</button
>
<button
onclick={() => {
aiDismissed = true;
aiSuggestion = null;
}}
class="rounded-lg border border-orange-200 px-3 py-1 text-xs text-orange-700"
>Dismiss</button
>
</div>
</div>
{/if}
{#if !isNewSighting}
<section class="rounded-2xl bg-white p-4 shadow-sm">
<h2 class="mb-3 text-sm font-semibold text-gray-700">
Species <span class="text-red-400">*</span>
</h2>
<div class="flex flex-wrap gap-2">
{#each COMMON_SPECIES as s (s)}
<button
onclick={() => {
species = s;
customSpecies = '';
}}
class="rounded-full px-3 py-1.5 text-sm {species === s
? 'bg-orange-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'}">{s}</button
>
{/each}
<button
onclick={() => {
species = '__custom__';
}}
class="rounded-full px-3 py-1.5 text-sm {species === '__custom__'
? 'bg-orange-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'}">Other…</button
>
</div>
{#if species === '__custom__'}
<input
bind:value={customSpecies}
placeholder="Enter species"
class="mt-3 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
/>
{/if}
</section>
<section class="rounded-2xl bg-white p-4 shadow-sm">
<h2 class="mb-1 text-sm font-semibold text-gray-700">
Breed <span class="text-xs font-normal text-gray-400">(optional)</span>
</h2>
<input
bind:value={breed}
placeholder="e.g. Golden Retriever"
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
/>
</section>
<section class="space-y-3 rounded-2xl bg-white p-4 shadow-sm">
<div>
<h2 class="mb-1 text-sm font-semibold text-gray-700">
Animal name <span class="text-xs font-normal text-gray-400">(optional)</span>
</h2>
<input
bind:value={animalName}
placeholder="e.g. Fluffy"
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
/>
</div>
<div>
<h2 class="mb-1 text-sm font-semibold text-gray-700">
Description <span class="text-xs font-normal text-gray-400">(optional)</span>
</h2>
<textarea
bind:value={description}
placeholder="Behaviour, features, context…"
rows="3"
class="w-full resize-none rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
></textarea>
</div>
</section>
{/if}
<section class="space-y-3 rounded-2xl bg-white p-4 shadow-sm">
<div>
<h2 class="mb-1 text-sm font-semibold text-gray-700">
When did you see it? <span class="text-red-400">*</span>
</h2>
<input
type="datetime-local"
bind:value={seenAt}
max={new Date().toISOString().slice(0, 16)}
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
/>
</div>
<div>
<h2 class="mb-1 text-sm font-semibold text-gray-700">
Your name <span class="text-xs font-normal text-gray-400">(optional)</span>
</h2>
<input
bind:value={reporterName}
placeholder="Saved for next time"
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
/>
</div>
</section>
<section class="rounded-2xl bg-white p-4 shadow-sm">
<h2 class="mb-2 text-sm font-semibold text-gray-700">
Location <span class="text-red-400">*</span>
</h2>
{#if locationLoading}
<div
class="flex h-40 items-center justify-center rounded-xl bg-gray-100 text-sm text-gray-400"
>
Getting your location…
</div>
{:else if lat !== null}
<div bind:this={mapContainer} class="h-48 w-full overflow-hidden rounded-xl"></div>
<p class="mt-2 text-xs text-gray-400">Drag the pin to fine-tune (max 30 km from you).</p>
{/if}
</section>
{#if submitError}
<div class="rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{submitError}
</div>
{/if}
<button
onclick={submit}
disabled={submitting || !!locationError || lat === null}
class="w-full rounded-xl bg-orange-500 py-3 font-semibold text-white hover:bg-orange-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? 'Submitting…' : isNewSighting ? 'Submit sighting' : 'Register animal'}
</button>
<p class="pb-8 text-center text-xs text-gray-400">
All submissions are reviewed before appearing on the map.
</p>
</div>
</div>

View File

@@ -0,0 +1 @@
export const ssr = false;

View File

@@ -0,0 +1,5 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
return { user: locals.user ?? null };
};

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import './layout.css';
import type { LayoutProps } from './$types';
let { data, children }: LayoutProps = $props();
</script>
<div class="flex min-h-screen flex-col">
<header class="flex justify-center border-b border-gray-200 px-6 py-3">
<div class="flex items-center gap-4">
{#if data.user}
<span class="text-sm text-gray-600">Logged in as {data.user.name}</span>
<form method="POST" action="/logout">
<button
class="cursor-pointer rounded-lg bg-gray-200 px-4 py-1.5 text-sm font-bold hover:bg-gray-300"
>Log out</button
>
</form>
{:else}
<a
href="/login"
class="rounded-lg bg-blue-500 px-4 py-1.5 text-sm font-bold text-white hover:bg-blue-600"
>Log in</a
>
{/if}
</div>
</header>
<main class="flex-1 p-4">{@render children()}</main>
</div>

View File

@@ -0,0 +1,107 @@
import { db } from '$lib/server/db';
import { game, pokemonGameEntry, userEntry, userGame } from '$lib/server/db/schema';
import { eq, inArray } from 'drizzle-orm';
import type { Actions, PageServerLoad } from './$types';
import { availabilityValues } from '$lib/availability';
import { fail } from '@sveltejs/kit';
export type WireEntry = [id: number, gameId: number, availability: number, note?: string];
export const load: PageServerLoad = async ({ locals }) => {
const pokemonRows = await db.query.pokemon.findMany({
with: {
entry: true
},
orderBy: (pokemon) => pokemon.number
});
const pokemon = pokemonRows.map(({ entry, ...rest }) => ({
...rest,
entries: entry.map((e): WireEntry => {
const code = availabilityValues.indexOf(e.availability);
return e.note === null ? [e.id, e.gameId, code] : [e.id, e.gameId, code, e.note];
})
}));
const games = await db.query.game.findMany({
orderBy: (game) => [game.generation, game.id]
});
let caughtIds: number[] = [];
let selectedGameIds: number[] = [];
if (locals.user) {
const caughtRows = await db
.select({ id: userEntry.pokemonGameEntryId })
.from(userEntry)
.where(eq(userEntry.userId, locals.user.id));
caughtIds = caughtRows.map((r) => r.id);
const gameRows = await db
.select({ id: userGame.gameId })
.from(userGame)
.where(eq(userGame.userId, locals.user.id));
selectedGameIds = gameRows.map((r) => r.id);
}
return { pokemon, games, caughtIds, selectedGameIds };
};
export const actions = {
togglePokemonCaught: async ({ request, locals }) => {
if (!locals.user) return fail(401, { message: 'Log in to track your progress' });
const data = await request.formData();
const entryId = Number(data.get('entryId'));
if (!Number.isInteger(entryId) || entryId <= 0) return fail(400, { message: 'Invalid entry' });
// only catchable entries can be toggled — the client disables these
// checkboxes but we can't trust that
const [entry] = await db
.select({ availability: pokemonGameEntry.availability })
.from(pokemonGameEntry)
.where(eq(pokemonGameEntry.id, entryId));
if (!entry) return fail(404, { message: 'Unknown entry' });
if (entry.availability !== 'catchable') {
return fail(400, { message: 'This pokemon is not catchable in this game' });
}
const caught = data.get('caught') === 'on';
if (caught) {
await db
.insert(userEntry)
.values({ pokemonGameEntryId: entryId, userId: locals.user.id })
.onConflictDoNothing();
} else {
await db
.delete(userEntry)
.where(
and(eq(userEntry.pokemonGameEntryId, entryId), eq(userEntry.userId, locals.user.id))
);
}
},
setGames: async ({ request, locals }) => {
if (!locals.user) return fail(401, { message: 'Log in to set rules' });
const data = await request.formData();
const gameIds = data
.getAll('gameIds')
.map(Number)
.filter((id) => Number.isInteger(id) && id > 0);
if (gameIds.length > 0) {
const validGames = await db
.select({ id: game.id })
.from(game)
.where(inArray(game.id, gameIds));
if (validGames.length !== gameIds.length) return fail(400, { message: 'Unknown game' });
}
const userId = locals.user.id;
await db.transaction(async (tx) => {
await tx.delete(userGame).where(eq(userGame.userId, userId));
if (gameIds.length > 0) {
await tx.insert(userGame).values(gameIds.map((gameId) => ({ userId, gameId })));
}
});
}
} satisfies Actions;

View File

@@ -0,0 +1,329 @@
<script lang="ts">
import { deserialize, enhance } from '$app/forms';
import { SvelteSet } from 'svelte/reactivity';
import type { PageProps } from './$types';
import type { Game } from '$lib/server/db/schema';
type Entry = { id: number; availability: Availability; note: string | null };
let { data }: PageProps = $props();
const caught = new SvelteSet(data.caughtIds);
// the full table is ~1000 rows x ~40 games; only render the rows inside
// the scroll viewport (plus some overscan) to keep the DOM small
const ROW_HEIGHT = 37; // keep in sync with h-[37px] on the rows
const OVERSCAN = 10;
let scrollEl = $state<HTMLDivElement>();
let scrollTop = $state(0);
let viewportHeight = $state(600);
const startIndex = $derived(Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN));
const endIndex = $derived(
Math.min(data.pokemon.length, Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + OVERSCAN)
);
const visiblePokemon = $derived(data.pokemon.slice(startIndex, endIndex));
// optimistic toggle without a <form> per cell — one shared handler instead
// of ~10k use:enhance initializations
async function toggleCaught(entryId: number, checked: boolean) {
if (checked) caught.add(entryId);
else caught.delete(entryId);
const body = new FormData();
body.set('entryId', String(entryId));
if (checked) body.set('caught', 'on');
let failed = false;
try {
const response = await fetch('?/togglePokemonCaught', { method: 'POST', body });
const result = deserialize(await response.text());
failed = result.type === 'failure' || result.type === 'error';
} catch {
failed = true;
}
if (failed) {
// server rejected the toggle, roll back the optimistic update
if (checked) caught.delete(entryId);
else caught.add(entryId);
}
}
// pokemonId -> gameId -> entry, for quick cell lookup
const entryMap = $derived(
new Map(
data.pokemon.map((pkmn) => [
pkmn.id,
new Map(
pkmn.entries.map(([id, gameId, code, note]) => [
gameId,
{ id, availability: availabilityValues[code], note: note ?? null } satisfies Entry
])
)
])
)
);
// empty selection = user hasn't set rules yet, show everything
const selectedGameIds = $derived(new Set(data.selectedGameIds));
const visibleGames = $derived(
selectedGameIds.size > 0 ? data.games.filter((g) => selectedGameIds.has(g.id)) : data.games
);
const visibleGameIds = $derived(new Set(visibleGames.map((g) => g.id)));
const catchableEntries = $derived(
data.pokemon
.flatMap((pkmn) => pkmn.entries)
.filter(
([, gameId, code]) => availabilityValues[code] === 'catchable' && visibleGameIds.has(gameId)
)
);
const total = $derived(catchableEntries.length);
const caughtCount = $derived(
catchableEntries.reduce((n, [id]) => n + (caught.has(id) ? 1 : 0), 0)
);
const percentage = $derived(total > 0 ? ((caughtCount / total) * 100).toFixed(2) : '0.00');
const gamesByGeneration = $derived(
[...Map.groupBy(data.games, (g) => g.generation)].sort(([a], [b]) => a - b)
);
// rules modal
let rulesDialog: HTMLDialogElement;
let draft = $state(new SvelteSet<number>());
function openRules() {
draft = new SvelteSet(selectedGameIds.size > 0 ? selectedGameIds : data.games.map((g) => g.id));
rulesDialog.showModal();
}
function tooltip(entry: Entry | undefined, game: Game): string | null {
if (!data.user) return 'Log in to track your progress';
if (!entry) return `Not available in ${game.name}`;
const reason = {
catchable: null,
trade_only: 'Only obtainable by trading in this game',
transfer_only: 'Only obtainable by transferring from another game',
event_only: 'Only obtainable through an event distribution'
}[entry.availability];
if (reason === null) return null;
return entry.note ? `${reason} (${entry.note})` : reason;
}
const typeColors: Record<string, string> = {
Normal: '#A8A77A',
Fire: '#EE8130',
Water: '#6390F0',
Electric: '#F7D02C',
Grass: '#7AC74C',
Ice: '#96D9D6',
Fighting: '#C22E28',
Poison: '#A33EA1',
Ground: '#E2BF65',
Flying: '#A98FF3',
Psychic: '#F95587',
Bug: '#A6B91A',
Rock: '#B6A136',
Ghost: '#735797',
Dragon: '#6F35FC',
Dark: '#705746',
Steel: '#B7B7CE',
Fairy: '#D685AD'
};
</script>
<svelte:head>
<title>catchemall</title>
</svelte:head>
<div class="flex flex-col">
<h1 class="py-4 text-center text-2xl font-bold">Gotta catch 'em all!</h1>
</div>
<div class="flex justify-center">
<p class="max-w-200 py-6 text-center">
Below are all {data.pokemon.length} Pokemon across {data.games.length} games. Play through all generations
and catch all catchable pokemon in each. Use the "Set rules" button to pick which games you want to
complete.
</p>
</div>
{#if !data.user}
<p class="pb-4 text-center text-sm text-gray-500">
<a href="/login" class="font-bold text-blue-500 underline">Log in</a> to save your progress.
</p>
{/if}
<div class="flex items-center justify-center gap-4 py-4">
<button
class="cursor-pointer rounded-lg bg-blue-400 px-4 py-2 font-bold text-white hover:bg-blue-500"
onclick={openRules}>Set rules</button
>
<p>{caughtCount}/{total} entries caught ({percentage} %)</p>
<progress value={total > 0 ? caughtCount / total : 0}></progress>
</div>
<div
bind:this={scrollEl}
bind:clientHeight={viewportHeight}
onscroll={() => (scrollTop = scrollEl?.scrollTop ?? 0)}
class="max-h-[75vh] overflow-auto rounded-xl border border-gray-300"
>
<table class="min-w-full table-fixed border-separate border-spacing-0 text-sm">
<thead>
<tr>
<th
class="sticky top-0 left-0 z-30 w-90 border-r border-b border-gray-300 bg-white px-2 text-left"
>Pokemon</th
>
{#each visibleGames as game (game.id)}
<th
class="sticky top-0 z-20 w-9 border-b border-gray-300 bg-white px-1 pt-2 pb-1 align-bottom"
>
<span class="mx-auto rotate-180 [writing-mode:vertical-rl]">{game.name}</span>
</th>
{/each}
</tr>
</thead>
<tbody>
{#if startIndex > 0}
<tr aria-hidden="true">
<td colspan={visibleGames.length + 1} style="height: {startIndex * ROW_HEIGHT}px"></td>
</tr>
{/if}
{#each visiblePokemon as pokemon (pokemon.id)}
<tr class="h-[37px] hover:bg-blue-50">
<th
class="sticky left-0 z-10 border-r border-b border-gray-200 bg-white px-2 py-0.5 text-left font-normal whitespace-nowrap"
>
<div class="flex items-center gap-2">
<img
src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{pokemon.number}.png"
alt=""
loading="lazy"
class="h-8 w-8"
/>
<span class="min-w-10 font-bold">{pokemon.number}</span>
<span class="min-w-30">{pokemon.name}</span>
<span
class="rounded-lg px-2 py-0.5 text-xs"
style:background-color={typeColors[pokemon.type1]}>{pokemon.type1}</span
>
{#if pokemon.type2}
<span
class="rounded-lg px-2 py-0.5 text-xs"
style:background-color={typeColors[pokemon.type2]}>{pokemon.type2}</span
>
{/if}
</div>
</th>
{#each visibleGames as game (game.id)}
{@const entry = entryMap.get(pokemon.id)?.get(game.id)}
{@const reason = tooltip(entry, game)}
<td class="border-b border-gray-200 text-center">
{#if entry && reason === null}
<input
type="checkbox"
class="cursor-pointer"
checked={caught.has(entry.id)}
onchange={(e) => toggleCaught(entry.id, e.currentTarget.checked)}
/>
{:else}
<span title={reason} class="cursor-help">
<input type="checkbox" disabled checked={entry ? caught.has(entry.id) : false} />
</span>
{/if}
</td>
{/each}
</tr>
{/each}
{#if endIndex < data.pokemon.length}
<tr aria-hidden="true">
<td
colspan={visibleGames.length + 1}
style="height: {(data.pokemon.length - endIndex) * ROW_HEIGHT}px"
></td>
</tr>
{/if}
</tbody>
</table>
</div>
<dialog bind:this={rulesDialog} class="m-auto w-full max-w-2xl rounded-xl p-6 backdrop:bg-black/50">
<h2 class="pb-2 text-xl font-bold">Set rules</h2>
<p class="pb-4 text-sm text-gray-600">
Pick the games you want to catch 'em all in. Only those show up in the table and count towards
your progress.
</p>
<div class="flex gap-2 pb-4">
<button
type="button"
class="cursor-pointer rounded-lg bg-gray-200 px-3 py-1 text-sm hover:bg-gray-300"
onclick={() => (draft = new SvelteSet(data.games.map((g) => g.id)))}>All games</button
>
<button
type="button"
class="cursor-pointer rounded-lg bg-gray-200 px-3 py-1 text-sm hover:bg-gray-300"
onclick={() => (draft = new SvelteSet())}>None</button
>
</div>
<form
method="POST"
action="?/setGames"
use:enhance={() => {
return async ({ result, update }) => {
await update();
if (result.type === 'success') rulesDialog.close();
};
}}
>
<div class="grid max-h-96 grid-cols-2 gap-x-6 overflow-y-auto sm:grid-cols-3">
{#each gamesByGeneration as [generation, games] (generation)}
<fieldset class="pb-3">
<legend class="flex items-center gap-2 pb-1 font-bold">
<input
type="checkbox"
checked={games.every((g) => draft.has(g.id))}
onchange={(e) => {
for (const g of games) {
if (e.currentTarget.checked) draft.add(g.id);
else draft.delete(g.id);
}
}}
/>
Gen {generation}
</legend>
{#each games as game (game.id)}
<label class="flex items-center gap-2 py-0.5 pl-4 text-sm">
<input
type="checkbox"
name="gameIds"
value={game.id}
checked={draft.has(game.id)}
onchange={(e) => {
if (e.currentTarget.checked) draft.add(game.id);
else draft.delete(game.id);
}}
/>
{game.name}
</label>
{/each}
</fieldset>
{/each}
</div>
<div class="flex justify-end gap-2 pt-4">
<button
type="button"
class="cursor-pointer rounded-lg bg-gray-200 px-4 py-2 font-bold hover:bg-gray-300"
onclick={() => rulesDialog.close()}>Cancel</button
>
<button
class="cursor-pointer rounded-lg bg-blue-400 px-4 py-2 font-bold text-white hover:bg-blue-500"
disabled={!data.user}
title={data.user ? undefined : 'Log in to save rules'}>Save</button
>
</div>
</form>
</dialog>

View File

@@ -0,0 +1 @@
@import 'tailwindcss';

View File

@@ -1,6 +1,4 @@
import { db } from '$lib/db';
import { tasks } from '$lib/db/schema.js';
import { desc } from 'drizzle-orm';
import { db } from '$lib/server/db';
export const load = async () => {
return {
@@ -12,7 +10,9 @@ export const load = async () => {
}
}
},
orderBy: [desc(tasks.currentPoints)]
orderBy: {
currentPoints: 'desc'
}
})
};
};

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