Compare commits

..

29 Commits

Author SHA1 Message Date
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
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
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
62ae672630 Merge pull request 'update api key imprts and change URLs' (#9) from dev into staging
All checks were successful
build / build (push) Successful in 43s
build / deploy (push) Successful in 3s
Reviewed-on: #9
2026-08-31 14:41:25 +00:00
d793400336 update api key imprts and change URLs 2026-08-31 14:40:31 +00:00
f94d68f539 Merge pull request 'dynamically import the maptiler api key' (#8) from dev into staging
All checks were successful
build / build (push) Successful in 46s
build / deploy (push) Successful in 13s
Reviewed-on: #8
2026-08-31 14:38:24 +00:00
20dddaa263 dynamically import the maptiler api key 2026-08-31 14:38:02 +00:00
3c2b626ea2 Merge pull request 'update dockerfile' (#7) from dev into staging
All checks were successful
build / build (push) Successful in 1m17s
build / deploy (push) Successful in 3s
Reviewed-on: #7
2026-08-31 14:24:48 +00:00
07f6a77fc6 update dockerfile 2026-08-31 14:24:03 +00:00
eba89011aa Merge pull request 'smaller docker image' (#6) from dev into staging
All checks were successful
build / build (push) Successful in 38s
build / deploy (push) Successful in 4s
Reviewed-on: #6
2026-08-30 22:25:18 +00:00
fe225f8a3d smaller docker image 2026-08-30 22:24:55 +00:00
c76a137090 Merge pull request 'removed auth dependency at build time' (#5) from dev into staging
Some checks failed
build / build (push) Failing after 1m19s
build / deploy (push) Has been skipped
Reviewed-on: #5
2026-08-30 22:07:07 +00:00
0e8c3c70b8 removed auth dependency at build time 2026-08-30 22:06:42 +00:00
99e97ce750 Merge pull request 'fix dockerfile with docker lockfile' (#4) from dev into staging
Some checks failed
build / build (push) Failing after 38s
build / deploy (push) Has been skipped
Reviewed-on: #4
2026-08-30 21:43:01 +00:00
077e9f54b9 fix dockerfile with docker lockfile 2026-08-30 21:42:33 +00:00
b005a39f7c Merge pull request 'fix: build errors and merging projects and homepage' (#3) from dev into staging
Some checks failed
build / build (push) Failing after 16s
build / deploy (push) Has been skipped
Reviewed-on: #3
2026-08-30 21:39:46 +00:00
c9a7294d13 fix: build errors and merging projects and homepage 2026-08-30 21:38:59 +00:00
95514c8eea Merge pull request 'dev' (#2) from dev into staging
Some checks failed
build / build (push) Failing after 23s
build / deploy (push) Has been skipped
Reviewed-on: #2
2026-08-29 11:39:42 +00:00
97ff9852cd update homepage draft and other pages 2026-08-29 11:38:16 +00:00
6452331ee7 update tailwind to v4 2026-08-27 00:50:55 +00:00
a337879875 chore: add basic readme
All checks were successful
build / build (push) Successful in 40s
build / deploy (push) Successful in 7s
2026-08-26 04:19:11 +00:00
40ff6f795b ci: pick dir by branch for staging/prod deploy
All checks were successful
build / build (push) Successful in 40s
build / deploy (push) Successful in 7s
2026-08-26 04:13:16 +00:00
cb5fc9d095 chore: flatten repo
All checks were successful
build / build (push) Successful in 47s
build / deploy (push) Successful in 17s
2026-08-26 03:57:15 +00:00
b02a07d3bd ci: update deploy command
All checks were successful
build / build (push) Successful in 11s
build / deploy (push) Successful in 3s
2026-08-26 03:22:45 +00:00
36cf013eef ci: add deploy job
Some checks failed
build / build (push) Successful in 11s
build / deploy (push) Failing after 2s
2026-08-26 03:15:41 +00:00
2bfba0d8fe ci: build and push image
All checks were successful
build / build (push) Successful in 2m54s
2026-08-24 20:47:14 +00:00
150 changed files with 16301 additions and 6196 deletions

View File

@@ -0,0 +1,40 @@
name: build
on:
push:
branches: [main, staging]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/build-push-action@v5
with:
context: .
push: false
load: true
tags: git.stanrunge.dev/stan/personal-website:${{ github.sha }}
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: setup key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H 172.17.0.1 >> ~/.ssh/known_hosts
- name: pick target
run: |
if [ "${{ github.ref_name }}" = "main" ]; then
echo "DIR=/opt/personal-website" >> $GITHUB_ENV
else
echo "DIR=/opt/personal-website-staging" >> $GITHUB_ENV
fi
- name: deploy
run: |
ssh deploy@172.17.0.1 "echo IMAGE_TAG=${{ github.sha }} | sudo tee $DIR/.env && sudo docker compose -f $DIR/compose.prod.yml up -d"

22
.gitignore vendored
View File

@@ -1 +1,21 @@
.env
node_modules
# Output
.output
.vercel
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

15
Dockerfile Executable file
View File

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

8
README.md Executable file
View File

@@ -0,0 +1,8 @@
# Stan Website
This is my personal website, including:
- Contact info / social links
- About me
- Blog posts + comments
- Projects like Animaldex, CatchEmAll

1128
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ services:
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=postgres
ports:
- "5432:5432"
- '5432:5432'
volumes:
- db-data:/var/lib/postgresql
@@ -17,7 +17,7 @@ services:
working_dir: /app
command: sh -c "bun install & bun run dev --host"
ports:
- "3000:5173"
- '3000:5173'
env_file: .env
develop:
watch:

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

8737
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

75
package.json Executable file
View File

@@ -0,0 +1,75 @@
{
"name": "web",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"test": "npm run test:integration && npm run test:unit",
"check": "svelte-kit sync && svelte-check --tsgo --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsgo --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"test:integration": "playwright test",
"test:unit": "vitest",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"db:seed": "bun scripts/seed.ts",
"db:reset": "bun scripts/reset.ts && drizzle-kit push --force && bun scripts/seed.ts",
"auth:schema": "bunx @better-auth/cli generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@iconify-json/simple-icons": "^1.2.94",
"@playwright/test": "^1.62.1",
"@sveltejs/enhanced-img": "^0.11.0",
"@sveltejs/kit": "^2.70.3",
"@sveltejs/vite-plugin-svelte": "^7.3.0",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3",
"@types/eslint": "9.6.1",
"@types/pg": "^8.23.1",
"@typescript/native": "npm:typescript@7",
"better-auth": "^1.7.2",
"bits-ui": "^2.19.0",
"clsx": "^2.1.1",
"cmdk-sv": "^0.0.19",
"drizzle-kit": "^1.0.0-rc.4",
"eslint": "^10.9.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.23.0",
"globals": "^17.11.0",
"lucide-svelte": "^1.0.1",
"prettier": "3.9.6",
"prettier-plugin-svelte": "^4.1.1",
"svelte": "^5.57.0",
"svelte-check": "4.7.6",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.3.1",
"tailwindcss": "^4.3.3",
"typescript": "~6",
"typescript-eslint": "8.68.0",
"unplugin-icons": "^23.0.1",
"vite": "^8.2.2",
"vitest": "^4.1.11"
},
"type": "module",
"dependencies": {
"@ai-sdk/anthropic": "^4.0.46",
"@aws-sdk/client-s3": "^3.1121.0",
"@aws-sdk/s3-request-presigner": "^3.1121.0",
"@maptiler/sdk": "^4.1.0",
"@sveltejs/adapter-node": "^5.5.7",
"ai": "^7.0.85",
"drizzle-orm": "^1.0.0-rc.4",
"highlight.js": "^11.12.0",
"marked": "^18.0.11",
"marked-highlight": "^2.2.4",
"mode-watcher": "^1.1.0",
"postgres": "^3.4.9",
"zod": "^4.5.4"
}
}

View File

@@ -1,21 +0,0 @@
node_modules
# Output
.output
.vercel
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

View File

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

View File

@@ -1,38 +0,0 @@
# create-svelte
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npm create svelte@latest
# create a new project in my-app
npm create svelte@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.

Binary file not shown.

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,57 +0,0 @@
{
"name": "web",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"test": "npm run test:integration && npm run test:unit",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"test:integration": "playwright test",
"test:unit": "vitest",
"generate": "drizzle-kit generate",
"migrate": "drizzle-kit migrate",
"studio": "drizzle-kit studio",
"seed": "bun src/lib/db/seed.ts"
},
"devDependencies": {
"@playwright/test": "1.49.1",
"@sveltejs/kit": "^2.15.2",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/eslint": "9.6.1",
"@types/pg": "^8.11.10",
"autoprefixer": "^10.4.20",
"bits-ui": "^1.4.7",
"clsx": "^2.1.1",
"cmdk-sv": "^0.0.18",
"drizzle-kit": "^0.30.1",
"eslint": "9.17.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-svelte": "^2.46.1",
"globals": "15.14.0",
"lucide-svelte": "^0.473.0",
"postcss": "^8.4.49",
"prettier": "3.4.2",
"prettier-plugin-svelte": "3.3.2",
"svelte": "^5.16.5",
"svelte-check": "4.1.1",
"tailwind-merge": "^2.6.0",
"tailwind-variants": "^0.3.1",
"tailwindcss": "^3.4.17",
"typescript": "5.7.2",
"typescript-eslint": "8.19.1",
"vite": "^6.0.7",
"vitest": "2.1.8"
},
"type": "module",
"dependencies": {
"@sveltejs/adapter-node": "^5.2.11",
"drizzle-orm": "^0.38.3",
"mode-watcher": "^0.5.0",
"postgres": "^3.4.5"
}
}

View File

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

View File

@@ -1,78 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 72.2% 50.6%;
--destructive-foreground: 210 40% 98%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -1,29 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-7K10F5HJMQ"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-7K10F5HJMQ');
</script>
<meta charset="utf-8" />
<link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="%sveltekit.assets%/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="%sveltekit.assets%/favicon-16x16.png">
<link rel="manifest" href="%sveltekit.assets%/site.webmanifest">
<title>Stan Runge</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

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

View File

@@ -1,25 +0,0 @@
<script lang="ts">
import { Button as ButtonPrimitive } from "bits-ui";
import { type Events, type Props, buttonVariants } from "./index.js";
import { cn } from "$lib/utils.js";
type $$Props = Props;
type $$Events = Events;
let className: $$Props["class"] = undefined;
export let variant: $$Props["variant"] = "default";
export let size: $$Props["size"] = "default";
export let builders: $$Props["builders"] = [];
export { className as class };
</script>
<ButtonPrimitive.Root
{builders}
class={cn(buttonVariants({ variant, size, className }))}
type="button"
{...$$restProps}
on:click
on:keydown
>
<slot />
</ButtonPrimitive.Root>

View File

@@ -1,49 +0,0 @@
import { type VariantProps, tv } from "tailwind-variants";
import type { Button as ButtonPrimitive } from "bits-ui";
import Root from "./button.svelte";
const buttonVariants = tv({
base: "ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border-input bg-background hover:bg-accent hover:text-accent-foreground border",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
type Variant = VariantProps<typeof buttonVariants>["variant"];
type Size = VariantProps<typeof buttonVariants>["size"];
type Props = ButtonPrimitive.Props & {
variant?: Variant;
size?: Size;
};
type Events = ButtonPrimitive.Events;
export {
Root,
type Props,
type Events,
//
Root as Button,
type Props as ButtonProps,
type Events as ButtonEvents,
buttonVariants,
};

View File

@@ -1,37 +0,0 @@
import { Command as CommandPrimitive } from "cmdk-sv";
import Root from "./command.svelte";
import Dialog from "./command-dialog.svelte";
import Empty from "./command-empty.svelte";
import Group from "./command-group.svelte";
import Item from "./command-item.svelte";
import Input from "./command-input.svelte";
import List from "./command-list.svelte";
import Separator from "./command-separator.svelte";
import Shortcut from "./command-shortcut.svelte";
const Loading = CommandPrimitive.Loading;
export {
Root,
Dialog,
Empty,
Group,
Item,
Input,
List,
Separator,
Shortcut,
Loading,
//
Root as Command,
Dialog as CommandDialog,
Empty as CommandEmpty,
Group as CommandGroup,
Item as CommandItem,
Input as CommandInput,
List as CommandList,
Separator as CommandSeparator,
Shortcut as CommandShortcut,
Loading as CommandLoading,
};

View File

@@ -1,16 +0,0 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<div
class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...$$restProps}
>
<slot />
</div>

View File

@@ -1,13 +0,0 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<div class={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...$$restProps}>
<slot />
</div>

View File

@@ -1,21 +0,0 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { fade } from "svelte/transition";
import { cn } from "$lib/utils.js";
type $$Props = DialogPrimitive.OverlayProps;
let className: $$Props["class"] = undefined;
export let transition: $$Props["transition"] = fade;
export let transitionConfig: $$Props["transitionConfig"] = {
duration: 150,
};
export { className as class };
</script>
<DialogPrimitive.Overlay
{transition}
{transitionConfig}
class={cn("bg-background/80 fixed inset-0 z-50 backdrop-blur-sm", className)}
{...$$restProps}
/>

View File

@@ -1,16 +0,0 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
type $$Props = DialogPrimitive.TitleProps;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<DialogPrimitive.Title
class={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...$$restProps}
>
<slot />
</DialogPrimitive.Title>

View File

@@ -1,22 +0,0 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
import { cn, flyAndScale } from "$lib/utils.js";
type $$Props = PopoverPrimitive.ContentProps;
let className: $$Props["class"] = undefined;
export let transition: $$Props["transition"] = flyAndScale;
export let transitionConfig: $$Props["transitionConfig"] = undefined;
export { className as class };
</script>
<PopoverPrimitive.Content
{transition}
{transitionConfig}
class={cn(
"bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-md outline-none",
className
)}
{...$$restProps}
>
<slot />
</PopoverPrimitive.Content>

View File

@@ -1,28 +0,0 @@
import Root from "./table.svelte";
import Body from "./table-body.svelte";
import Caption from "./table-caption.svelte";
import Cell from "./table-cell.svelte";
import Footer from "./table-footer.svelte";
import Head from "./table-head.svelte";
import Header from "./table-header.svelte";
import Row from "./table-row.svelte";
export {
Root,
Body,
Caption,
Cell,
Footer,
Head,
Header,
Row,
//
Root as Table,
Body as TableBody,
Caption as TableCaption,
Cell as TableCell,
Footer as TableFooter,
Head as TableHead,
Header as TableHeader,
Row as TableRow,
};

View File

@@ -1,16 +0,0 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import type { WithElementRef } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<caption bind:this={ref} class={cn("text-muted-foreground mt-4 text-sm", className)} {...restProps}>
{@render children?.()}
</caption>

View File

@@ -1,73 +0,0 @@
import { relations } from 'drizzle-orm';
import { integer, pgSchema, serial, timestamp, varchar } from 'drizzle-orm/pg-core';
export const personalWebsiteSchema = pgSchema('personal_website');
export const tasks = personalWebsiteSchema.table('tasks', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 256 }),
currentPoints: integer('current_points').default(0),
totalPoints: integer('total_points').default(1),
notes: varchar('notes', { length: 8192 }),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow()
});
export const tasksRelations = relations(tasks, ({ many }) => ({
tasksToTopics: many(tasksToTopics)
}));
export const topics = personalWebsiteSchema.table('topics', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 256 }),
emoji: varchar('emoji', { length: 256 }),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow()
});
export const topicsRelations = relations(topics, ({ many }) => ({
topicsToTasks: many(tasksToTopics)
}));
export const tasksToTopics = personalWebsiteSchema.table('tasks_to_topics', {
taskId: integer('task_id')
.notNull()
.references(() => tasks.id),
topicId: integer('topic_id')
.notNull()
.references(() => topics.id)
});
export const taskToTopicsRelations = relations(tasksToTopics, ({ one }) => ({
task: one(tasks, {
fields: [tasksToTopics.taskId],
references: [tasks.id]
}),
topic: one(topics, {
fields: [tasksToTopics.topicId],
references: [topics.id]
})
}));
export const universities = personalWebsiteSchema.table('universities', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 256 }),
progress: integer('progress').default(0),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow()
});
export const certificates = personalWebsiteSchema.table('certificates', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 256 }),
progress: integer('progress').default(0),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow()
});
export const publications = personalWebsiteSchema.table('publications', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 256 }),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow()
});

View File

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

View File

@@ -1,10 +0,0 @@
<h1 class="my-8 text-4xl font-bold text-center">Stan Runge</h1>
<img src="/moon.jpg" alt="Moon" />
<div class="flex flex-col">
<div>Vash Software</div>
<div>Hogeschool Inholland</div>
<div>Junior</div>
</div>

View File

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

View File

@@ -1,64 +0,0 @@
import { fontFamily } from "tailwindcss/defaultTheme";
/** @type {import('tailwindcss').Config} */
const config = {
darkMode: ["class"],
content: ["./src/**/*.{html,js,svelte,ts}"],
safelist: ["dark"],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px"
}
},
extend: {
colors: {
border: "hsl(var(--border) / <alpha-value>)",
input: "hsl(var(--input) / <alpha-value>)",
ring: "hsl(var(--ring) / <alpha-value>)",
background: "hsl(var(--background) / <alpha-value>)",
foreground: "hsl(var(--foreground) / <alpha-value>)",
primary: {
DEFAULT: "hsl(var(--primary) / <alpha-value>)",
foreground: "hsl(var(--primary-foreground) / <alpha-value>)"
},
secondary: {
DEFAULT: "hsl(var(--secondary) / <alpha-value>)",
foreground: "hsl(var(--secondary-foreground) / <alpha-value>)"
},
destructive: {
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)"
},
muted: {
DEFAULT: "hsl(var(--muted) / <alpha-value>)",
foreground: "hsl(var(--muted-foreground) / <alpha-value>)"
},
accent: {
DEFAULT: "hsl(var(--accent) / <alpha-value>)",
foreground: "hsl(var(--accent-foreground) / <alpha-value>)"
},
popover: {
DEFAULT: "hsl(var(--popover) / <alpha-value>)",
foreground: "hsl(var(--popover-foreground) / <alpha-value>)"
},
card: {
DEFAULT: "hsl(var(--card) / <alpha-value>)",
foreground: "hsl(var(--card-foreground) / <alpha-value>)"
}
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)"
},
fontFamily: {
sans: [...fontFamily.sans]
}
}
},
};
export default config;

View File

@@ -1,9 +0,0 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [sveltekit()],
test: {
include: ['src/**/*.{test,spec}.{js,ts}']
}
});

56
src/app.css Executable file
View File

@@ -0,0 +1,56 @@
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
@theme {
--color-bg: #f6f4f0;
--color-surface: #edeae4;
--color-surface-hover: #e4e0d8;
--color-border: #d6d2ca;
--color-border-light: #e8e5de;
--color-text: #2c2c2c;
--color-text-muted: #6b6b6b;
--color-text-dim: #999999;
--color-text-ghost: #c5c0b8;
--color-accent: #3d6b8e;
--color-accent-hover: #4d7fa6;
--color-danger: #b44;
--font-heading: 'DM Serif Display', Georgia, serif;
--font-mono: 'IBM Plex Mono', 'Courier New', monospace;
--font-body: 'Source Sans 3', 'Helvetica Neue', sans-serif;
}
@layer base {
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
scroll-behavior: smooth;
}
section[id] {
scroll-margin-top: 88px;
}
body {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-body);
font-size: 15px;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
::selection {
background: color-mix(in srgb, var(--color-accent) 20%, transparent);
}
a {
color: inherit;
text-decoration: none;
}
}

View File

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

29
src/app.html Executable file
View File

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

@@ -0,0 +1,25 @@
<script lang="ts">
import { Button as ButtonPrimitive } from 'bits-ui';
import { type Events, type Props, buttonVariants } from './index.js';
import { cn } from '$lib/utils.js';
type $$Props = Props;
type $$Events = Events;
let className: $$Props['class'] = undefined;
export let variant: $$Props['variant'] = 'default';
export let size: $$Props['size'] = 'default';
export let builders: $$Props['builders'] = [];
export { className as class };
</script>
<ButtonPrimitive.Root
{builders}
class={cn(buttonVariants({ variant, size, className }))}
type="button"
{...$$restProps}
on:click
on:keydown
>
<slot />
</ButtonPrimitive.Root>

View File

@@ -0,0 +1,48 @@
import { type VariantProps, tv } from 'tailwind-variants';
import type { Button as ButtonPrimitive } from 'bits-ui';
import Root from './button.svelte';
const buttonVariants = tv({
base: 'ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border-input bg-background hover:bg-accent hover:text-accent-foreground border',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
});
type Variant = VariantProps<typeof buttonVariants>['variant'];
type Size = VariantProps<typeof buttonVariants>['size'];
type Props = ButtonPrimitive.Props & {
variant?: Variant;
size?: Size;
};
type Events = ButtonPrimitive.Events;
export {
Root,
type Props,
type Events,
//
Root as Button,
type Props as ButtonProps,
type Events as ButtonEvents,
buttonVariants
};

View File

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

View File

@@ -1,12 +1,12 @@
<script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv";
import { cn } from "$lib/utils.js";
import { Command as CommandPrimitive } from 'cmdk-sv';
import { cn } from '$lib/utils.js';
type $$Props = CommandPrimitive.EmptyProps;
let className: string | undefined | null = undefined;
export { className as class };
</script>
<CommandPrimitive.Empty class={cn("py-6 text-center text-sm", className)} {...$$restProps}>
<CommandPrimitive.Empty class={cn('py-6 text-center text-sm', className)} {...$$restProps}>
<slot />
</CommandPrimitive.Empty>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv";
import { cn } from "$lib/utils.js";
import { Command as CommandPrimitive } from 'cmdk-sv';
import { cn } from '$lib/utils.js';
type $$Props = CommandPrimitive.GroupProps;
let className: string | undefined | null = undefined;
@@ -9,7 +9,7 @@
<CommandPrimitive.Group
class={cn(
"text-foreground [&_[data-cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[data-cmdk-group-heading]]:px-2 [&_[data-cmdk-group-heading]]:py-1.5 [&_[data-cmdk-group-heading]]:text-xs [&_[data-cmdk-group-heading]]:font-medium",
'text-foreground [&_[data-cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[data-cmdk-group-heading]]:px-2 [&_[data-cmdk-group-heading]]:py-1.5 [&_[data-cmdk-group-heading]]:text-xs [&_[data-cmdk-group-heading]]:font-medium',
className
)}
{...$$restProps}

View File

@@ -1,20 +1,20 @@
<script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv";
import Search from "lucide-svelte/icons/search";
import { cn } from "$lib/utils.js";
import { Command as CommandPrimitive } from 'cmdk-sv';
import Search from 'lucide-svelte/icons/search';
import { cn } from '$lib/utils.js';
type $$Props = CommandPrimitive.InputProps;
let className: string | undefined | null = undefined;
export { className as class };
export let value: string = "";
export let value: string = '';
</script>
<div class="flex items-center border-b px-2" data-cmdk-input-wrapper="">
<Search class="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
class={cn(
"placeholder:text-muted-foreground flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50",
'placeholder:text-muted-foreground flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...$$restProps}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv";
import { cn } from "$lib/utils.js";
import { Command as CommandPrimitive } from 'cmdk-sv';
import { cn } from '$lib/utils.js';
type $$Props = CommandPrimitive.ItemProps;
@@ -13,7 +13,7 @@
<CommandPrimitive.Item
{asChild}
class={cn(
"aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
'aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...$$restProps}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv";
import { cn } from "$lib/utils.js";
import { Command as CommandPrimitive } from 'cmdk-sv';
import { cn } from '$lib/utils.js';
type $$Props = CommandPrimitive.ListProps;
let className: string | undefined | null = undefined;
@@ -8,7 +8,7 @@
</script>
<CommandPrimitive.List
class={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
class={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...$$restProps}
>
<slot />

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv";
import { cn } from "$lib/utils.js";
import { Command as CommandPrimitive } from 'cmdk-sv';
import { cn } from '$lib/utils.js';
type $$Props = CommandPrimitive.SeparatorProps;
let className: string | undefined | null = undefined;
export { className as class };
</script>
<CommandPrimitive.Separator class={cn("bg-border -mx-1 h-px", className)} {...$$restProps} />
<CommandPrimitive.Separator class={cn('bg-border -mx-1 h-px', className)} {...$$restProps} />

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils.js';
type $$Props = HTMLAttributes<HTMLSpanElement>;
@@ -9,7 +9,7 @@
</script>
<span
class={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
class={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...$$restProps}
>
<slot />

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv";
import { cn } from "$lib/utils.js";
import { Command as CommandPrimitive } from 'cmdk-sv';
import { cn } from '$lib/utils.js';
type $$Props = CommandPrimitive.CommandProps;
export let value: $$Props["value"] = undefined;
export let value: $$Props['value'] = undefined;
let className: string | undefined | null = undefined;
export { className as class };
@@ -12,7 +12,7 @@
<CommandPrimitive.Root
class={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
className
)}
bind:value

View File

@@ -0,0 +1,37 @@
import { Command as CommandPrimitive } from 'cmdk-sv';
import Root from './command.svelte';
import Dialog from './command-dialog.svelte';
import Empty from './command-empty.svelte';
import Group from './command-group.svelte';
import Item from './command-item.svelte';
import Input from './command-input.svelte';
import List from './command-list.svelte';
import Separator from './command-separator.svelte';
import Shortcut from './command-shortcut.svelte';
const Loading = CommandPrimitive.Loading;
export {
Root,
Dialog,
Empty,
Group,
Item,
Input,
List,
Separator,
Shortcut,
Loading,
//
Root as Command,
Dialog as CommandDialog,
Empty as CommandEmpty,
Group as CommandGroup,
Item as CommandItem,
Input as CommandInput,
List as CommandList,
Separator as CommandSeparator,
Shortcut as CommandShortcut,
Loading as CommandLoading
};

View File

@@ -1,15 +1,15 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import X from "lucide-svelte/icons/x";
import * as Dialog from "./index.js";
import { cn, flyAndScale } from "$lib/utils.js";
import { Dialog as DialogPrimitive } from 'bits-ui';
import X from 'lucide-svelte/icons/x';
import * as Dialog from './index.js';
import { cn, flyAndScale } from '$lib/utils.js';
type $$Props = DialogPrimitive.ContentProps;
let className: $$Props["class"] = undefined;
export let transition: $$Props["transition"] = flyAndScale;
export let transitionConfig: $$Props["transitionConfig"] = {
duration: 200,
let className: $$Props['class'] = undefined;
export let transition: $$Props['transition'] = flyAndScale;
export let transitionConfig: $$Props['transitionConfig'] = {
duration: 200
};
export { className as class };
</script>
@@ -20,7 +20,7 @@
{transition}
{transitionConfig}
class={cn(
"bg-background fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg md:w-full",
'bg-background fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg md:w-full',
className
)}
{...$$restProps}

View File

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

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils.js';
type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<div
class={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...$$restProps}
>
<slot />
</div>

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils.js';
type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<div class={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...$$restProps}>
<slot />
</div>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import { fade } from 'svelte/transition';
import { cn } from '$lib/utils.js';
type $$Props = DialogPrimitive.OverlayProps;
let className: $$Props['class'] = undefined;
export let transition: $$Props['transition'] = fade;
export let transitionConfig: $$Props['transitionConfig'] = {
duration: 150
};
export { className as class };
</script>
<DialogPrimitive.Overlay
{transition}
{transitionConfig}
class={cn('bg-background/80 fixed inset-0 z-50 backdrop-blur-sm', className)}
{...$$restProps}
/>

View File

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

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
type $$Props = DialogPrimitive.TitleProps;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<DialogPrimitive.Title
class={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...$$restProps}
>
<slot />
</DialogPrimitive.Title>

View File

@@ -1,12 +1,12 @@
import { Dialog as DialogPrimitive } from "bits-ui";
import { Dialog as DialogPrimitive } from 'bits-ui';
import Title from "./dialog-title.svelte";
import Portal from "./dialog-portal.svelte";
import Footer from "./dialog-footer.svelte";
import Header from "./dialog-header.svelte";
import Overlay from "./dialog-overlay.svelte";
import Content from "./dialog-content.svelte";
import Description from "./dialog-description.svelte";
import Title from './dialog-title.svelte';
import Portal from './dialog-portal.svelte';
import Footer from './dialog-footer.svelte';
import Header from './dialog-header.svelte';
import Overlay from './dialog-overlay.svelte';
import Content from './dialog-content.svelte';
import Description from './dialog-description.svelte';
const Root = DialogPrimitive.Root;
const Trigger = DialogPrimitive.Trigger;
@@ -33,5 +33,5 @@ export {
Overlay as DialogOverlay,
Content as DialogContent,
Description as DialogDescription,
Close as DialogClose,
Close as DialogClose
};

View File

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

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from 'bits-ui';
import { cn, flyAndScale } from '$lib/utils.js';
type $$Props = PopoverPrimitive.ContentProps;
let className: $$Props['class'] = undefined;
export let transition: $$Props['transition'] = flyAndScale;
export let transitionConfig: $$Props['transitionConfig'] = undefined;
export { className as class };
</script>
<PopoverPrimitive.Content
{transition}
{transitionConfig}
class={cn(
'bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-md outline-none',
className
)}
{...$$restProps}
>
<slot />
</PopoverPrimitive.Content>

View File

@@ -0,0 +1,28 @@
import Root from './table.svelte';
import Body from './table-body.svelte';
import Caption from './table-caption.svelte';
import Cell from './table-cell.svelte';
import Footer from './table-footer.svelte';
import Head from './table-head.svelte';
import Header from './table-header.svelte';
import Row from './table-row.svelte';
export {
Root,
Body,
Caption,
Cell,
Footer,
Head,
Header,
Row,
//
Root as Table,
Body as TableBody,
Caption as TableCaption,
Cell as TableCell,
Footer as TableFooter,
Head as TableHead,
Header as TableHeader,
Row as TableRow
};

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import type { WithElementRef } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { HTMLAttributes } from 'svelte/elements';
import type { WithElementRef } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -11,6 +11,6 @@
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script>
<tbody bind:this={ref} class={cn("[&_tr:last-child]:border-0", className)} {...restProps}>
<tbody bind:this={ref} class={cn('[&_tr:last-child]:border-0', className)} {...restProps}>
{@render children?.()}
</tbody>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import type { WithElementRef } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<caption bind:this={ref} class={cn('text-muted-foreground mt-4 text-sm', className)} {...restProps}>
{@render children?.()}
</caption>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import type { HTMLTdAttributes } from "svelte/elements";
import type { WithElementRef } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { HTMLTdAttributes } from 'svelte/elements';
import type { WithElementRef } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -13,7 +13,7 @@
<td
bind:this={ref}
class={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
class={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
{...restProps}
>
{@render children?.()}

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import type { WithElementRef } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { HTMLAttributes } from 'svelte/elements';
import type { WithElementRef } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -11,6 +11,6 @@
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script>
<tfoot bind:this={ref} class={cn("bg-muted/50 font-medium", className)} {...restProps}>
<tfoot bind:this={ref} class={cn('bg-muted/50 font-medium', className)} {...restProps}>
{@render children?.()}
</tfoot>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import type { HTMLThAttributes } from "svelte/elements";
import type { WithElementRef } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { HTMLThAttributes } from 'svelte/elements';
import type { WithElementRef } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -14,7 +14,7 @@
<th
bind:this={ref}
class={cn(
"text-muted-foreground h-12 px-4 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0",
'text-muted-foreground h-12 px-4 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0',
className
)}
{...restProps}

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import type { WithElementRef } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { HTMLAttributes } from 'svelte/elements';
import type { WithElementRef } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -11,6 +11,6 @@
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script>
<thead bind:this={ref} class={cn("[&_tr]:border-b", className)} {...restProps}>
<thead bind:this={ref} class={cn('[&_tr]:border-b', className)} {...restProps}>
{@render children?.()}
</thead>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import type { WithElementRef } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { HTMLAttributes } from 'svelte/elements';
import type { WithElementRef } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -14,7 +14,7 @@
<tr
bind:this={ref}
class={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
className
)}
{...restProps}

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import type { HTMLTableAttributes } from "svelte/elements";
import type { WithElementRef } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { HTMLTableAttributes } from 'svelte/elements';
import type { WithElementRef } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -12,7 +12,7 @@
</script>
<div class="relative w-full overflow-auto">
<table bind:this={ref} class={cn("w-full caption-bottom text-sm", className)} {...restProps}>
<table bind:this={ref} class={cn('w-full caption-bottom text-sm', className)} {...restProps}>
{@render children?.()}
</table>
</div>

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -1,11 +1,14 @@
import { topics } from "./schema"
import * as schema from './schema'
import { topics } from './schema';
import * as schema from './schema';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
const client = postgres('postgresql://postgres:postgres@localhost:5432/db');
const db = drizzle(client, { schema });
await db.insert(topics).values([{ name: 'Math', emoji: '👍' }, { name: 'Programming', emoji: '👍' }])
await db.insert(topics).values([
{ name: 'Math', emoji: '👍' },
{ name: 'Programming', emoji: '👍' }
]);
process.exit(0)
process.exit(0);

View File

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

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

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

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

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

View File

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

View File

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

View File

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

78
src/routes/+layout.svelte Executable file
View File

@@ -0,0 +1,78 @@
<script lang="ts">
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();
const links = [
{ icon: SiMail, label: 'Email', href: 'mailto:stan@stanrunge.dev' },
{ icon: SiGithub, label: 'GitHub', href: 'https://github.com/stanrunge' },
{ icon: SiLinkedin, label: 'LinkedIn', href: 'https://linkedin.com/in/stanrunge' },
{ icon: SiX, label: 'X', href: 'https://x.com/stanrunge' },
{ icon: SiYoutube, label: 'YouTube', href: 'https://youtube.com/@stanrunge' },
{ icon: SiInstagram, label: 'Instagram', href: 'https://instagram.com/stan_runge' },
// { icon: SiBluesky, label: 'Bluesky', href: 'https://bsky.app/profile/stanrunge.bsky.social' },
// { icon: SiThreads, label: 'Threads', href: 'https://www.threads.com/@stan_runge' },
{ icon: SiTwitch, label: 'Twitch', href: 'https://twitch.tv/stanrunge_' },
{
icon: SiSpotify,
label: 'Spotify',
href: 'https://open.spotify.com/user/e3o3qr9z7fpsu4gur9zz4y1ld'
},
{ icon: SiSteam, label: 'Steam', href: 'https://steamcommunity.com/id/stanrunge' },
{ icon: SiOsu, label: 'osu!', href: 'https://osu.ppy.sh/users/11212255' }
];
const copies = [
{ icon: SiWhatsapp, label: 'WhatsApp', value: 'stanrunge' },
{ icon: SiDiscord, label: 'Discord', value: 'stanrunge' },
{ icon: SiSignal, label: 'Signal', value: 'stan.443' }
];
</script>
<div class="flex flex-col min-h-screen">
<header class="flex justify-between items-center gap-4 py-4 px-4">
<a href={resolve('/')} class="text-2xl font-bold">Stan Runge</a>
<nav class="flex flex-wrap justify-center gap-4" aria-label="Social links">
{#each copies as c (c.label)}
<CopyButton {...c} />
{/each}
{#each links as { icon: Icon, label, href } (label)}
<a {href} aria-label={label} class="text-2xl transition hover:opacity-60">
<Icon />
</a>
{/each}
</nav>
</header>
<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>

106
src/routes/+page.svelte Executable file
View File

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

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