Compare commits

..

25 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
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
88 changed files with 13921 additions and 5809 deletions

View File

@@ -9,16 +9,11 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - 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 - uses: docker/build-push-action@v5
with: with:
context: . context: .
push: true push: false
load: true
tags: git.stanrunge.dev/stan/personal-website:${{ github.sha }} tags: git.stanrunge.dev/stan/personal-website:${{ github.sha }}
deploy: deploy:

View File

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

View File

@@ -1,6 +1,7 @@
# Stan Website # Stan Website
This is my personal website, including: This is my personal website, including:
- Contact info / social links - Contact info / social links
- About me - About me
- Blog posts + comments - 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_PASSWORD=postgres
- POSTGRES_DB=postgres - POSTGRES_DB=postgres
ports: ports:
- "5432:5432" - '5432:5432'
volumes: volumes:
- db-data:/var/lib/postgresql - db-data:/var/lib/postgresql
@@ -17,7 +17,7 @@ services:
working_dir: /app working_dir: /app
command: sh -c "bun install & bun run dev --host" command: sh -c "bun install & bun run dev --host"
ports: ports:
- "3000:5173" - '3000:5173'
env_file: .env env_file: .env
develop: develop:
watch: watch:

View File

@@ -2,12 +2,11 @@ import { defineConfig } from 'drizzle-kit';
export default defineConfig({ export default defineConfig({
dialect: 'postgresql', dialect: 'postgresql',
schema: './src/lib/db/schema.ts', schema: './src/lib/server/db/schema.ts',
out: './drizzle', out: './drizzle',
verbose: true, verbose: true,
strict: true, strict: true,
dbCredentials: { dbCredentials: {
url: process.env.DB_URL 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'], files: ['**/*.svelte'],
rules: {
'@typescript-eslint/explicit-function-return-type': [
'error',
{
allowExpressions: false,
allowTypedFunctionExpressions: true
}
]
},
languageOptions: { languageOptions: {
parserOptions: { parserOptions: {
parser: ts.parser parser: ts.parser
@@ -42,15 +31,6 @@ export default [
files: ['**/*.ts'], files: ['**/*.ts'],
languageOptions: { languageOptions: {
globals: globals.node 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,56 +1,75 @@
{ {
"name": "web", "name": "web",
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "npm run test:integration && npm run test:unit", "test": "npm run test:integration && npm run test:unit",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsgo --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsgo --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .", "lint": "prettier --check . && eslint .",
"format": "prettier --write .", "format": "prettier --write .",
"test:integration": "playwright test", "test:integration": "playwright test",
"test:unit": "vitest", "test:unit": "vitest",
"generate": "drizzle-kit generate", "db:push": "drizzle-kit push",
"migrate": "drizzle-kit migrate", "db:generate": "drizzle-kit generate",
"studio": "drizzle-kit studio", "db:migrate": "drizzle-kit migrate",
"seed": "bun src/lib/db/seed.ts" "db:studio": "drizzle-kit studio",
}, "db:seed": "bun scripts/seed.ts",
"devDependencies": { "db:reset": "bun scripts/reset.ts && drizzle-kit push --force && bun scripts/seed.ts",
"@playwright/test": "1.49.1", "auth:schema": "bunx @better-auth/cli generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes"
"@sveltejs/kit": "^2.15.2", },
"@sveltejs/vite-plugin-svelte": "^5.0.3", "devDependencies": {
"@tailwindcss/vite": "^4.0.0", "@eslint/js": "^10.0.1",
"@types/eslint": "9.6.1", "@iconify-json/simple-icons": "^1.2.94",
"@types/pg": "^8.11.10", "@playwright/test": "^1.62.1",
"bits-ui": "^1.4.7", "@sveltejs/enhanced-img": "^0.11.0",
"clsx": "^2.1.1", "@sveltejs/kit": "^2.70.3",
"cmdk-sv": "^0.0.18", "@sveltejs/vite-plugin-svelte": "^7.3.0",
"drizzle-kit": "^0.30.1", "@tailwindcss/typography": "^0.5.20",
"eslint": "9.17.0", "@tailwindcss/vite": "^4.3.3",
"eslint-config-prettier": "9.1.0", "@types/eslint": "9.6.1",
"eslint-plugin-svelte": "^2.46.1", "@types/pg": "^8.23.1",
"globals": "15.14.0", "@typescript/native": "npm:typescript@7",
"lucide-svelte": "^0.473.0", "better-auth": "^1.7.2",
"prettier": "3.4.2", "bits-ui": "^2.19.0",
"prettier-plugin-svelte": "3.3.2", "clsx": "^2.1.1",
"svelte": "^5.16.5", "cmdk-sv": "^0.0.19",
"svelte-check": "4.1.1", "drizzle-kit": "^1.0.0-rc.4",
"tailwind-merge": "^2.6.0", "eslint": "^10.9.1",
"tailwind-variants": "^0.3.1", "eslint-config-prettier": "^10.1.8",
"tailwindcss": "^4.0.0", "eslint-plugin-svelte": "^3.23.0",
"typescript": "5.7.2", "globals": "^17.11.0",
"typescript-eslint": "8.19.1", "lucide-svelte": "^1.0.1",
"vite": "^6.0.7", "prettier": "3.9.6",
"vitest": "2.1.8" "prettier-plugin-svelte": "^4.1.1",
}, "svelte": "^5.57.0",
"type": "module", "svelte-check": "4.7.6",
"dependencies": { "tailwind-merge": "^3.6.0",
"@sveltejs/adapter-node": "^5.2.11", "tailwind-variants": "^3.3.1",
"drizzle-orm": "^0.38.3", "tailwindcss": "^4.3.3",
"mode-watcher": "^0.5.0", "typescript": "~6",
"postgres": "^3.4.5" "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,4 +1,5 @@
@import 'tailwindcss'; @import 'tailwindcss';
@plugin '@tailwindcss/typography';
@theme { @theme {
--color-bg: #f6f4f0; --color-bg: #f6f4f0;
@@ -53,4 +54,3 @@
text-decoration: none; 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 // See https://kit.svelte.dev/docs/types#app
// for information about these interfaces // for information about these interfaces
declare global { declare global {

View File

@@ -1,29 +1,29 @@
<!doctype html> <!doctype html>
<html lang="en"> <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> gtag('config', 'G-7K10F5HJMQ');
<!-- Google tag (gtag.js) --> </script>
<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'); <meta charset="utf-8" />
</script> <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" />
<meta charset="utf-8" /> <link rel="icon" type="image/png" sizes="16x16" href="%sveltekit.assets%/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/apple-touch-icon.png"> <link rel="manifest" href="%sveltekit.assets%/site.webmanifest" />
<link rel="icon" type="image/png" sizes="32x32" href="%sveltekit.assets%/favicon-32x32.png"> <title>Stan Runge</title>
<link rel="icon" type="image/png" sizes="16x16" href="%sveltekit.assets%/favicon-16x16.png"> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" href="%sveltekit.assets%/site.webmanifest"> %sveltekit.head%
<title>Stan Runge</title> </head>
<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>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html> </html>

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"> <script lang="ts">
import { Button as ButtonPrimitive } from "bits-ui"; import { Button as ButtonPrimitive } from 'bits-ui';
import { type Events, type Props, buttonVariants } from "./index.js"; import { type Events, type Props, buttonVariants } from './index.js';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
type $$Props = Props; type $$Props = Props;
type $$Events = Events; type $$Events = Events;
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export let variant: $$Props["variant"] = "default"; export let variant: $$Props['variant'] = 'default';
export let size: $$Props["size"] = "default"; export let size: $$Props['size'] = 'default';
export let builders: $$Props["builders"] = []; export let builders: $$Props['builders'] = [];
export { className as class }; export { className as class };
</script> </script>

View File

@@ -1,34 +1,33 @@
import { type VariantProps, tv } from "tailwind-variants"; import { type VariantProps, tv } from 'tailwind-variants';
import type { Button as ButtonPrimitive } from "bits-ui"; import type { Button as ButtonPrimitive } from 'bits-ui';
import Root from "./button.svelte"; import Root from './button.svelte';
const buttonVariants = tv({ 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: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: outline: 'border-input bg-background hover:bg-accent hover:text-accent-foreground border',
"border-input bg-background hover:bg-accent hover:text-accent-foreground border", secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: 'hover:bg-accent hover:text-accent-foreground',
ghost: "hover:bg-accent hover:text-accent-foreground", link: 'text-primary underline-offset-4 hover:underline'
link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: "h-10 px-4 py-2", default: 'h-10 px-4 py-2',
sm: "h-9 rounded-md px-3", sm: 'h-9 rounded-md px-3',
lg: "h-11 rounded-md px-8", lg: 'h-11 rounded-md px-8',
icon: "h-10 w-10", icon: 'h-10 w-10'
}, }
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
size: "default", size: 'default'
}, }
}); });
type Variant = VariantProps<typeof buttonVariants>["variant"]; type Variant = VariantProps<typeof buttonVariants>['variant'];
type Size = VariantProps<typeof buttonVariants>["size"]; type Size = VariantProps<typeof buttonVariants>['size'];
type Props = ButtonPrimitive.Props & { type Props = ButtonPrimitive.Props & {
variant?: Variant; variant?: Variant;
@@ -45,5 +44,5 @@ export {
Root as Button, Root as Button,
type Props as ButtonProps, type Props as ButtonProps,
type Events as ButtonEvents, type Events as ButtonEvents,
buttonVariants, buttonVariants
}; };

View File

@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import type { Dialog as DialogPrimitive } from "bits-ui"; import type { Dialog as DialogPrimitive } from 'bits-ui';
import type { Command as CommandPrimitive } from "cmdk-sv"; import type { Command as CommandPrimitive } from 'cmdk-sv';
import Command from "./command.svelte"; import Command from './command.svelte';
import * as Dialog from "$lib/components/ui/dialog/index.js"; import * as Dialog from '$lib/components/ui/dialog/index.js';
type $$Props = DialogPrimitive.Props & CommandPrimitive.CommandProps; type $$Props = DialogPrimitive.Props & CommandPrimitive.CommandProps;
export let open: $$Props["open"] = false; export let open: $$Props['open'] = false;
export let value: $$Props["value"] = undefined; export let value: $$Props['value'] = undefined;
</script> </script>
<Dialog.Root bind:open {...$$restProps}> <Dialog.Root bind:open {...$$restProps}>

View File

@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv"; import { Command as CommandPrimitive } from 'cmdk-sv';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
type $$Props = CommandPrimitive.EmptyProps; type $$Props = CommandPrimitive.EmptyProps;
let className: string | undefined | null = undefined; let className: string | undefined | null = undefined;
export { className as class }; export { className as class };
</script> </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 /> <slot />
</CommandPrimitive.Empty> </CommandPrimitive.Empty>

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv"; import { Command as CommandPrimitive } from 'cmdk-sv';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
type $$Props = CommandPrimitive.GroupProps; type $$Props = CommandPrimitive.GroupProps;
let className: string | undefined | null = undefined; let className: string | undefined | null = undefined;
@@ -9,7 +9,7 @@
<CommandPrimitive.Group <CommandPrimitive.Group
class={cn( 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 className
)} )}
{...$$restProps} {...$$restProps}

View File

@@ -1,20 +1,20 @@
<script lang="ts"> <script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv"; import { Command as CommandPrimitive } from 'cmdk-sv';
import Search from "lucide-svelte/icons/search"; import Search from 'lucide-svelte/icons/search';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
type $$Props = CommandPrimitive.InputProps; type $$Props = CommandPrimitive.InputProps;
let className: string | undefined | null = undefined; let className: string | undefined | null = undefined;
export { className as class }; export { className as class };
export let value: string = ""; export let value: string = '';
</script> </script>
<div class="flex items-center border-b px-2" data-cmdk-input-wrapper=""> <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" /> <Search class="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input <CommandPrimitive.Input
class={cn( 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 className
)} )}
{...$$restProps} {...$$restProps}

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv"; import { Command as CommandPrimitive } from 'cmdk-sv';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
type $$Props = CommandPrimitive.ItemProps; type $$Props = CommandPrimitive.ItemProps;
@@ -13,7 +13,7 @@
<CommandPrimitive.Item <CommandPrimitive.Item
{asChild} {asChild}
class={cn( 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 className
)} )}
{...$$restProps} {...$$restProps}

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv"; import { Command as CommandPrimitive } from 'cmdk-sv';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
type $$Props = CommandPrimitive.ListProps; type $$Props = CommandPrimitive.ListProps;
let className: string | undefined | null = undefined; let className: string | undefined | null = undefined;
@@ -8,7 +8,7 @@
</script> </script>
<CommandPrimitive.List <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} {...$$restProps}
> >
<slot /> <slot />

View File

@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv"; import { Command as CommandPrimitive } from 'cmdk-sv';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
type $$Props = CommandPrimitive.SeparatorProps; type $$Props = CommandPrimitive.SeparatorProps;
let className: string | undefined | null = undefined; let className: string | undefined | null = undefined;
export { className as class }; export { className as class };
</script> </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"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
type $$Props = HTMLAttributes<HTMLSpanElement>; type $$Props = HTMLAttributes<HTMLSpanElement>;
@@ -9,7 +9,7 @@
</script> </script>
<span <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} {...$$restProps}
> >
<slot /> <slot />

View File

@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv"; import { Command as CommandPrimitive } from 'cmdk-sv';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
type $$Props = CommandPrimitive.CommandProps; type $$Props = CommandPrimitive.CommandProps;
export let value: $$Props["value"] = undefined; export let value: $$Props['value'] = undefined;
let className: string | undefined | null = undefined; let className: string | undefined | null = undefined;
export { className as class }; export { className as class };
@@ -12,7 +12,7 @@
<CommandPrimitive.Root <CommandPrimitive.Root
class={cn( 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 className
)} )}
bind:value 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 Root from './command.svelte';
import Dialog from "./command-dialog.svelte"; import Dialog from './command-dialog.svelte';
import Empty from "./command-empty.svelte"; import Empty from './command-empty.svelte';
import Group from "./command-group.svelte"; import Group from './command-group.svelte';
import Item from "./command-item.svelte"; import Item from './command-item.svelte';
import Input from "./command-input.svelte"; import Input from './command-input.svelte';
import List from "./command-list.svelte"; import List from './command-list.svelte';
import Separator from "./command-separator.svelte"; import Separator from './command-separator.svelte';
import Shortcut from "./command-shortcut.svelte"; import Shortcut from './command-shortcut.svelte';
const Loading = CommandPrimitive.Loading; const Loading = CommandPrimitive.Loading;
@@ -33,5 +33,5 @@ export {
List as CommandList, List as CommandList,
Separator as CommandSeparator, Separator as CommandSeparator,
Shortcut as CommandShortcut, Shortcut as CommandShortcut,
Loading as CommandLoading, Loading as CommandLoading
}; };

View File

@@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui"; import { Dialog as DialogPrimitive } from 'bits-ui';
import X from "lucide-svelte/icons/x"; import X from 'lucide-svelte/icons/x';
import * as Dialog from "./index.js"; import * as Dialog from './index.js';
import { cn, flyAndScale } from "$lib/utils.js"; import { cn, flyAndScale } from '$lib/utils.js';
type $$Props = DialogPrimitive.ContentProps; type $$Props = DialogPrimitive.ContentProps;
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export let transition: $$Props["transition"] = flyAndScale; export let transition: $$Props['transition'] = flyAndScale;
export let transitionConfig: $$Props["transitionConfig"] = { export let transitionConfig: $$Props['transitionConfig'] = {
duration: 200, duration: 200
}; };
export { className as class }; export { className as class };
</script> </script>
@@ -20,7 +20,7 @@
{transition} {transition}
{transitionConfig} {transitionConfig}
class={cn( 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 className
)} )}
{...$$restProps} {...$$restProps}

View File

@@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui"; import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
type $$Props = DialogPrimitive.DescriptionProps; type $$Props = DialogPrimitive.DescriptionProps;
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export { className as class }; export { className as class };
</script> </script>
<DialogPrimitive.Description <DialogPrimitive.Description
class={cn("text-muted-foreground text-sm", className)} class={cn('text-muted-foreground text-sm', className)}
{...$$restProps} {...$$restProps}
> >
<slot /> <slot />

View File

@@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
type $$Props = HTMLAttributes<HTMLDivElement>; type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export { className as class }; export { className as class };
</script> </script>
<div <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} {...$$restProps}
> >
<slot /> <slot />

View File

@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
type $$Props = HTMLAttributes<HTMLDivElement>; type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export { className as class }; export { className as class };
</script> </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 /> <slot />
</div> </div>

View File

@@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui"; import { Dialog as DialogPrimitive } from 'bits-ui';
import { fade } from "svelte/transition"; import { fade } from 'svelte/transition';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
type $$Props = DialogPrimitive.OverlayProps; type $$Props = DialogPrimitive.OverlayProps;
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export let transition: $$Props["transition"] = fade; export let transition: $$Props['transition'] = fade;
export let transitionConfig: $$Props["transitionConfig"] = { export let transitionConfig: $$Props['transitionConfig'] = {
duration: 150, duration: 150
}; };
export { className as class }; export { className as class };
</script> </script>
@@ -16,6 +16,6 @@
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
{transition} {transition}
{transitionConfig} {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} {...$$restProps}
/> />

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui"; import { Dialog as DialogPrimitive } from 'bits-ui';
type $$Props = DialogPrimitive.PortalProps; type $$Props = DialogPrimitive.PortalProps;
</script> </script>

View File

@@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui"; import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
type $$Props = DialogPrimitive.TitleProps; type $$Props = DialogPrimitive.TitleProps;
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export { className as class }; export { className as class };
</script> </script>
<DialogPrimitive.Title <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} {...$$restProps}
> >
<slot /> <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 Title from './dialog-title.svelte';
import Portal from "./dialog-portal.svelte"; import Portal from './dialog-portal.svelte';
import Footer from "./dialog-footer.svelte"; import Footer from './dialog-footer.svelte';
import Header from "./dialog-header.svelte"; import Header from './dialog-header.svelte';
import Overlay from "./dialog-overlay.svelte"; import Overlay from './dialog-overlay.svelte';
import Content from "./dialog-content.svelte"; import Content from './dialog-content.svelte';
import Description from "./dialog-description.svelte"; import Description from './dialog-description.svelte';
const Root = DialogPrimitive.Root; const Root = DialogPrimitive.Root;
const Trigger = DialogPrimitive.Trigger; const Trigger = DialogPrimitive.Trigger;
@@ -33,5 +33,5 @@ export {
Overlay as DialogOverlay, Overlay as DialogOverlay,
Content as DialogContent, Content as DialogContent,
Description as DialogDescription, Description as DialogDescription,
Close as DialogClose, Close as DialogClose
}; };

View File

@@ -1,5 +1,5 @@
import { Popover as PopoverPrimitive } from "bits-ui"; import { Popover as PopoverPrimitive } from 'bits-ui';
import Content from "./popover-content.svelte"; import Content from './popover-content.svelte';
const Root = PopoverPrimitive.Root; const Root = PopoverPrimitive.Root;
const Trigger = PopoverPrimitive.Trigger; const Trigger = PopoverPrimitive.Trigger;
const Close = PopoverPrimitive.Close; const Close = PopoverPrimitive.Close;
@@ -13,5 +13,5 @@ export {
Root as Popover, Root as Popover,
Content as PopoverContent, Content as PopoverContent,
Trigger as PopoverTrigger, Trigger as PopoverTrigger,
Close as PopoverClose, Close as PopoverClose
}; };

View File

@@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui"; import { Popover as PopoverPrimitive } from 'bits-ui';
import { cn, flyAndScale } from "$lib/utils.js"; import { cn, flyAndScale } from '$lib/utils.js';
type $$Props = PopoverPrimitive.ContentProps; type $$Props = PopoverPrimitive.ContentProps;
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export let transition: $$Props["transition"] = flyAndScale; export let transition: $$Props['transition'] = flyAndScale;
export let transitionConfig: $$Props["transitionConfig"] = undefined; export let transitionConfig: $$Props['transitionConfig'] = undefined;
export { className as class }; export { className as class };
</script> </script>
@@ -13,7 +13,7 @@
{transition} {transition}
{transitionConfig} {transitionConfig}
class={cn( 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 className
)} )}
{...$$restProps} {...$$restProps}

View File

@@ -1,11 +1,11 @@
import Root from "./table.svelte"; import Root from './table.svelte';
import Body from "./table-body.svelte"; import Body from './table-body.svelte';
import Caption from "./table-caption.svelte"; import Caption from './table-caption.svelte';
import Cell from "./table-cell.svelte"; import Cell from './table-cell.svelte';
import Footer from "./table-footer.svelte"; import Footer from './table-footer.svelte';
import Head from "./table-head.svelte"; import Head from './table-head.svelte';
import Header from "./table-header.svelte"; import Header from './table-header.svelte';
import Row from "./table-row.svelte"; import Row from './table-row.svelte';
export { export {
Root, Root,
@@ -24,5 +24,5 @@ export {
Footer as TableFooter, Footer as TableFooter,
Head as TableHead, Head as TableHead,
Header as TableHeader, Header as TableHeader,
Row as TableRow, Row as TableRow
}; };

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import type { WithElementRef } from "bits-ui"; import type { WithElementRef } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -11,6 +11,6 @@
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script> </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?.()} {@render children?.()}
</tbody> </tbody>

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import type { WithElementRef } from "bits-ui"; import type { WithElementRef } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -11,6 +11,6 @@
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script> </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?.()} {@render children?.()}
</caption> </caption>

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { HTMLTdAttributes } from "svelte/elements"; import type { HTMLTdAttributes } from 'svelte/elements';
import type { WithElementRef } from "bits-ui"; import type { WithElementRef } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -13,7 +13,7 @@
<td <td
bind:this={ref} 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} {...restProps}
> >
{@render children?.()} {@render children?.()}

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import type { WithElementRef } from "bits-ui"; import type { WithElementRef } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -11,6 +11,6 @@
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script> </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?.()} {@render children?.()}
</tfoot> </tfoot>

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { HTMLThAttributes } from "svelte/elements"; import type { HTMLThAttributes } from 'svelte/elements';
import type { WithElementRef } from "bits-ui"; import type { WithElementRef } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -14,7 +14,7 @@
<th <th
bind:this={ref} bind:this={ref}
class={cn( 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 className
)} )}
{...restProps} {...restProps}

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import type { WithElementRef } from "bits-ui"; import type { WithElementRef } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -11,6 +11,6 @@
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script> </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?.()} {@render children?.()}
</thead> </thead>

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import type { WithElementRef } from "bits-ui"; import type { WithElementRef } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -14,7 +14,7 @@
<tr <tr
bind:this={ref} bind:this={ref}
class={cn( 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 className
)} )}
{...restProps} {...restProps}

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { HTMLTableAttributes } from "svelte/elements"; import type { HTMLTableAttributes } from 'svelte/elements';
import type { WithElementRef } from "bits-ui"; import type { WithElementRef } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -12,7 +12,7 @@
</script> </script>
<div class="relative w-full overflow-auto"> <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?.()} {@render children?.()}
</table> </table>
</div> </div>

View File

@@ -1,85 +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()
});
// Legacy ^
// New
export const blogArticles = personalWebsiteSchema.table('blog_articles', {
id: serial('id').primaryKey(),
title: varchar('title', { length: 256 }),
slug: varchar('slug', { length: 256 }),
content: varchar('content', { length: 8192 }),
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 { env } from '$env/dynamic/private';
import * as schema from './schema'; import { relations } from './relations';
import { drizzle } from 'drizzle-orm/postgres-js'; import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres'; import postgres from 'postgres';
const client = postgres(env.DB_URL, { prepare: false }); const client = postgres(env.DB_URL, { prepare: false });
export const db = drizzle({ export const db = drizzle({
schema, relations,
client 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 { topics } from './schema';
import * as schema from './schema' import * as schema from './schema';
import { drizzle } from 'drizzle-orm/postgres-js'; import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres'; import postgres from 'postgres';
const client = postgres('postgresql://postgres:postgres@localhost:5432/db'); const client = postgres('postgresql://postgres:postgres@localhost:5432/db');
const db = drizzle(client, { schema }); 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 { type ClassValue, clsx } from 'clsx';
import { twMerge } from "tailwind-merge"; import { twMerge } from 'tailwind-merge';
import { cubicOut } from "svelte/easing"; import { cubicOut } from 'svelte/easing';
import type { TransitionConfig } from "svelte/transition"; import type { TransitionConfig } from 'svelte/transition';
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
@@ -19,13 +19,9 @@ export const flyAndScale = (
params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 } params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 }
): TransitionConfig => { ): TransitionConfig => {
const style = getComputedStyle(node); const style = getComputedStyle(node);
const transform = style.transform === "none" ? "" : style.transform; const transform = style.transform === 'none' ? '' : style.transform;
const scaleConversion = ( const scaleConversion = (valueA: number, scaleA: [number, number], scaleB: [number, number]) => {
valueA: number,
scaleA: [number, number],
scaleB: [number, number]
) => {
const [minA, maxA] = scaleA; const [minA, maxA] = scaleA;
const [minB, maxB] = scaleB; const [minB, maxB] = scaleB;
@@ -35,13 +31,11 @@ export const flyAndScale = (
return valueB; return valueB;
}; };
const styleToString = ( const styleToString = (style: Record<string, number | string | undefined>): string => {
style: Record<string, number | string | undefined>
): string => {
return Object.keys(style).reduce((str, key) => { return Object.keys(style).reduce((str, key) => {
if (style[key] === undefined) return str; if (style[key] === undefined) return str;
return str + `${key}:${style[key]};`; return str + `${key}:${style[key]};`;
}, ""); }, '');
}; };
return { return {
@@ -59,4 +53,4 @@ export const flyAndScale = (
}, },
easing: cubicOut 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,58 +1,78 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
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';
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(); 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> </script>
<header class="flex justify-center gap-4"> <div class="flex flex-col min-h-screen">
<a href="https://osu.ppy.sh/users/11212255"> <header class="flex justify-between items-center gap-4 py-4 px-4">
<img src="./osu.png" alt="osu" /> <a href={resolve('/')} class="text-2xl font-bold">Stan Runge</a>
</a> <nav class="flex flex-wrap justify-center gap-4" aria-label="Social links">
<a href="https://steamcommunity.com/id/stanrunge"> {#each copies as c (c.label)}
<img src="./steam.png" alt="steam" /> <CopyButton {...c} />
</a> {/each}
<a href="mailto://stan@stanrunge.dev"> {#each links as { icon: Icon, label, href } (label)}
<img src="./mail.png" alt="email" /> <a {href} aria-label={label} class="text-2xl transition hover:opacity-60">
</a> <Icon />
<a href="https://twitch.tv/stanrunge_"> </a>
<img src="./twitch.png" alt="twitch" /> {/each}
</a> </nav>
<a href="https://open.spotify.com/user/e3o3qr9z7fpsu4gur9zz4y1ld?si=9874beb4481f45c3"> </header>
<img src="./spotify.png" alt="spotify" />
</a>
<a href="">
<img src="./signal.png" alt="signal" />
</a>
<a href="">
<img src="./bluesky.png" alt="bluesky" />
</a>
<div>
<img src="./discord.png" alt="discord" />
</div>
<a href="">
<img src="./whatsapp.png" alt="whatsapp" />
</a>
<a href="">
<img src="./threads.png" alt="threads" />
</a>
<a href="https://github.com/stanrunge">
<img src="./github.png" alt="github" />
</a>
<a href="https://youtube.com/@stanrunge">
<img src="./youtube.png" alt="youtube" />
</a>
<a href="https://linkedin.com/in/stanrunge">
<img src="./linkedin.png" alt="linkedin" />
</a>
<a href="https://instagram.com/stan_runge">
<img src="./instagram.png" alt="instagram" />
</a>
<a href="">
<img src="./facebook.png" alt="facebook" />
</a>
<a href="https://x.com/stanrunge">
<img src="./x.png" alt="x" />
</a>
</header>
{@render children()} <main class="flex-1">
{@render children()}
</main>
<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
>
</p>
</footer>
</div>

View File

@@ -1,5 +1,9 @@
<div class="text-center py-4 font-bold text-l"> <script lang="ts">
<a href="/blog">Blog</a> import { resolve } from '$app/paths';
</script>
<div class="text-center py-2 font-bold text-xl underline">
<a href={resolve('/blog')}>Blog</a>
</div> </div>
<div class="flex justify-around py-8"> <div class="flex justify-around py-8">
@@ -34,10 +38,16 @@
</div> </div>
</div> </div>
<div class="pt-32"> <div class="pt-4 px-8">
<h1 class="font-black text-center text-4xl">Stan Runge</h1> <h1 class="font-black text-center text-4xl py-6">Stan Runge</h1>
<img src="./quantum-chair.png" alt="stan runge" /> <enhanced:img
<p class="italic text-sm text-center"> 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" me preparing for my presentation about <a href="https://arxiv.org/abs/2209.08167"
>quantum vision transformers</a >quantum vision transformers</a
> >
@@ -72,15 +82,15 @@
<p class="font-bold text-xl">infra</p> <p class="font-bold text-xl">infra</p>
<p>server, project deployments and dev config as code with setup script</p> <p>server, project deployments and dev config as code with setup script</p>
</div> </div>
<a href="/projects/animaldex"> <a href={resolve('/projects/animaldex')}>
<p class="font-bold text-xl">animaldex</p> <p class="font-bold text-xl">animaldex</p>
<p>world map with pets & other animals around the neighborhood 😼</p> <p>world map with pets & other animals around the neighborhood 😼</p>
</a> </a>
<a href="/projects/catch-em-all"> <a href={resolve('/projects/catch-em-all')}>
<p class="font-bold text-xl">catch em all</p> <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> <p>table of all 1025 pokemon across 38 games with checkboxes for catching them all</p>
</a> </a>
<a href="/projects/spotify-beatmap-finder"> <a href={resolve('/projects/spotify-beatmap-finder')}>
<p class="font-bold text-xl">spotify osu! map finder</p> <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> <p>small tool for finding osu! maps using a spotify url of a song or playlist</p>
</a> </a>
@@ -94,4 +104,3 @@
</div> </div>
</div> </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

@@ -1,26 +1,50 @@
import { db } from "$lib/db" import { db } from '$lib/server/db';
import { blogArticles } from "$lib/db/schema"; import { blogArticles } from '$lib/server/db/schema';
import { redirect } from "@sveltejs/kit"; import { isAdmin, requireAdmin } from '$lib/server/auth';
import type { PageServerLoad } from "./$types.js"; 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 () => { export const load: PageServerLoad = async (event) => {
const articles = await db.select().from(blogArticles) 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 { return {
blogArticles: articles blogArticles: articles,
} isAdmin: admin
} };
};
export const actions = { export const actions = {
default: async ({ request }): Promise<Response> => { default: async (event) => {
const formData = await request.formData(); requireAdmin(event);
const blogArticle = await db.insert(blogArticles).values({ const formData = await event.request.formData();
title: formData.get('title')?.toString(), const parsed = createArticleSchema.safeParse({
slug: formData.get('slug')?.toString(), title: formData.get('title')?.toString()
content: formData.get('content')?.toString() });
}).returning();
return redirect(303, `/blog/${blogArticle[0].slug}`); 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

@@ -1,28 +1,131 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import { resolve } from '$app/paths';
let { data } = $props(); let { data } = $props();
let createNewArticlePopup = $state(false); 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> </script>
<div class="text-center py-8 gap-2 flex align-center justify-center"> <div class="text-center py-8 gap-2 flex align-center justify-center">
<h1 class="text-2xl">Blog (Stan's yapping corner)</h1> <h1 class="text-2xl">Blog (Stan's yapping corner)</h1>
<button onclick={() => createNewArticlePopup = true} class="bg-gray-800 rounded px-4 py-2 text-white font-bold">Write new article</button> {#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> </div>
{#each data.blogArticles as article (article.slug)}
{#each data.blogArticles as article} <div class="flex gap-2 px-4">
<div class="flex"> <a href={resolve('/blog/[slug]', { slug: article.slug })}>{article.title}</a>
<a href="/blog/${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> <p>{article.createdAt?.toDateString()}</p>
</div> </div>
{/each} {/each}
{#if createNewArticlePopup} {#if createNewArticlePopup}
<form method="POST" use:enhance> <div class="fixed inset-0 flex items-center justify-center bg-black/40">
<div> <form
<h2>Create New Article</h2> method="POST"
</div> use:enhance={() => {
</form> 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} {/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,22 +0,0 @@
import { db } from "$lib/db"
import { blogArticles } from "$lib/db/schema";
import { eq } from "drizzle-orm";
import type { PageServerLoad } from "./$types.js";
import { fail } from "@sveltejs/kit";
export const load: PageServerLoad = async ({params }) => {
const article = await db.select().from(blogArticles).where(eq(blogArticles.slug, params.slug))
if (article.length === 0) {
return fail(404, { message: "Article not found" });
}
return {
blogArticle: article[0]
}
}
export const actions = {
}

View File

@@ -1,2 +0,0 @@
l

View File

@@ -55,15 +55,16 @@
onMount(async () => { onMount(async () => {
const { Map, Marker, config } = await import('@maptiler/sdk'); const { Map, Marker, config } = await import('@maptiler/sdk');
const { PUBLIC_MAPTILER_API_KEY } = await import('$env/static/public'); const { env } = await import('$env/dynamic/public');
config.apiKey = PUBLIC_MAPTILER_API_KEY; config.apiKey = env.PUBLIC_MAPTILER_API_KEY;
MapMarker = Marker; MapMarker = Marker;
const initMap = (center: [number, number], zoom: number) => { const initMap = (center: [number, number], zoom: number) => {
map = new Map({ map = new Map({
container: mapContainer, container: mapContainer,
style: 'https://api.maptiler.com/maps/satellite/style.json?key=' + PUBLIC_MAPTILER_API_KEY, style:
'https://api.maptiler.com/maps/satellite/style.json?key=' + env.PUBLIC_MAPTILER_API_KEY,
center, center,
zoom zoom
}); });
@@ -217,7 +218,7 @@
<div class="absolute top-4 left-1/2 z-10 flex -translate-x-1/2 gap-2"> <div class="absolute top-4 left-1/2 z-10 flex -translate-x-1/2 gap-2">
<a <a
href={resolve('/register')} 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" 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 + Log a sighting
@@ -345,7 +346,7 @@
{/each} {/each}
</div> </div>
<a <a
href={resolve(`/register?animalId=${selected.animalId}`)} 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" 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! I saw this animal too!

View File

@@ -1,6 +1,5 @@
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { detectBreed } from '$lib/server/breed-detector';
import { z } from 'zod'; import { z } from 'zod';
const schema = z.object({ imageUrl: z.string().url() }); const schema = z.object({ imageUrl: z.string().url() });
@@ -13,6 +12,6 @@ export const POST: RequestHandler = async ({ request }) => {
throw error(400, { message: 'imageUrl is required' }); throw error(400, { message: 'imageUrl is required' });
} }
const result = await detectBreed(parsed.data.imageUrl); // const result = await detectBreed(parsed.data.imageUrl);
return json(result ?? { breed: null, confidence: 0 }); return json(result ?? { breed: null, confidence: 0 });
}; };

View File

@@ -68,12 +68,13 @@
async function initMap() { async function initMap() {
if (!mapContainer || lat === null || lng === null) return; if (!mapContainer || lat === null || lng === null) return;
const { Map, Marker, config } = await import('@maptiler/sdk'); const { Map, Marker, config } = await import('@maptiler/sdk');
const { PUBLIC_MAPTILER_API_KEY } = await import('$env/static/public'); const { env } = await import('$env/dynamic/public');
config.apiKey = PUBLIC_MAPTILER_API_KEY; config.apiKey = env.PUBLIC_MAPTILER_API_KEY;
map = new Map({ map = new Map({
container: mapContainer, container: mapContainer,
style: 'https://api.maptiler.com/maps/satellite/style.json?key=' + PUBLIC_MAPTILER_API_KEY, style:
'https://api.maptiler.com/maps/satellite/style.json?key=' + env.PUBLIC_MAPTILER_API_KEY,
center: [lng!, lat!], center: [lng!, lat!],
zoom: 15 zoom: 15
}); });

View File

@@ -1,12 +1,9 @@
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { game, pokemonGameEntry, userEntry, userGame } from '$lib/server/db/schema'; 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 { availabilityValues } from '$lib/availability';
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './catchemall/$types';
import { and, eq, inArray } from 'drizzle-orm';
// compact wire format for the ~19k game entries — full objects with repeated
// keys and availability strings add megabytes to the page payload
export type WireEntry = [id: number, gameId: number, availability: number, note?: string]; export type WireEntry = [id: number, gameId: number, availability: number, note?: string];
export const load: PageServerLoad = async ({ locals }) => { export const load: PageServerLoad = async ({ locals }) => {

View File

@@ -2,7 +2,6 @@
import { deserialize, enhance } from '$app/forms'; import { deserialize, enhance } from '$app/forms';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
import { availabilityValues, type Availability } from '$lib/availability';
import type { Game } from '$lib/server/db/schema'; import type { Game } from '$lib/server/db/schema';
type Entry = { id: number; availability: Availability; note: string | null }; type Entry = { id: number; availability: Availability; note: string | null };

View File

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

View File

@@ -1,5 +1,5 @@
import { db } from '$lib/db'; import { db } from '$lib/server/db';
import { tasks, tasksToTopics, topics } from '$lib/db/schema'; import { tasks, tasksToTopics, topics } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
export const load = async ({ cookies, params }) => { export const load = async ({ cookies, params }) => {
@@ -12,7 +12,9 @@ export const load = async ({ cookies, params }) => {
} }
} }
}, },
where: eq(tasks.id, params.id) where: {
id: Number(params.id)
}
}), }),
topics: await db.query.topics.findMany(), topics: await db.query.topics.findMany(),
editable: cookies.get('token') editable: cookies.get('token')

View File

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

View File

@@ -1,4 +1,4 @@
import { db } from '$lib/db'; import { db } from '$lib/server/db';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => { export const load: PageServerLoad = async () => {

View File

@@ -1,6 +1,4 @@
import { db } from '$lib/db'; import { db } from '$lib/server/db';
import { topics } from '$lib/db/schema';
import { eq } from 'drizzle-orm';
export const load = async ({ params }) => { export const load = async ({ params }) => {
return { return {
@@ -12,7 +10,9 @@ export const load = async ({ params }) => {
} }
} }
}, },
where: eq(topics.id, params.id) where: {
id: Number(params.id)
}
}) })
}; };
}; };

View File

@@ -1,9 +1,11 @@
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
import Icons from 'unplugin-icons/vite';
import { enhancedImages } from '@sveltejs/enhanced-img';
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), sveltekit()], plugins: [tailwindcss(), enhancedImages(), sveltekit(), Icons({ compiler: 'svelte' })],
test: { test: {
include: ['src/**/*.{test,spec}.{js,ts}'] include: ['src/**/*.{test,spec}.{js,ts}']
}, },