base website #10
@@ -9,16 +9,11 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: git.stanrunge.dev
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
|
||||||
|
|
||||||
- uses: docker/build-push-action@v5
|
- uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: false
|
||||||
|
load: true
|
||||||
tags: git.stanrunge.dev/stan/personal-website:${{ github.sha }}
|
tags: git.stanrunge.dev/stan/personal-website:${{ github.sha }}
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
|
|||||||
18
Dockerfile
18
Dockerfile
@@ -1,25 +1,15 @@
|
|||||||
FROM oven/bun AS builder
|
FROM oven/bun AS builder
|
||||||
|
|
||||||
RUN bun
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json bun.lockb ./
|
COPY package.json bun.lock ./
|
||||||
|
|
||||||
RUN bun install
|
RUN bun install
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|
||||||
RUN bun run build
|
RUN bun run build
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
FROM oven/bun
|
FROM oven/bun
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /app ./
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/build ./build
|
||||||
|
COPY package.json ./
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
CMD ["bun", "build/index.js"]
|
CMD ["bun", "build/index.js"]
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
# Stan Website
|
# Stan Website
|
||||||
|
|
||||||
This is my personal website, including:
|
This is my personal website, including:
|
||||||
|
|
||||||
- Contact info / social links
|
- Contact info / social links
|
||||||
- About me
|
- About me
|
||||||
- Blog posts + comments
|
- Blog posts + comments
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ services:
|
|||||||
- POSTGRES_PASSWORD=postgres
|
- POSTGRES_PASSWORD=postgres
|
||||||
- POSTGRES_DB=postgres
|
- POSTGRES_DB=postgres
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- '5432:5432'
|
||||||
volumes:
|
volumes:
|
||||||
- db-data:/var/lib/postgresql
|
- db-data:/var/lib/postgresql
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ services:
|
|||||||
working_dir: /app
|
working_dir: /app
|
||||||
command: sh -c "bun install & bun run dev --host"
|
command: sh -c "bun install & bun run dev --host"
|
||||||
ports:
|
ports:
|
||||||
- "3000:5173"
|
- '3000:5173'
|
||||||
env_file: .env
|
env_file: .env
|
||||||
develop:
|
develop:
|
||||||
watch:
|
watch:
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ import { defineConfig } from 'drizzle-kit';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
dialect: 'postgresql',
|
dialect: 'postgresql',
|
||||||
schema: './src/lib/db/schema.ts',
|
schema: './src/lib/server/db/schema.ts',
|
||||||
out: './drizzle',
|
out: './drizzle',
|
||||||
verbose: true,
|
verbose: true,
|
||||||
strict: true,
|
strict: true,
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: process.env.DB_URL
|
url: process.env.DB_URL
|
||||||
},
|
}
|
||||||
schemaFilter: 'personal_website'
|
|
||||||
});
|
});
|
||||||
|
|||||||
198
drizzle/20260830223753_illegal_lord_tyger/migration.sql
Normal file
198
drizzle/20260830223753_illegal_lord_tyger/migration.sql
Normal 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;
|
||||||
2251
drizzle/20260830223753_illegal_lord_tyger/snapshot.json
Normal file
2251
drizzle/20260830223753_illegal_lord_tyger/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -21,17 +21,6 @@ export default [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['**/*.svelte'],
|
files: ['**/*.svelte'],
|
||||||
|
|
||||||
rules: {
|
|
||||||
'@typescript-eslint/explicit-function-return-type': [
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
allowExpressions: false,
|
|
||||||
allowTypedFunctionExpressions: true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
parser: ts.parser
|
parser: ts.parser
|
||||||
@@ -42,15 +31,6 @@ export default [
|
|||||||
files: ['**/*.ts'],
|
files: ['**/*.ts'],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: globals.node
|
globals: globals.node
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
'@typescript-eslint/explicit-function-return-type': [
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
allowExpressions: false,
|
|
||||||
allowTypedFunctionExpressions: true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 $$;
|
|
||||||
@@ -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": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "7",
|
|
||||||
"dialect": "postgresql",
|
|
||||||
"entries": [
|
|
||||||
{
|
|
||||||
"idx": 0,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1725668865024,
|
|
||||||
"tag": "0000_famous_random",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
8671
package-lock.json
generated
8671
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
84
package.json
84
package.json
@@ -7,51 +7,65 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "npm run test:integration && npm run test:unit",
|
"test": "npm run test:integration && npm run test:unit",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsgo --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsgo --tsconfig ./tsconfig.json --watch",
|
||||||
"lint": "prettier --check . && eslint .",
|
"lint": "prettier --check . && eslint .",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"test:integration": "playwright test",
|
"test:integration": "playwright test",
|
||||||
"test:unit": "vitest",
|
"test:unit": "vitest",
|
||||||
"generate": "drizzle-kit generate",
|
"db:push": "drizzle-kit push",
|
||||||
"migrate": "drizzle-kit migrate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"studio": "drizzle-kit studio",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"seed": "bun src/lib/db/seed.ts"
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"db:seed": "bun scripts/seed.ts",
|
||||||
|
"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": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.49.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@sveltejs/kit": "^2.15.2",
|
"@iconify-json/simple-icons": "^1.2.94",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@playwright/test": "^1.62.1",
|
||||||
|
"@sveltejs/enhanced-img": "^0.11.0",
|
||||||
|
"@sveltejs/kit": "^2.70.3",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@types/eslint": "9.6.1",
|
"@types/eslint": "9.6.1",
|
||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.23.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"@typescript/native": "npm:typescript@7",
|
||||||
"bits-ui": "^1.4.7",
|
"better-auth": "^1.7.2",
|
||||||
|
"bits-ui": "^2.19.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk-sv": "^0.0.18",
|
"cmdk-sv": "^0.0.19",
|
||||||
"drizzle-kit": "^0.30.1",
|
"drizzle-kit": "^1.0.0-rc.4",
|
||||||
"eslint": "9.17.0",
|
"eslint": "^10.9.1",
|
||||||
"eslint-config-prettier": "9.1.0",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-svelte": "^2.46.1",
|
"eslint-plugin-svelte": "^3.23.0",
|
||||||
"globals": "15.14.0",
|
"globals": "^17.11.0",
|
||||||
"lucide-svelte": "^0.473.0",
|
"lucide-svelte": "^1.0.1",
|
||||||
"postcss": "^8.4.49",
|
"prettier": "3.9.6",
|
||||||
"prettier": "3.4.2",
|
"prettier-plugin-svelte": "^4.1.1",
|
||||||
"prettier-plugin-svelte": "3.3.2",
|
"svelte": "^5.57.0",
|
||||||
"svelte": "^5.16.5",
|
"svelte-check": "4.7.6",
|
||||||
"svelte-check": "4.1.1",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-variants": "^3.3.1",
|
||||||
"tailwind-variants": "^0.3.1",
|
"tailwindcss": "^4.3.3",
|
||||||
"tailwindcss": "^3.4.17",
|
"typescript": "~6",
|
||||||
"typescript": "5.7.2",
|
"typescript-eslint": "8.68.0",
|
||||||
"typescript-eslint": "8.19.1",
|
"unplugin-icons": "^23.0.1",
|
||||||
"vite": "^6.0.7",
|
"vite": "^8.2.2",
|
||||||
"vitest": "2.1.8"
|
"vitest": "^4.1.11"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sveltejs/adapter-node": "^5.2.11",
|
"@ai-sdk/anthropic": "^4.0.46",
|
||||||
"drizzle-orm": "^0.38.3",
|
"@aws-sdk/client-s3": "^3.1121.0",
|
||||||
"mode-watcher": "^0.5.0",
|
"@aws-sdk/s3-request-presigner": "^3.1121.0",
|
||||||
"postgres": "^3.4.5"
|
"@maptiler/sdk": "^4.1.0",
|
||||||
|
"@sveltejs/adapter-node": "^5.5.7",
|
||||||
|
"ai": "^7.0.85",
|
||||||
|
"drizzle-orm": "^1.0.0-rc.4",
|
||||||
|
"mode-watcher": "^1.1.0",
|
||||||
|
"postgres": "^3.4.9",
|
||||||
|
"zod": "^4.5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
113
src/app.css
113
src/app.css
@@ -1,78 +1,55 @@
|
|||||||
@tailwind base;
|
@import 'tailwindcss';
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
@layer base {
|
@theme {
|
||||||
:root {
|
--color-bg: #f6f4f0;
|
||||||
--background: 0 0% 100%;
|
--color-surface: #edeae4;
|
||||||
--foreground: 222.2 84% 4.9%;
|
--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;
|
||||||
|
|
||||||
--muted: 210 40% 96.1%;
|
--font-heading: 'DM Serif Display', Georgia, serif;
|
||||||
--muted-foreground: 215.4 16.3% 46.9%;
|
--font-mono: 'IBM Plex Mono', 'Courier New', monospace;
|
||||||
|
--font-body: 'Source Sans 3', 'Helvetica Neue', sans-serif;
|
||||||
--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 {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border;
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
section[id] {
|
||||||
|
scroll-margin-top: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.65;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
2
src/app.d.ts
vendored
2
src/app.d.ts
vendored
@@ -1,3 +1,5 @@
|
|||||||
|
/// <reference types="unplugin-icons/types/svelte" />
|
||||||
|
|
||||||
// See https://kit.svelte.dev/docs/types#app
|
// See https://kit.svelte.dev/docs/types#app
|
||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
14
src/app.html
14
src/app.html
@@ -1,22 +1,23 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<!-- Google tag (gtag.js) -->
|
<!-- Google tag (gtag.js) -->
|
||||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-7K10F5HJMQ"></script>
|
<script async src="https://www.googletagmanager.com/gtag/js?id=G-7K10F5HJMQ"></script>
|
||||||
<script>
|
<script>
|
||||||
window.dataLayer = window.dataLayer || [];
|
window.dataLayer = window.dataLayer || [];
|
||||||
function gtag() {dataLayer.push(arguments);}
|
function gtag() {
|
||||||
|
dataLayer.push(arguments);
|
||||||
|
}
|
||||||
gtag('js', new Date());
|
gtag('js', new Date());
|
||||||
|
|
||||||
gtag('config', 'G-7K10F5HJMQ');
|
gtag('config', 'G-7K10F5HJMQ');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/apple-touch-icon.png">
|
<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="32x32" href="%sveltekit.assets%/favicon-32x32.png" />
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="%sveltekit.assets%/favicon-16x16.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="%sveltekit.assets%/favicon-16x16.png" />
|
||||||
<link rel="manifest" href="%sveltekit.assets%/site.webmanifest">
|
<link rel="manifest" href="%sveltekit.assets%/site.webmanifest" />
|
||||||
<title>Stan Runge</title>
|
<title>Stan Runge</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
@@ -25,5 +26,4 @@
|
|||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
|
|
||||||
describe('sum test', () => {
|
|
||||||
it('adds 1 + 2 to equal 3', () => {
|
|
||||||
expect(1 + 2).toBe(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
BIN
src/lib/assets/stan.jpg
Normal file
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
12
src/lib/availability.ts
Normal 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];
|
||||||
51
src/lib/components/CopyButton.svelte
Normal file
51
src/lib/components/CopyButton.svelte
Normal 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>
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button as ButtonPrimitive } from "bits-ui";
|
import { Button as ButtonPrimitive } from 'bits-ui';
|
||||||
import { type Events, type Props, buttonVariants } from "./index.js";
|
import { type Events, type Props, buttonVariants } from './index.js';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
type $$Props = Props;
|
type $$Props = Props;
|
||||||
type $$Events = Events;
|
type $$Events = Events;
|
||||||
|
|
||||||
let className: $$Props["class"] = undefined;
|
let className: $$Props['class'] = undefined;
|
||||||
export let variant: $$Props["variant"] = "default";
|
export let variant: $$Props['variant'] = 'default';
|
||||||
export let size: $$Props["size"] = "default";
|
export let size: $$Props['size'] = 'default';
|
||||||
export let builders: $$Props["builders"] = [];
|
export let builders: $$Props['builders'] = [];
|
||||||
export { className as class };
|
export { className as class };
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,33 @@
|
|||||||
import { type VariantProps, tv } from "tailwind-variants";
|
import { type VariantProps, tv } from 'tailwind-variants';
|
||||||
import type { Button as ButtonPrimitive } from "bits-ui";
|
import type { Button as ButtonPrimitive } from 'bits-ui';
|
||||||
import Root from "./button.svelte";
|
import Root from './button.svelte';
|
||||||
|
|
||||||
const buttonVariants = tv({
|
const buttonVariants = tv({
|
||||||
base: "ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
base: 'ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
outline:
|
outline: 'border-input bg-background hover:bg-accent hover:text-accent-foreground border',
|
||||||
"border-input bg-background hover:bg-accent hover:text-accent-foreground border",
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
link: 'text-primary underline-offset-4 hover:underline'
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-10 px-4 py-2",
|
default: 'h-10 px-4 py-2',
|
||||||
sm: "h-9 rounded-md px-3",
|
sm: 'h-9 rounded-md px-3',
|
||||||
lg: "h-11 rounded-md px-8",
|
lg: 'h-11 rounded-md px-8',
|
||||||
icon: "h-10 w-10",
|
icon: 'h-10 w-10'
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: 'default',
|
||||||
size: "default",
|
size: 'default'
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
type Variant = VariantProps<typeof buttonVariants>["variant"];
|
type Variant = VariantProps<typeof buttonVariants>['variant'];
|
||||||
type Size = VariantProps<typeof buttonVariants>["size"];
|
type Size = VariantProps<typeof buttonVariants>['size'];
|
||||||
|
|
||||||
type Props = ButtonPrimitive.Props & {
|
type Props = ButtonPrimitive.Props & {
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
@@ -45,5 +44,5 @@ export {
|
|||||||
Root as Button,
|
Root as Button,
|
||||||
type Props as ButtonProps,
|
type Props as ButtonProps,
|
||||||
type Events as ButtonEvents,
|
type Events as ButtonEvents,
|
||||||
buttonVariants,
|
buttonVariants
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Dialog as DialogPrimitive } from "bits-ui";
|
import type { Dialog as DialogPrimitive } from 'bits-ui';
|
||||||
import type { Command as CommandPrimitive } from "cmdk-sv";
|
import type { Command as CommandPrimitive } from 'cmdk-sv';
|
||||||
import Command from "./command.svelte";
|
import Command from './command.svelte';
|
||||||
import * as Dialog from "$lib/components/ui/dialog/index.js";
|
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||||
|
|
||||||
type $$Props = DialogPrimitive.Props & CommandPrimitive.CommandProps;
|
type $$Props = DialogPrimitive.Props & CommandPrimitive.CommandProps;
|
||||||
|
|
||||||
export let open: $$Props["open"] = false;
|
export let open: $$Props['open'] = false;
|
||||||
export let value: $$Props["value"] = undefined;
|
export let value: $$Props['value'] = undefined;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root bind:open {...$$restProps}>
|
<Dialog.Root bind:open {...$$restProps}>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
import { Command as CommandPrimitive } from 'cmdk-sv';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
type $$Props = CommandPrimitive.EmptyProps;
|
type $$Props = CommandPrimitive.EmptyProps;
|
||||||
let className: string | undefined | null = undefined;
|
let className: string | undefined | null = undefined;
|
||||||
export { className as class };
|
export { className as class };
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CommandPrimitive.Empty class={cn("py-6 text-center text-sm", className)} {...$$restProps}>
|
<CommandPrimitive.Empty class={cn('py-6 text-center text-sm', className)} {...$$restProps}>
|
||||||
<slot />
|
<slot />
|
||||||
</CommandPrimitive.Empty>
|
</CommandPrimitive.Empty>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
import { Command as CommandPrimitive } from 'cmdk-sv';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
type $$Props = CommandPrimitive.GroupProps;
|
type $$Props = CommandPrimitive.GroupProps;
|
||||||
|
|
||||||
let className: string | undefined | null = undefined;
|
let className: string | undefined | null = undefined;
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<CommandPrimitive.Group
|
<CommandPrimitive.Group
|
||||||
class={cn(
|
class={cn(
|
||||||
"text-foreground [&_[data-cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[data-cmdk-group-heading]]:px-2 [&_[data-cmdk-group-heading]]:py-1.5 [&_[data-cmdk-group-heading]]:text-xs [&_[data-cmdk-group-heading]]:font-medium",
|
'text-foreground [&_[data-cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[data-cmdk-group-heading]]:px-2 [&_[data-cmdk-group-heading]]:py-1.5 [&_[data-cmdk-group-heading]]:text-xs [&_[data-cmdk-group-heading]]:font-medium',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...$$restProps}
|
{...$$restProps}
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
import { Command as CommandPrimitive } from 'cmdk-sv';
|
||||||
import Search from "lucide-svelte/icons/search";
|
import Search from 'lucide-svelte/icons/search';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
type $$Props = CommandPrimitive.InputProps;
|
type $$Props = CommandPrimitive.InputProps;
|
||||||
|
|
||||||
let className: string | undefined | null = undefined;
|
let className: string | undefined | null = undefined;
|
||||||
export { className as class };
|
export { className as class };
|
||||||
export let value: string = "";
|
export let value: string = '';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex items-center border-b px-2" data-cmdk-input-wrapper="">
|
<div class="flex items-center border-b px-2" data-cmdk-input-wrapper="">
|
||||||
<Search class="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
<Search class="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
<CommandPrimitive.Input
|
<CommandPrimitive.Input
|
||||||
class={cn(
|
class={cn(
|
||||||
"placeholder:text-muted-foreground flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
'placeholder:text-muted-foreground flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...$$restProps}
|
{...$$restProps}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
import { Command as CommandPrimitive } from 'cmdk-sv';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
type $$Props = CommandPrimitive.ItemProps;
|
type $$Props = CommandPrimitive.ItemProps;
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<CommandPrimitive.Item
|
<CommandPrimitive.Item
|
||||||
{asChild}
|
{asChild}
|
||||||
class={cn(
|
class={cn(
|
||||||
"aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
'aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...$$restProps}
|
{...$$restProps}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
import { Command as CommandPrimitive } from 'cmdk-sv';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
type $$Props = CommandPrimitive.ListProps;
|
type $$Props = CommandPrimitive.ListProps;
|
||||||
let className: string | undefined | null = undefined;
|
let className: string | undefined | null = undefined;
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CommandPrimitive.List
|
<CommandPrimitive.List
|
||||||
class={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
class={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
|
||||||
{...$$restProps}
|
{...$$restProps}
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
import { Command as CommandPrimitive } from 'cmdk-sv';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
type $$Props = CommandPrimitive.SeparatorProps;
|
type $$Props = CommandPrimitive.SeparatorProps;
|
||||||
let className: string | undefined | null = undefined;
|
let className: string | undefined | null = undefined;
|
||||||
export { className as class };
|
export { className as class };
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CommandPrimitive.Separator class={cn("bg-border -mx-1 h-px", className)} {...$$restProps} />
|
<CommandPrimitive.Separator class={cn('bg-border -mx-1 h-px', className)} {...$$restProps} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
type $$Props = HTMLAttributes<HTMLSpanElement>;
|
type $$Props = HTMLAttributes<HTMLSpanElement>;
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
class={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
|
class={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
|
||||||
{...$$restProps}
|
{...$$restProps}
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
import { Command as CommandPrimitive } from 'cmdk-sv';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
type $$Props = CommandPrimitive.CommandProps;
|
type $$Props = CommandPrimitive.CommandProps;
|
||||||
|
|
||||||
export let value: $$Props["value"] = undefined;
|
export let value: $$Props['value'] = undefined;
|
||||||
|
|
||||||
let className: string | undefined | null = undefined;
|
let className: string | undefined | null = undefined;
|
||||||
export { className as class };
|
export { className as class };
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
<CommandPrimitive.Root
|
<CommandPrimitive.Root
|
||||||
class={cn(
|
class={cn(
|
||||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
bind:value
|
bind:value
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
import { Command as CommandPrimitive } from 'cmdk-sv';
|
||||||
|
|
||||||
import Root from "./command.svelte";
|
import Root from './command.svelte';
|
||||||
import Dialog from "./command-dialog.svelte";
|
import Dialog from './command-dialog.svelte';
|
||||||
import Empty from "./command-empty.svelte";
|
import Empty from './command-empty.svelte';
|
||||||
import Group from "./command-group.svelte";
|
import Group from './command-group.svelte';
|
||||||
import Item from "./command-item.svelte";
|
import Item from './command-item.svelte';
|
||||||
import Input from "./command-input.svelte";
|
import Input from './command-input.svelte';
|
||||||
import List from "./command-list.svelte";
|
import List from './command-list.svelte';
|
||||||
import Separator from "./command-separator.svelte";
|
import Separator from './command-separator.svelte';
|
||||||
import Shortcut from "./command-shortcut.svelte";
|
import Shortcut from './command-shortcut.svelte';
|
||||||
|
|
||||||
const Loading = CommandPrimitive.Loading;
|
const Loading = CommandPrimitive.Loading;
|
||||||
|
|
||||||
@@ -33,5 +33,5 @@ export {
|
|||||||
List as CommandList,
|
List as CommandList,
|
||||||
Separator as CommandSeparator,
|
Separator as CommandSeparator,
|
||||||
Shortcut as CommandShortcut,
|
Shortcut as CommandShortcut,
|
||||||
Loading as CommandLoading,
|
Loading as CommandLoading
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||||
import X from "lucide-svelte/icons/x";
|
import X from 'lucide-svelte/icons/x';
|
||||||
import * as Dialog from "./index.js";
|
import * as Dialog from './index.js';
|
||||||
import { cn, flyAndScale } from "$lib/utils.js";
|
import { cn, flyAndScale } from '$lib/utils.js';
|
||||||
|
|
||||||
type $$Props = DialogPrimitive.ContentProps;
|
type $$Props = DialogPrimitive.ContentProps;
|
||||||
|
|
||||||
let className: $$Props["class"] = undefined;
|
let className: $$Props['class'] = undefined;
|
||||||
export let transition: $$Props["transition"] = flyAndScale;
|
export let transition: $$Props['transition'] = flyAndScale;
|
||||||
export let transitionConfig: $$Props["transitionConfig"] = {
|
export let transitionConfig: $$Props['transitionConfig'] = {
|
||||||
duration: 200,
|
duration: 200
|
||||||
};
|
};
|
||||||
export { className as class };
|
export { className as class };
|
||||||
</script>
|
</script>
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
{transition}
|
{transition}
|
||||||
{transitionConfig}
|
{transitionConfig}
|
||||||
class={cn(
|
class={cn(
|
||||||
"bg-background fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg md:w-full",
|
'bg-background fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg md:w-full',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...$$restProps}
|
{...$$restProps}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
type $$Props = DialogPrimitive.DescriptionProps;
|
type $$Props = DialogPrimitive.DescriptionProps;
|
||||||
|
|
||||||
let className: $$Props["class"] = undefined;
|
let className: $$Props['class'] = undefined;
|
||||||
export { className as class };
|
export { className as class };
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<DialogPrimitive.Description
|
<DialogPrimitive.Description
|
||||||
class={cn("text-muted-foreground text-sm", className)}
|
class={cn('text-muted-foreground text-sm', className)}
|
||||||
{...$$restProps}
|
{...$$restProps}
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
type $$Props = HTMLAttributes<HTMLDivElement>;
|
type $$Props = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
let className: $$Props["class"] = undefined;
|
let className: $$Props['class'] = undefined;
|
||||||
export { className as class };
|
export { className as class };
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
class={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||||
{...$$restProps}
|
{...$$restProps}
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
type $$Props = HTMLAttributes<HTMLDivElement>;
|
type $$Props = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
let className: $$Props["class"] = undefined;
|
let className: $$Props['class'] = undefined;
|
||||||
export { className as class };
|
export { className as class };
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...$$restProps}>
|
<div class={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...$$restProps}>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||||
import { fade } from "svelte/transition";
|
import { fade } from 'svelte/transition';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
type $$Props = DialogPrimitive.OverlayProps;
|
type $$Props = DialogPrimitive.OverlayProps;
|
||||||
|
|
||||||
let className: $$Props["class"] = undefined;
|
let className: $$Props['class'] = undefined;
|
||||||
export let transition: $$Props["transition"] = fade;
|
export let transition: $$Props['transition'] = fade;
|
||||||
export let transitionConfig: $$Props["transitionConfig"] = {
|
export let transitionConfig: $$Props['transitionConfig'] = {
|
||||||
duration: 150,
|
duration: 150
|
||||||
};
|
};
|
||||||
export { className as class };
|
export { className as class };
|
||||||
</script>
|
</script>
|
||||||
@@ -16,6 +16,6 @@
|
|||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
{transition}
|
{transition}
|
||||||
{transitionConfig}
|
{transitionConfig}
|
||||||
class={cn("bg-background/80 fixed inset-0 z-50 backdrop-blur-sm", className)}
|
class={cn('bg-background/80 fixed inset-0 z-50 backdrop-blur-sm', className)}
|
||||||
{...$$restProps}
|
{...$$restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||||
type $$Props = DialogPrimitive.PortalProps;
|
type $$Props = DialogPrimitive.PortalProps;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
type $$Props = DialogPrimitive.TitleProps;
|
type $$Props = DialogPrimitive.TitleProps;
|
||||||
|
|
||||||
let className: $$Props["class"] = undefined;
|
let className: $$Props['class'] = undefined;
|
||||||
export { className as class };
|
export { className as class };
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
class={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
class={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||||
{...$$restProps}
|
{...$$restProps}
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
import Title from "./dialog-title.svelte";
|
import Title from './dialog-title.svelte';
|
||||||
import Portal from "./dialog-portal.svelte";
|
import Portal from './dialog-portal.svelte';
|
||||||
import Footer from "./dialog-footer.svelte";
|
import Footer from './dialog-footer.svelte';
|
||||||
import Header from "./dialog-header.svelte";
|
import Header from './dialog-header.svelte';
|
||||||
import Overlay from "./dialog-overlay.svelte";
|
import Overlay from './dialog-overlay.svelte';
|
||||||
import Content from "./dialog-content.svelte";
|
import Content from './dialog-content.svelte';
|
||||||
import Description from "./dialog-description.svelte";
|
import Description from './dialog-description.svelte';
|
||||||
|
|
||||||
const Root = DialogPrimitive.Root;
|
const Root = DialogPrimitive.Root;
|
||||||
const Trigger = DialogPrimitive.Trigger;
|
const Trigger = DialogPrimitive.Trigger;
|
||||||
@@ -33,5 +33,5 @@ export {
|
|||||||
Overlay as DialogOverlay,
|
Overlay as DialogOverlay,
|
||||||
Content as DialogContent,
|
Content as DialogContent,
|
||||||
Description as DialogDescription,
|
Description as DialogDescription,
|
||||||
Close as DialogClose,
|
Close as DialogClose
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||||
import Content from "./popover-content.svelte";
|
import Content from './popover-content.svelte';
|
||||||
const Root = PopoverPrimitive.Root;
|
const Root = PopoverPrimitive.Root;
|
||||||
const Trigger = PopoverPrimitive.Trigger;
|
const Trigger = PopoverPrimitive.Trigger;
|
||||||
const Close = PopoverPrimitive.Close;
|
const Close = PopoverPrimitive.Close;
|
||||||
@@ -13,5 +13,5 @@ export {
|
|||||||
Root as Popover,
|
Root as Popover,
|
||||||
Content as PopoverContent,
|
Content as PopoverContent,
|
||||||
Trigger as PopoverTrigger,
|
Trigger as PopoverTrigger,
|
||||||
Close as PopoverClose,
|
Close as PopoverClose
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||||
import { cn, flyAndScale } from "$lib/utils.js";
|
import { cn, flyAndScale } from '$lib/utils.js';
|
||||||
|
|
||||||
type $$Props = PopoverPrimitive.ContentProps;
|
type $$Props = PopoverPrimitive.ContentProps;
|
||||||
let className: $$Props["class"] = undefined;
|
let className: $$Props['class'] = undefined;
|
||||||
export let transition: $$Props["transition"] = flyAndScale;
|
export let transition: $$Props['transition'] = flyAndScale;
|
||||||
export let transitionConfig: $$Props["transitionConfig"] = undefined;
|
export let transitionConfig: $$Props['transitionConfig'] = undefined;
|
||||||
export { className as class };
|
export { className as class };
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
{transition}
|
{transition}
|
||||||
{transitionConfig}
|
{transitionConfig}
|
||||||
class={cn(
|
class={cn(
|
||||||
"bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-md outline-none",
|
'bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-md outline-none',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...$$restProps}
|
{...$$restProps}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import Root from "./table.svelte";
|
import Root from './table.svelte';
|
||||||
import Body from "./table-body.svelte";
|
import Body from './table-body.svelte';
|
||||||
import Caption from "./table-caption.svelte";
|
import Caption from './table-caption.svelte';
|
||||||
import Cell from "./table-cell.svelte";
|
import Cell from './table-cell.svelte';
|
||||||
import Footer from "./table-footer.svelte";
|
import Footer from './table-footer.svelte';
|
||||||
import Head from "./table-head.svelte";
|
import Head from './table-head.svelte';
|
||||||
import Header from "./table-header.svelte";
|
import Header from './table-header.svelte';
|
||||||
import Row from "./table-row.svelte";
|
import Row from './table-row.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
@@ -24,5 +24,5 @@ export {
|
|||||||
Footer as TableFooter,
|
Footer as TableFooter,
|
||||||
Head as TableHead,
|
Head as TableHead,
|
||||||
Header as TableHeader,
|
Header as TableHeader,
|
||||||
Row as TableRow,
|
Row as TableRow
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import type { WithElementRef } from "bits-ui";
|
import type { WithElementRef } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -11,6 +11,6 @@
|
|||||||
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<tbody bind:this={ref} class={cn("[&_tr:last-child]:border-0", className)} {...restProps}>
|
<tbody bind:this={ref} class={cn('[&_tr:last-child]:border-0', className)} {...restProps}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import type { WithElementRef } from "bits-ui";
|
import type { WithElementRef } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -11,6 +11,6 @@
|
|||||||
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<caption bind:this={ref} class={cn("text-muted-foreground mt-4 text-sm", className)} {...restProps}>
|
<caption bind:this={ref} class={cn('text-muted-foreground mt-4 text-sm', className)} {...restProps}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</caption>
|
</caption>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLTdAttributes } from "svelte/elements";
|
import type { HTMLTdAttributes } from 'svelte/elements';
|
||||||
import type { WithElementRef } from "bits-ui";
|
import type { WithElementRef } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
<td
|
<td
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
class={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
class={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import type { WithElementRef } from "bits-ui";
|
import type { WithElementRef } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -11,6 +11,6 @@
|
|||||||
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<tfoot bind:this={ref} class={cn("bg-muted/50 font-medium", className)} {...restProps}>
|
<tfoot bind:this={ref} class={cn('bg-muted/50 font-medium', className)} {...restProps}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</tfoot>
|
</tfoot>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLThAttributes } from "svelte/elements";
|
import type { HTMLThAttributes } from 'svelte/elements';
|
||||||
import type { WithElementRef } from "bits-ui";
|
import type { WithElementRef } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<th
|
<th
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
class={cn(
|
class={cn(
|
||||||
"text-muted-foreground h-12 px-4 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0",
|
'text-muted-foreground h-12 px-4 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import type { WithElementRef } from "bits-ui";
|
import type { WithElementRef } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -11,6 +11,6 @@
|
|||||||
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<thead bind:this={ref} class={cn("[&_tr]:border-b", className)} {...restProps}>
|
<thead bind:this={ref} class={cn('[&_tr]:border-b', className)} {...restProps}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import type { WithElementRef } from "bits-ui";
|
import type { WithElementRef } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<tr
|
<tr
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
class={cn(
|
class={cn(
|
||||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLTableAttributes } from "svelte/elements";
|
import type { HTMLTableAttributes } from 'svelte/elements';
|
||||||
import type { WithElementRef } from "bits-ui";
|
import type { WithElementRef } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative w-full overflow-auto">
|
<div class="relative w-full overflow-auto">
|
||||||
<table bind:this={ref} class={cn("w-full caption-bottom text-sm", className)} {...restProps}>
|
<table bind:this={ref} class={cn('w-full caption-bottom text-sm', className)} {...restProps}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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()
|
|
||||||
});
|
|
||||||
38
src/lib/server/auth.ts
Normal file
38
src/lib/server/auth.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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 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)) {
|
||||||
|
throw new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/lib/server/breed-detector.ts
Normal file
50
src/lib/server/breed-detector.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/lib/server/db/auth.schema.ts
Normal file
73
src/lib/server/db/auth.schema.ts
Normal 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)]
|
||||||
|
);
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import * as schema from './schema';
|
import { relations } from './relations';
|
||||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
import postgres from 'postgres';
|
import postgres from 'postgres';
|
||||||
|
|
||||||
const client = postgres(env.DB_URL, { prepare: false });
|
const client = postgres(env.DB_URL, { prepare: false });
|
||||||
|
|
||||||
export const db = drizzle({
|
export const db = drizzle({
|
||||||
schema,
|
relations,
|
||||||
client
|
client
|
||||||
});
|
});
|
||||||
99
src/lib/server/db/relations.ts
Normal file
99
src/lib/server/db/relations.ts
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}));
|
||||||
232
src/lib/server/db/schema.ts
Executable file
232
src/lib/server/db/schema.ts
Executable file
@@ -0,0 +1,232 @@
|
|||||||
|
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 blogArticles = pgTable('blog_articles', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
title: varchar('title', { length: 256 }),
|
||||||
|
slug: varchar('slug', { length: 256 }),
|
||||||
|
content: varchar('content', { length: 8192 }),
|
||||||
|
createdAt: timestamp('created_at').defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow()
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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';
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { topics } from "./schema"
|
import { topics } from './schema';
|
||||||
import * as schema from './schema'
|
import * as schema from './schema';
|
||||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
import postgres from 'postgres';
|
import postgres from 'postgres';
|
||||||
|
|
||||||
const client = postgres('postgresql://postgres:postgres@localhost:5432/db');
|
const client = postgres('postgresql://postgres:postgres@localhost:5432/db');
|
||||||
const db = drizzle(client, { schema });
|
const db = drizzle(client, { schema });
|
||||||
|
|
||||||
await db.insert(topics).values([{ name: 'Math', emoji: '👍' }, { name: 'Programming', emoji: '👍' }])
|
await db.insert(topics).values([
|
||||||
|
{ name: 'Math', emoji: '👍' },
|
||||||
|
{ name: 'Programming', emoji: '👍' }
|
||||||
|
]);
|
||||||
|
|
||||||
process.exit(0)
|
process.exit(0);
|
||||||
86
src/lib/server/r2.ts
Normal file
86
src/lib/server/r2.ts
Normal 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 }));
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type ClassValue, clsx } from "clsx";
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from 'tailwind-merge';
|
||||||
import { cubicOut } from "svelte/easing";
|
import { cubicOut } from 'svelte/easing';
|
||||||
import type { TransitionConfig } from "svelte/transition";
|
import type { TransitionConfig } from 'svelte/transition';
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
@@ -19,13 +19,9 @@ export const flyAndScale = (
|
|||||||
params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 }
|
params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 }
|
||||||
): TransitionConfig => {
|
): TransitionConfig => {
|
||||||
const style = getComputedStyle(node);
|
const style = getComputedStyle(node);
|
||||||
const transform = style.transform === "none" ? "" : style.transform;
|
const transform = style.transform === 'none' ? '' : style.transform;
|
||||||
|
|
||||||
const scaleConversion = (
|
const scaleConversion = (valueA: number, scaleA: [number, number], scaleB: [number, number]) => {
|
||||||
valueA: number,
|
|
||||||
scaleA: [number, number],
|
|
||||||
scaleB: [number, number]
|
|
||||||
) => {
|
|
||||||
const [minA, maxA] = scaleA;
|
const [minA, maxA] = scaleA;
|
||||||
const [minB, maxB] = scaleB;
|
const [minB, maxB] = scaleB;
|
||||||
|
|
||||||
@@ -35,13 +31,11 @@ export const flyAndScale = (
|
|||||||
return valueB;
|
return valueB;
|
||||||
};
|
};
|
||||||
|
|
||||||
const styleToString = (
|
const styleToString = (style: Record<string, number | string | undefined>): string => {
|
||||||
style: Record<string, number | string | undefined>
|
|
||||||
): string => {
|
|
||||||
return Object.keys(style).reduce((str, key) => {
|
return Object.keys(style).reduce((str, key) => {
|
||||||
if (style[key] === undefined) return str;
|
if (style[key] === undefined) return str;
|
||||||
return str + `${key}:${style[key]};`;
|
return str + `${key}:${style[key]};`;
|
||||||
}, "");
|
}, '');
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
76
src/lib/validation/sighting.ts
Normal file
76
src/lib/validation/sighting.ts
Normal 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 (1–10 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'])
|
||||||
|
});
|
||||||
@@ -1,108 +1,65 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import { ModeWatcher } from 'mode-watcher';
|
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
|
|
||||||
interface Props {
|
import SiOsu from '~icons/simple-icons/osu';
|
||||||
children?: import('svelte').Snippet;
|
import SiSteam from '~icons/simple-icons/steam';
|
||||||
}
|
import SiTwitch from '~icons/simple-icons/twitch';
|
||||||
const { children, data }: Props = $props();
|
import SiSpotify from '~icons/simple-icons/spotify';
|
||||||
|
import SiGithub from '~icons/simple-icons/github';
|
||||||
|
import SiYoutube from '~icons/simple-icons/youtube';
|
||||||
|
import SiLinkedin from '~icons/simple-icons/linkedin';
|
||||||
|
import SiInstagram from '~icons/simple-icons/instagram';
|
||||||
|
import SiX from '~icons/simple-icons/x';
|
||||||
|
// import SiBluesky from '~icons/simple-icons/bluesky';
|
||||||
|
// import SiThreads from '~icons/simple-icons/threads';
|
||||||
|
import SiMail from '~icons/simple-icons/gmail';
|
||||||
|
|
||||||
let editModalVisible = $state(false);
|
import CopyButton from '$lib/components/CopyButton.svelte';
|
||||||
|
import SiDiscord from '~icons/simple-icons/discord';
|
||||||
|
import SiSignal from '~icons/simple-icons/signal';
|
||||||
|
import SiWhatsapp from '~icons/simple-icons/whatsapp';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ icon: SiMail, label: 'Email', href: 'mailto:stan@stanrunge.dev' },
|
||||||
|
{ icon: SiGithub, label: 'GitHub', href: 'https://github.com/stanrunge' },
|
||||||
|
{ icon: SiLinkedin, label: 'LinkedIn', href: 'https://linkedin.com/in/stanrunge' },
|
||||||
|
{ icon: SiX, label: 'X', href: 'https://x.com/stanrunge' },
|
||||||
|
{ icon: SiYoutube, label: 'YouTube', href: 'https://youtube.com/@stanrunge' },
|
||||||
|
{ icon: SiInstagram, label: 'Instagram', href: 'https://instagram.com/stan_runge' },
|
||||||
|
// { icon: SiBluesky, label: 'Bluesky', href: 'https://bsky.app/profile/stanrunge.bsky.social' },
|
||||||
|
// { icon: SiThreads, label: 'Threads', href: 'https://www.threads.com/@stan_runge' },
|
||||||
|
{ icon: SiTwitch, label: 'Twitch', href: 'https://twitch.tv/stanrunge_' },
|
||||||
|
{
|
||||||
|
icon: SiSpotify,
|
||||||
|
label: 'Spotify',
|
||||||
|
href: 'https://open.spotify.com/user/e3o3qr9z7fpsu4gur9zz4y1ld'
|
||||||
|
},
|
||||||
|
{ icon: SiSteam, label: 'Steam', href: 'https://steamcommunity.com/id/stanrunge' },
|
||||||
|
{ icon: SiOsu, label: 'osu!', href: 'https://osu.ppy.sh/users/11212255' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const copies = [
|
||||||
|
{ icon: SiWhatsapp, label: 'WhatsApp', value: 'stanrunge' },
|
||||||
|
{ icon: SiDiscord, label: 'Discord', value: 'stanrunge' },
|
||||||
|
{ icon: SiSignal, label: 'Signal', value: 'stan.443' }
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<header class="flex justify-between items-center gap-4 py-4 px-4">
|
||||||
class="min-h-screen bg-black text-white bg-center bg-[url('/moon.jpg')] bg-auto bg-no-repeat flex flex-col"
|
<a href={resolve('/')} class="text-2xl font-bold">Stan Runge</a>
|
||||||
>
|
<nav class="flex flex-wrap justify-center gap-4" aria-label="Social links">
|
||||||
<nav class="flex justify-between bg-gray-800 items-center px-4 py-2">
|
{#each copies as c (c.label)}
|
||||||
<div class="m-2">
|
<CopyButton {...c} />
|
||||||
<a href="/" class="font-bold text-xl sm:text-2xl">Stan Runge</a>
|
{/each}
|
||||||
</div>
|
{#each links as { icon: Icon, label, href } (label)}
|
||||||
<div class="m-2 flex gap-4 items-center">
|
<a {href} aria-label={label} class="text-2xl transition hover:opacity-60">
|
||||||
<a href="/progress" class="hover:underline">Progress</a>
|
<Icon />
|
||||||
<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>
|
||||||
<a href="https://github.com/stanrunge" class="hover:text-gray-300" aria-label="GitHub">
|
{/each}
|
||||||
<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>
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
<!-- page content goes here -->
|
{@render children()}
|
||||||
<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}
|
|
||||||
|
|||||||
@@ -1,10 +1,106 @@
|
|||||||
<h1 class="my-8 text-4xl font-bold text-center">Stan Runge</h1>
|
<script lang="ts">
|
||||||
<img src="/moon.jpg" alt="Moon" />
|
import { resolve } from '$app/paths';
|
||||||
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col">
|
<div class="text-center py-2 font-bold text-xl underline">
|
||||||
<div>Vash Software</div>
|
<a href={resolve('/blog')}>Blog</a>
|
||||||
|
</div>
|
||||||
<div>Hogeschool Inholland</div>
|
|
||||||
|
<div class="flex justify-around py-8">
|
||||||
<div>Junior</div>
|
<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>
|
</div>
|
||||||
|
|||||||
29
src/routes/blog/+page.server.ts
Normal file
29
src/routes/blog/+page.server.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { blogArticles } from '$lib/server/db/schema';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types.js';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
const articles = await db.select().from(blogArticles);
|
||||||
|
|
||||||
|
return {
|
||||||
|
blogArticles: articles
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
default: async ({ request }): Promise<Response> => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
const blogArticle = await db
|
||||||
|
.insert(blogArticles)
|
||||||
|
.values({
|
||||||
|
title: formData.get('title')?.toString(),
|
||||||
|
slug: formData.get('slug')?.toString(),
|
||||||
|
content: formData.get('content')?.toString()
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return redirect(303, `/blog/${blogArticle[0].slug}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
31
src/routes/blog/+page.svelte
Normal file
31
src/routes/blog/+page.svelte
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
let createNewArticlePopup = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="text-center py-8 gap-2 flex align-center justify-center">
|
||||||
|
<h1 class="text-2xl">Blog (Stan's yapping corner)</h1>
|
||||||
|
<button
|
||||||
|
onclick={() => (createNewArticlePopup = true)}
|
||||||
|
class="bg-gray-800 rounded px-4 py-2 text-white font-bold">Write new article</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each data.blogArticles as article (article.slug)}
|
||||||
|
<div class="flex">
|
||||||
|
<a href={resolve('/blog/{slug}', { slug: article.slug })}>{article.title}</a>
|
||||||
|
<p>{article.createdAt?.toDateString()}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if createNewArticlePopup}
|
||||||
|
<form method="POST" use:enhance>
|
||||||
|
<div>
|
||||||
|
<h2>Create New Article</h2>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
20
src/routes/blog/{slug}/+page.server.ts
Normal file
20
src/routes/blog/{slug}/+page.server.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { blogArticles } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import type { PageServerLoad } from './$types.js';
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
|
const article = await db.select().from(blogArticles).where(eq(blogArticles.slug, params.slug));
|
||||||
|
|
||||||
|
if (article.length === 0) {
|
||||||
|
return fail(404, { message: 'Article not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
blogArticle: article[0]
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions = {};
|
||||||
1
src/routes/blog/{slug}/+page.svelte
Normal file
1
src/routes/blog/{slug}/+page.svelte
Normal file
@@ -0,0 +1 @@
|
|||||||
|
bu
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
15
src/routes/projects/animaldex/+layout.svelte
Normal file
15
src/routes/projects/animaldex/+layout.svelte
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import './layout.css';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Animaldex" />
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
</svelte:head>
|
||||||
|
{@render children()}
|
||||||
356
src/routes/projects/animaldex/+page.svelte
Normal file
356
src/routes/projects/animaldex/+page.svelte
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import type { Map as MaptilerMap, Marker as MaptilerMarker } from '@maptiler/sdk';
|
||||||
|
|
||||||
|
interface TrailPoint {
|
||||||
|
id: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
seenAt: string;
|
||||||
|
reporterName: string | null;
|
||||||
|
photoUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnimalFeatureProps {
|
||||||
|
animalId: string;
|
||||||
|
species: string;
|
||||||
|
breed: string | null;
|
||||||
|
animalName: string | null;
|
||||||
|
sightingCount: number;
|
||||||
|
trail: string | TrailPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeoJSONFeature {
|
||||||
|
type: 'Feature';
|
||||||
|
geometry: { type: 'Point'; coordinates: [number, number] };
|
||||||
|
properties: AnimalFeatureProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mapContainer: HTMLDivElement;
|
||||||
|
let map: MaptilerMap | null = null;
|
||||||
|
let htmlMarkers: MaptilerMarker[] = [];
|
||||||
|
let MapMarker: typeof MaptilerMarker | null = null;
|
||||||
|
|
||||||
|
let filterSpecies = $state('');
|
||||||
|
let filterBreed = $state('');
|
||||||
|
let filterName = $state('');
|
||||||
|
let filterReporter = $state('');
|
||||||
|
let filterFrom = $state('');
|
||||||
|
let filterTo = $state('');
|
||||||
|
let showFilters = $state(false);
|
||||||
|
|
||||||
|
let selected = $state<{
|
||||||
|
animalId: string;
|
||||||
|
species: string;
|
||||||
|
breed: string | null;
|
||||||
|
animalName: string | null;
|
||||||
|
sightingCount: number;
|
||||||
|
trail: TrailPoint[];
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
function parseTrail(raw: string | TrailPoint[]): TrailPoint[] {
|
||||||
|
return typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const { Map, Marker, config } = await import('@maptiler/sdk');
|
||||||
|
const { env } = await import('$env/dynamic/public');
|
||||||
|
|
||||||
|
config.apiKey = env.PUBLIC_MAPTILER_API_KEY;
|
||||||
|
MapMarker = Marker;
|
||||||
|
|
||||||
|
const initMap = (center: [number, number], zoom: number) => {
|
||||||
|
map = new Map({
|
||||||
|
container: mapContainer,
|
||||||
|
style: 'https://api.maptiler.com/maps/satellite/style.json?key=' + env.PUBLIC_MAPTILER_API_KEY,
|
||||||
|
center,
|
||||||
|
zoom
|
||||||
|
});
|
||||||
|
|
||||||
|
map.on('load', () => {
|
||||||
|
map!.addSource('trails', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: { type: 'FeatureCollection', features: [] }
|
||||||
|
});
|
||||||
|
map!.addLayer({
|
||||||
|
id: 'trails',
|
||||||
|
type: 'line',
|
||||||
|
source: 'trails',
|
||||||
|
paint: {
|
||||||
|
'line-color': '#f97316',
|
||||||
|
'line-width': 2,
|
||||||
|
'line-dasharray': [2, 2],
|
||||||
|
'line-opacity': 0.7
|
||||||
|
}
|
||||||
|
});
|
||||||
|
loadMarkers(Marker);
|
||||||
|
});
|
||||||
|
|
||||||
|
map.on('moveend', () => {
|
||||||
|
if (MapMarker) loadMarkers(MapMarker);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => initMap([pos.coords.longitude, pos.coords.latitude], 13),
|
||||||
|
() => initMap([4.708, 52.009], 15.5),
|
||||||
|
{ enableHighAccuracy: true, timeout: 5000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
htmlMarkers.forEach((m) => m.remove());
|
||||||
|
map?.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadMarkers(Marker: typeof MaptilerMarker) {
|
||||||
|
if (!map) return;
|
||||||
|
const bounds = map.getBounds();
|
||||||
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
minLat: String(bounds.getSouth()),
|
||||||
|
minLng: String(bounds.getWest()),
|
||||||
|
maxLat: String(bounds.getNorth()),
|
||||||
|
maxLng: String(bounds.getEast())
|
||||||
|
});
|
||||||
|
if (filterSpecies) params.set('species', filterSpecies);
|
||||||
|
if (filterBreed) params.set('breed', filterBreed);
|
||||||
|
if (filterName) params.set('name', filterName);
|
||||||
|
if (filterReporter) params.set('reporter', filterReporter);
|
||||||
|
if (filterFrom) params.set('fromDate', new Date(filterFrom).toISOString());
|
||||||
|
if (filterTo) params.set('toDate', new Date(filterTo).toISOString());
|
||||||
|
|
||||||
|
const res = await fetch(`/api/map?${params}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const geojson: { features: GeoJSONFeature[] } = await res.json();
|
||||||
|
|
||||||
|
htmlMarkers.forEach((m) => m.remove());
|
||||||
|
htmlMarkers = [];
|
||||||
|
|
||||||
|
const lineFeatures = geojson.features
|
||||||
|
.filter((f) => parseTrail(f.properties.trail).length > 1)
|
||||||
|
.map((f) => {
|
||||||
|
const trail = parseTrail(f.properties.trail);
|
||||||
|
return {
|
||||||
|
type: 'Feature' as const,
|
||||||
|
geometry: {
|
||||||
|
type: 'LineString' as const,
|
||||||
|
coordinates: [...trail]
|
||||||
|
.sort((a, b) => new Date(a.seenAt).getTime() - new Date(b.seenAt).getTime())
|
||||||
|
.map((s) => [s.lng, s.lat])
|
||||||
|
},
|
||||||
|
properties: {}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const trailSource = map?.getSource('trails');
|
||||||
|
// @ts-expect-error setData exists on GeoJSONSource
|
||||||
|
trailSource?.setData({ type: 'FeatureCollection', features: lineFeatures });
|
||||||
|
|
||||||
|
for (const feature of geojson.features) {
|
||||||
|
const props = feature.properties;
|
||||||
|
const trail = parseTrail(props.trail);
|
||||||
|
const photoUrl = trail[0]?.photoUrl ?? null;
|
||||||
|
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.style.cssText = `
|
||||||
|
width: 44px; height: 44px; border-radius: 50%;
|
||||||
|
border: 3px solid white;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||||
|
cursor: pointer; overflow: hidden;
|
||||||
|
background: #f97316;
|
||||||
|
`;
|
||||||
|
if (photoUrl) {
|
||||||
|
el.style.backgroundImage = `url(${photoUrl})`;
|
||||||
|
el.style.backgroundSize = 'cover';
|
||||||
|
el.style.backgroundPosition = 'center';
|
||||||
|
} else {
|
||||||
|
el.innerHTML = `<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:18px">🐾</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.onclick = () => {
|
||||||
|
selected = {
|
||||||
|
animalId: props.animalId,
|
||||||
|
species: props.species,
|
||||||
|
breed: props.breed,
|
||||||
|
animalName: props.animalName,
|
||||||
|
sightingCount: props.sightingCount,
|
||||||
|
trail
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const marker = new Marker({ element: el })
|
||||||
|
.setLngLat(feature.geometry.coordinates)
|
||||||
|
.addTo(map!);
|
||||||
|
htmlMarkers.push(marker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
showFilters = false;
|
||||||
|
if (MapMarker) loadMarkers(MapMarker);
|
||||||
|
}
|
||||||
|
function clearFilters() {
|
||||||
|
filterSpecies = '';
|
||||||
|
filterBreed = '';
|
||||||
|
filterName = '';
|
||||||
|
filterReporter = '';
|
||||||
|
filterFrom = '';
|
||||||
|
filterTo = '';
|
||||||
|
if (MapMarker) loadMarkers(MapMarker);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeFilterCount = $derived(
|
||||||
|
[filterSpecies, filterBreed, filterName, filterReporter, filterFrom, filterTo].filter(Boolean)
|
||||||
|
.length
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Animaldex</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.maptiler.com/maptiler-sdk-js/latest/maptiler-sdk.css" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="relative h-screen w-full overflow-hidden">
|
||||||
|
<div bind:this={mapContainer} class="h-full w-full"></div>
|
||||||
|
|
||||||
|
<div class="absolute top-4 left-1/2 z-10 flex -translate-x-1/2 gap-2">
|
||||||
|
<a
|
||||||
|
href={resolve('/projects/animaldex/register')}
|
||||||
|
class="flex items-center gap-2 rounded-full bg-orange-500 px-4 py-2 text-sm font-semibold text-white shadow-lg hover:bg-orange-600"
|
||||||
|
>
|
||||||
|
+ Log a sighting
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onclick={() => (showFilters = !showFilters)}
|
||||||
|
class="relative rounded-full bg-white px-4 py-2 text-sm font-semibold text-gray-700 shadow-lg hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Filters
|
||||||
|
{#if activeFilterCount > 0}
|
||||||
|
<span
|
||||||
|
class="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-orange-500 text-[10px] text-white"
|
||||||
|
>{activeFilterCount}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showFilters}
|
||||||
|
<div
|
||||||
|
class="absolute top-16 left-1/2 z-20 w-80 -translate-x-1/2 rounded-2xl bg-white p-4 shadow-xl"
|
||||||
|
>
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<h2 class="font-semibold text-gray-800">Filter sightings</h2>
|
||||||
|
<button onclick={() => (showFilters = false)} class="text-gray-400 hover:text-gray-600"
|
||||||
|
>✕</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<input
|
||||||
|
bind:value={filterSpecies}
|
||||||
|
placeholder="Species"
|
||||||
|
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
bind:value={filterBreed}
|
||||||
|
placeholder="Breed"
|
||||||
|
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
bind:value={filterName}
|
||||||
|
placeholder="Animal name"
|
||||||
|
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
bind:value={filterReporter}
|
||||||
|
placeholder="Reporter name"
|
||||||
|
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||||
|
/>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label for="filter-from" class="mb-1 block text-xs text-gray-500">From</label>
|
||||||
|
<input
|
||||||
|
id="filter-from"
|
||||||
|
type="date"
|
||||||
|
bind:value={filterFrom}
|
||||||
|
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="filter-to" class="mb-1 block text-xs text-gray-500">To</label>
|
||||||
|
<input
|
||||||
|
id="filter-to"
|
||||||
|
type="date"
|
||||||
|
bind:value={filterTo}
|
||||||
|
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
onclick={applyFilters}
|
||||||
|
class="flex-1 rounded-lg bg-orange-500 py-2 text-sm font-semibold text-white hover:bg-orange-600"
|
||||||
|
>Apply</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onclick={clearFilters}
|
||||||
|
class="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50"
|
||||||
|
>Clear</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if selected}
|
||||||
|
<div class="absolute right-4 bottom-8 z-10 w-80 overflow-hidden rounded-2xl bg-white shadow-xl">
|
||||||
|
{#if selected.trail[0]?.photoUrl}
|
||||||
|
<div class="relative h-40 bg-gray-100">
|
||||||
|
<img src={selected.trail[0].photoUrl} alt="" class="h-full w-full object-cover" />
|
||||||
|
<button
|
||||||
|
onclick={() => (selected = null)}
|
||||||
|
class="absolute top-2 right-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/50 text-white"
|
||||||
|
>✕</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="mb-1 flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-lg font-semibold text-gray-900">
|
||||||
|
{selected.animalName ?? selected.species}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
{selected.species}{selected.breed ? ` · ${selected.breed}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="rounded-full bg-orange-100 px-2 py-0.5 text-xs font-medium text-orange-700"
|
||||||
|
>{selected.sightingCount}
|
||||||
|
{selected.sightingCount === 1 ? 'sighting' : 'sightings'}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 max-h-36 space-y-1.5 overflow-y-auto">
|
||||||
|
{#each [...selected.trail].sort((a, b) => new Date(b.seenAt).getTime() - new Date(a.seenAt).getTime()) as s (s.id)}
|
||||||
|
<div class="flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2 text-xs">
|
||||||
|
<span class="text-gray-600"
|
||||||
|
>{new Date(s.seenAt).toLocaleDateString('nl-NL', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric'
|
||||||
|
})}</span
|
||||||
|
>
|
||||||
|
{#if s.reporterName}<span class="text-gray-400">by {s.reporterName}</span>{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={resolve(`/projects/animaldex/register?animalId=${selected.animalId}`)}
|
||||||
|
class="mt-3 block w-full rounded-lg bg-orange-500 py-2 text-center text-sm font-semibold text-white hover:bg-orange-600"
|
||||||
|
>
|
||||||
|
I saw this animal too!
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
1
src/routes/projects/animaldex/+page.ts
Normal file
1
src/routes/projects/animaldex/+page.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const ssr = false;
|
||||||
53
src/routes/projects/animaldex/admin/+page.server.ts
Normal file
53
src/routes/projects/animaldex/admin/+page.server.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { animals, sightings } from '$lib/server/db/schema';
|
||||||
|
import { isAdmin } from '$lib/server/auth';
|
||||||
|
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
const admin = isAdmin(event);
|
||||||
|
if (!admin) return { admin: false, queue: [] };
|
||||||
|
|
||||||
|
const pending = await db
|
||||||
|
.select({
|
||||||
|
animalId: animals.id,
|
||||||
|
species: animals.species,
|
||||||
|
breed: animals.breed,
|
||||||
|
animalName: animals.animalName,
|
||||||
|
description: animals.description,
|
||||||
|
aiBreedSuggestion: animals.aiBreedSuggestion,
|
||||||
|
aiBreedConfidence: animals.aiBreedConfidence,
|
||||||
|
submittedAt: animals.submittedAt,
|
||||||
|
acceptedAt: animals.acceptedAt,
|
||||||
|
deniedAt: animals.deniedAt,
|
||||||
|
sightingId: sightings.id,
|
||||||
|
sightingLat: sightings.lat,
|
||||||
|
sightingLng: sightings.lng,
|
||||||
|
seenAt: sightings.seenAt,
|
||||||
|
reporterName: sightings.reporterName,
|
||||||
|
sightingAcceptedAt: sightings.acceptedAt,
|
||||||
|
sightingDeniedAt: sightings.deniedAt,
|
||||||
|
photoUrl: sql<string>`(
|
||||||
|
SELECT url FROM photos
|
||||||
|
WHERE sighting_id = ${sightings.id}
|
||||||
|
ORDER BY sort_order ASC
|
||||||
|
LIMIT 1
|
||||||
|
)`
|
||||||
|
})
|
||||||
|
.from(animals)
|
||||||
|
.innerJoin(
|
||||||
|
sightings,
|
||||||
|
and(
|
||||||
|
eq(sightings.animalId, animals.id),
|
||||||
|
sql`${sightings.id} = (
|
||||||
|
SELECT id FROM sightings
|
||||||
|
WHERE animal_id = ${animals.id}
|
||||||
|
ORDER BY submitted_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
)`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(animals.submittedAt);
|
||||||
|
|
||||||
|
return { admin: true, queue: pending };
|
||||||
|
};
|
||||||
170
src/routes/projects/animaldex/admin/+page.svelte
Normal file
170
src/routes/projects/animaldex/admin/+page.svelte
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
let password = $state('');
|
||||||
|
let loginError = $state('');
|
||||||
|
let loginLoading = $state(false);
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
loginLoading = true;
|
||||||
|
loginError = '';
|
||||||
|
const res = await fetch('/admin/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
await invalidateAll();
|
||||||
|
} else {
|
||||||
|
loginError = 'Wrong password';
|
||||||
|
}
|
||||||
|
loginLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let actionLoading = $state<string | null>(null);
|
||||||
|
|
||||||
|
async function moderate(type: 'animals' | 'sightings', id: string, action: 'accept' | 'deny') {
|
||||||
|
actionLoading = `${type}-${id}-${action}`;
|
||||||
|
await fetch(`/api/${type}/${id}/moderate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action })
|
||||||
|
});
|
||||||
|
actionLoading = null;
|
||||||
|
await invalidateAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = $derived(data.queue ?? []);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Animaldex - Admin</title></svelte:head>
|
||||||
|
|
||||||
|
{#if !data.admin}
|
||||||
|
<div class="flex min-h-screen items-center justify-center bg-gray-50">
|
||||||
|
<div class="w-80 rounded-2xl bg-white p-8 shadow-xl">
|
||||||
|
<h1 class="mb-6 text-xl font-semibold text-gray-900">Admin login</h1>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
bind:value={password}
|
||||||
|
placeholder="Password"
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && login()}
|
||||||
|
class="mb-3 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||||
|
/>
|
||||||
|
{#if loginError}
|
||||||
|
<p class="mb-3 text-xs text-red-500">{loginError}</p>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
onclick={login}
|
||||||
|
disabled={loginLoading}
|
||||||
|
class="w-full rounded-lg bg-orange-500 py-2 text-sm font-semibold text-white hover:bg-orange-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loginLoading ? 'Logging in…' : 'Log in'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
<div
|
||||||
|
class="sticky top-0 z-10 flex items-center justify-between border-b border-gray-100 bg-white px-6 py-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<a href={resolve('/')} class="text-gray-400 hover:text-gray-600">←</a>
|
||||||
|
<h1 class="font-semibold text-gray-900">Animaldex admin</h1>
|
||||||
|
</div>
|
||||||
|
<span class="rounded-full bg-orange-100 px-2 py-0.5 text-xs font-medium text-orange-700">
|
||||||
|
{queue.filter((r) => !r.acceptedAt && !r.deniedAt).length} pending
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-3xl space-y-4 px-4 py-6">
|
||||||
|
{#if queue.length === 0}
|
||||||
|
<div class="rounded-2xl bg-white p-8 text-center text-gray-400 shadow-sm">
|
||||||
|
No submissions yet
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each queue as item (item.animalId)}
|
||||||
|
{@const status = item.acceptedAt ? 'accepted' : item.deniedAt ? 'denied' : 'pending'}
|
||||||
|
<div
|
||||||
|
class="overflow-hidden rounded-2xl bg-white shadow-sm {status === 'denied'
|
||||||
|
? 'opacity-60'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
<div class="flex gap-4 p-4">
|
||||||
|
<div class="h-24 w-24 shrink-0 overflow-hidden rounded-xl bg-gray-100">
|
||||||
|
{#if item.photoUrl}
|
||||||
|
<img src={item.photoUrl} alt="" class="h-full w-full object-cover" />
|
||||||
|
{:else}
|
||||||
|
<div class="flex h-full items-center justify-center text-2xl">🐾</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-gray-900">{item.animalName ?? item.species}</p>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
{item.species}{item.breed ? ` · ${item.breed}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium
|
||||||
|
{status === 'accepted'
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: status === 'denied'
|
||||||
|
? 'bg-red-100 text-red-700'
|
||||||
|
: 'bg-yellow-100 text-yellow-700'}"
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if item.aiBreedSuggestion}
|
||||||
|
<p class="mt-1 text-xs text-gray-400">
|
||||||
|
🤖 AI: {item.aiBreedSuggestion} ({Math.round(
|
||||||
|
(item.aiBreedConfidence ?? 0) * 100
|
||||||
|
)}%)
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<p class="mt-1 text-xs text-gray-400">
|
||||||
|
{new Date(item.submittedAt).toLocaleDateString('nl-NL', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})}
|
||||||
|
{#if item.reporterName}· by {item.reporterName}{/if}
|
||||||
|
</p>
|
||||||
|
<p class="mt-0.5 text-xs text-gray-400">
|
||||||
|
📍 {item.sightingLat.toFixed(5)}, {item.sightingLng.toFixed(5)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 border-t border-gray-100 px-4 py-3">
|
||||||
|
<button
|
||||||
|
onclick={() => moderate('animals', item.animalId, 'accept')}
|
||||||
|
disabled={actionLoading !== null || status === 'accepted'}
|
||||||
|
class="flex-1 rounded-lg py-1.5 text-sm font-medium {status === 'accepted'
|
||||||
|
? 'cursor-default bg-green-100 text-green-700'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-green-100 hover:text-green-700'}"
|
||||||
|
>
|
||||||
|
{actionLoading === `animals-${item.animalId}-accept` ? '…' : '✓ Accept'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => moderate('animals', item.animalId, 'deny')}
|
||||||
|
disabled={actionLoading !== null || status === 'denied'}
|
||||||
|
class="flex-1 rounded-lg py-1.5 text-sm font-medium {status === 'denied'
|
||||||
|
? 'cursor-default bg-red-100 text-red-700'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-red-100 hover:text-red-700'}"
|
||||||
|
>
|
||||||
|
{actionLoading === `animals-${item.animalId}-deny` ? '…' : '✕ Deny'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
25
src/routes/projects/animaldex/admin/login/+server.ts
Normal file
25
src/routes/projects/animaldex/admin/login/+server.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const loginSchema = z.object({ password: z.string().min(1) });
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const parsed = loginSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success || parsed.data.password !== env.ADMIN_SECRET) {
|
||||||
|
throw error(401, { message: 'Invalid password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
cookies.set('admin_token', env.ADMIN_SECRET, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
maxAge: 60 * 60 * 24 * 7
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
73
src/routes/projects/animaldex/api/animals/+server.ts
Normal file
73
src/routes/projects/animaldex/api/animals/+server.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { animals, sightings, photos } from '$lib/server/db/schema';
|
||||||
|
import { registerAnimalSchema } from '$lib/validation/sighting';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
|
const MAX_DISTANCE_METERS = 30_000;
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const parsed = registerAnimalSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw error(400, { message: parsed.error.issues[0]?.message ?? 'Invalid request' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parsed.data;
|
||||||
|
|
||||||
|
const [distRow] = await db.execute(sql`
|
||||||
|
SELECT ST_Distance(
|
||||||
|
ST_MakePoint(${data.deviceLng}, ${data.deviceLat})::geography,
|
||||||
|
ST_MakePoint(${data.lng}, ${data.lat})::geography
|
||||||
|
) AS dist
|
||||||
|
`);
|
||||||
|
|
||||||
|
const dist = Number((distRow as Record<string, unknown>).dist);
|
||||||
|
if (dist > MAX_DISTANCE_METERS) {
|
||||||
|
throw error(422, {
|
||||||
|
message: `Location is ${Math.round(dist / 1000)}km from your device. Maximum is 30km.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.transaction(async (tx) => {
|
||||||
|
const [animal] = await tx
|
||||||
|
.insert(animals)
|
||||||
|
.values({
|
||||||
|
species: data.species,
|
||||||
|
breed: data.breed,
|
||||||
|
animalName: data.animalName,
|
||||||
|
description: data.description,
|
||||||
|
aiBreedSuggestion: data.aiBreedSuggestion,
|
||||||
|
aiBreedConfidence: data.aiBreedConfidence
|
||||||
|
})
|
||||||
|
.returning({ id: animals.id });
|
||||||
|
|
||||||
|
const [sighting] = await tx
|
||||||
|
.insert(sightings)
|
||||||
|
.values({
|
||||||
|
animalId: animal.id,
|
||||||
|
reporterName: data.reporterName,
|
||||||
|
seenAt: new Date(data.seenAt),
|
||||||
|
lat: data.lat,
|
||||||
|
lng: data.lng
|
||||||
|
})
|
||||||
|
.returning({ id: sightings.id });
|
||||||
|
|
||||||
|
await tx.insert(photos).values(
|
||||||
|
data.photoKeys.map((key, i) => ({
|
||||||
|
sightingId: sighting.id,
|
||||||
|
r2Key: key,
|
||||||
|
url: `${env.R2_PUBLIC_URL}/${key}`,
|
||||||
|
sortOrder: i
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
return { animalId: animal.id, sightingId: sighting.id };
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[animals] created animal ${result.animalId}, sighting ${result.sightingId}`);
|
||||||
|
return json(result, { status: 201 });
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { animals, sightings } from '$lib/server/db/schema';
|
||||||
|
import { moderationActionSchema } from '$lib/validation/sighting';
|
||||||
|
import { eq, isNull } from 'drizzle-orm';
|
||||||
|
import { requireAdmin } from '$lib/server/auth';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async (event) => {
|
||||||
|
requireAdmin(event);
|
||||||
|
|
||||||
|
const body = await event.request.json().catch(() => null);
|
||||||
|
const parsed = moderationActionSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw error(400, { message: 'action must be "accept" or "deny"' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const isAccept = parsed.data.action === 'accept';
|
||||||
|
|
||||||
|
const [updated] = await db.transaction(async (tx) => {
|
||||||
|
const result = await tx
|
||||||
|
.update(animals)
|
||||||
|
.set({
|
||||||
|
acceptedAt: isAccept ? now : null,
|
||||||
|
deniedAt: isAccept ? null : now
|
||||||
|
})
|
||||||
|
.where(eq(animals.id, event.params.id))
|
||||||
|
.returning({ id: animals.id });
|
||||||
|
|
||||||
|
if (result.length && isAccept) {
|
||||||
|
await tx
|
||||||
|
.update(sightings)
|
||||||
|
.set({ acceptedAt: now, deniedAt: null })
|
||||||
|
.where(eq(sightings.animalId, event.params.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw error(404, { message: 'Animal not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[moderate] animal ${event.params.id} → ${parsed.data.action}, updated ${updated?.id}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return json({ ok: true, action: parsed.data.action });
|
||||||
|
};
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { animals, sightings, photos } from '$lib/server/db/schema';
|
||||||
|
import { addSightingSchema } from '$lib/validation/sighting';
|
||||||
|
import { eq, sql } from 'drizzle-orm';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
|
const MAX_DISTANCE_METERS = 30_000;
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request, params }) => {
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const parsed = addSightingSchema.safeParse({ ...body, animalId: params.id });
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw error(400, { message: parsed.error.issues[0]?.message ?? 'Invalid request' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parsed.data;
|
||||||
|
|
||||||
|
const [animal] = await db
|
||||||
|
.select({ id: animals.id, acceptedAt: animals.acceptedAt })
|
||||||
|
.from(animals)
|
||||||
|
.where(eq(animals.id, params.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!animal) {
|
||||||
|
throw error(404, { message: 'Animal not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [distRow] = await db.execute(sql`
|
||||||
|
SELECT ST_Distance(
|
||||||
|
ST_MakePoint(${data.deviceLng}, ${data.deviceLat})::geography,
|
||||||
|
ST_MakePoint(${data.lng}, ${data.lat})::geography
|
||||||
|
) AS dist
|
||||||
|
`);
|
||||||
|
|
||||||
|
const dist = Number((distRow as Record<string, unknown>).dist);
|
||||||
|
if (dist > MAX_DISTANCE_METERS) {
|
||||||
|
throw error(422, {
|
||||||
|
message: `Location is ${Math.round(dist / 1000)}km from your device. Maximum is 30km.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const animalAlreadyAccepted = animal.acceptedAt !== null;
|
||||||
|
|
||||||
|
const result = await db.transaction(async (tx) => {
|
||||||
|
const [sighting] = await tx
|
||||||
|
.insert(sightings)
|
||||||
|
.values({
|
||||||
|
animalId: data.animalId,
|
||||||
|
reporterName: data.reporterName,
|
||||||
|
seenAt: new Date(data.seenAt),
|
||||||
|
lat: data.lat,
|
||||||
|
lng: data.lng,
|
||||||
|
...(animalAlreadyAccepted ? { acceptedAt: new Date() } : {})
|
||||||
|
})
|
||||||
|
.returning({ id: sightings.id });
|
||||||
|
|
||||||
|
if (data.photoKeys && data.photoKeys.length > 0) {
|
||||||
|
await tx.insert(photos).values(
|
||||||
|
data.photoKeys.map((key, i) => ({
|
||||||
|
sightingId: sighting.id,
|
||||||
|
r2Key: key,
|
||||||
|
url: `${env.R2_PUBLIC_URL}/${key}`,
|
||||||
|
sortOrder: i
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sightingId: sighting.id };
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[sightings] created sighting ${result.sightingId} for animal ${params.id} (auto-accepted: ${animalAlreadyAccepted})`
|
||||||
|
);
|
||||||
|
return json(result, { status: 201 });
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { sightings } from '$lib/server/db/schema';
|
||||||
|
import { moderationActionSchema } from '$lib/validation/sighting';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { requireAdmin } from '$lib/server/auth';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async (event) => {
|
||||||
|
requireAdmin(event);
|
||||||
|
|
||||||
|
const body = await event.request.json().catch(() => null);
|
||||||
|
const parsed = moderationActionSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw error(400, { message: 'action must be "accept" or "deny"' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const isAccept = parsed.data.action === 'accept';
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(sightings)
|
||||||
|
.set({
|
||||||
|
acceptedAt: isAccept ? now : null,
|
||||||
|
deniedAt: isAccept ? null : now
|
||||||
|
})
|
||||||
|
.where(eq(sightings.id, event.params.id))
|
||||||
|
.returning({ id: sightings.id });
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw error(404, { message: 'Sighting not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ ok: true, action: parsed.data.action });
|
||||||
|
};
|
||||||
17
src/routes/projects/animaldex/api/detect-breed/+server.ts
Normal file
17
src/routes/projects/animaldex/api/detect-breed/+server.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const schema = z.object({ imageUrl: z.string().url() });
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const parsed = schema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw error(400, { message: 'imageUrl is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// const result = await detectBreed(parsed.data.imageUrl);
|
||||||
|
return json(result ?? { breed: null, confidence: 0 });
|
||||||
|
};
|
||||||
127
src/routes/projects/animaldex/api/map/+server.ts
Normal file
127
src/routes/projects/animaldex/api/map/+server.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { animals, sightings } from '$lib/server/db/schema';
|
||||||
|
import { mapQuerySchema } from '$lib/validation/sighting';
|
||||||
|
import { and, eq, gte, ilike, isNotNull, isNull, lte, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
const params = Object.fromEntries(url.searchParams);
|
||||||
|
const parsed = mapQuerySchema.safeParse(params);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw error(400, { message: 'Invalid query parameters' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = parsed.data;
|
||||||
|
|
||||||
|
const conditions = [
|
||||||
|
isNotNull(animals.acceptedAt),
|
||||||
|
isNull(animals.deniedAt),
|
||||||
|
isNotNull(sightings.acceptedAt),
|
||||||
|
isNull(sightings.deniedAt),
|
||||||
|
sql`ST_Within(
|
||||||
|
${sightings}.location::geometry,
|
||||||
|
ST_MakeEnvelope(${q.minLng}, ${q.minLat}, ${q.maxLng}, ${q.maxLat}, 4326)
|
||||||
|
)`
|
||||||
|
];
|
||||||
|
|
||||||
|
if (q.species) conditions.push(ilike(animals.species, `%${q.species}%`));
|
||||||
|
if (q.breed) conditions.push(ilike(animals.breed, `%${q.breed}%`));
|
||||||
|
if (q.name) conditions.push(ilike(animals.animalName, `%${q.name}%`));
|
||||||
|
if (q.reporter) conditions.push(ilike(sightings.reporterName, `%${q.reporter}%`));
|
||||||
|
if (q.fromDate) conditions.push(gte(sightings.seenAt, new Date(q.fromDate)));
|
||||||
|
if (q.toDate) conditions.push(lte(sightings.seenAt, new Date(q.toDate)));
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
animalId: animals.id,
|
||||||
|
species: animals.species,
|
||||||
|
breed: animals.breed,
|
||||||
|
animalName: animals.animalName,
|
||||||
|
sightingId: sightings.id,
|
||||||
|
seenAt: sightings.seenAt,
|
||||||
|
reporterName: sightings.reporterName,
|
||||||
|
lat: sightings.lat,
|
||||||
|
lng: sightings.lng,
|
||||||
|
photoUrl: sql<string>`(
|
||||||
|
SELECT url FROM photos
|
||||||
|
WHERE sighting_id = ${sightings.id}
|
||||||
|
ORDER BY sort_order ASC
|
||||||
|
LIMIT 1
|
||||||
|
)`
|
||||||
|
})
|
||||||
|
.from(sightings)
|
||||||
|
.innerJoin(animals, eq(sightings.animalId, animals.id))
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(sightings.seenAt);
|
||||||
|
|
||||||
|
const animalMap = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
animalId: string;
|
||||||
|
species: string;
|
||||||
|
breed: string | null;
|
||||||
|
animalName: string | null;
|
||||||
|
sightings: Array<{
|
||||||
|
id: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
seenAt: Date;
|
||||||
|
reporterName: string | null;
|
||||||
|
photoUrl: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!animalMap.has(row.animalId)) {
|
||||||
|
animalMap.set(row.animalId, {
|
||||||
|
animalId: row.animalId,
|
||||||
|
species: row.species,
|
||||||
|
breed: row.breed,
|
||||||
|
animalName: row.animalName,
|
||||||
|
sightings: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
animalMap.get(row.animalId)!.sightings.push({
|
||||||
|
id: row.sightingId,
|
||||||
|
lat: row.lat,
|
||||||
|
lng: row.lng,
|
||||||
|
seenAt: row.seenAt,
|
||||||
|
reporterName: row.reporterName,
|
||||||
|
photoUrl: row.photoUrl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const features = Array.from(animalMap.values()).map((animal) => {
|
||||||
|
const sorted = [...animal.sightings].sort((a, b) => b.seenAt.getTime() - a.seenAt.getTime());
|
||||||
|
const latest = sorted[0];
|
||||||
|
return {
|
||||||
|
type: 'Feature' as const,
|
||||||
|
geometry: { type: 'Point' as const, coordinates: [latest.lng, latest.lat] },
|
||||||
|
properties: {
|
||||||
|
animalId: animal.animalId,
|
||||||
|
species: animal.species,
|
||||||
|
breed: animal.breed,
|
||||||
|
animalName: animal.animalName,
|
||||||
|
sightingCount: animal.sightings.length,
|
||||||
|
trail: animal.sightings.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
lng: s.lng,
|
||||||
|
lat: s.lat,
|
||||||
|
seenAt: s.seenAt.toISOString(),
|
||||||
|
reporterName: s.reporterName,
|
||||||
|
photoUrl: s.photoUrl
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[map] query returned ${rows.length} rows for bbox ${q.minLng},${q.minLat} → ${q.maxLng},${q.maxLat}`
|
||||||
|
);
|
||||||
|
console.log(`[map] built ${features.length} features`);
|
||||||
|
|
||||||
|
return json({ type: 'FeatureCollection', features });
|
||||||
|
};
|
||||||
22
src/routes/projects/animaldex/api/upload/+server.ts
Normal file
22
src/routes/projects/animaldex/api/upload/+server.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { createPresignedUpload, isAllowedMimeType } from '$lib/server/r2';
|
||||||
|
import { presignedUploadSchema } from '$lib/validation/sighting';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const parsed = presignedUploadSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw error(400, { message: 'Invalid request body' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { mimeType } = parsed.data;
|
||||||
|
|
||||||
|
if (!isAllowedMimeType(mimeType)) {
|
||||||
|
throw error(400, { message: 'Unsupported image type' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await createPresignedUpload(mimeType);
|
||||||
|
return json(result);
|
||||||
|
};
|
||||||
1
src/routes/projects/animaldex/layout.css
Normal file
1
src/routes/projects/animaldex/layout.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
444
src/routes/projects/animaldex/register/+page.svelte
Normal file
444
src/routes/projects/animaldex/register/+page.svelte
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import type { Map as MaptilerMap, Marker as MaptilerMarker } from '@maptiler/sdk';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
const animalId = $derived($page.url.searchParams.get('animalId'));
|
||||||
|
const isNewSighting = $derived(!!animalId);
|
||||||
|
|
||||||
|
let species = $state('');
|
||||||
|
let customSpecies = $state('');
|
||||||
|
let breed = $state('');
|
||||||
|
let animalName = $state('');
|
||||||
|
let description = $state('');
|
||||||
|
let reporterName = $state('');
|
||||||
|
let seenAt = $state(new Date().toISOString().slice(0, 16));
|
||||||
|
|
||||||
|
let aiSuggestion = $state<{ breed: string; confidence: number } | null>(null);
|
||||||
|
let aiLoading = $state(false);
|
||||||
|
let aiDismissed = $state(false);
|
||||||
|
|
||||||
|
let photoFiles = $state<File[]>([]);
|
||||||
|
let photoPreviews = $state<string[]>([]);
|
||||||
|
|
||||||
|
let lat = $state<number | null>(null);
|
||||||
|
let lng = $state<number | null>(null);
|
||||||
|
let deviceLat = $state<number | null>(null);
|
||||||
|
let deviceLng = $state<number | null>(null);
|
||||||
|
let locationError = $state('');
|
||||||
|
let locationLoading = $state(false);
|
||||||
|
|
||||||
|
let mapContainer = $state<HTMLDivElement>(null!);
|
||||||
|
let map: MaptilerMap | null = null;
|
||||||
|
let marker: MaptilerMarker | null = null;
|
||||||
|
|
||||||
|
let submitting = $state(false);
|
||||||
|
let submitError = $state('');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (mapContainer && lat !== null && lng !== null && !map) {
|
||||||
|
initMap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const COMMON_SPECIES = ['Dog', 'Cat', 'Fox', 'Bird', 'Rabbit', 'Deer', 'Squirrel', 'Hedgehog'];
|
||||||
|
const finalSpecies = $derived(species === '__custom__' ? customSpecies : species);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
reporterName = localStorage.getItem('reporterName') ?? '';
|
||||||
|
locationLoading = true;
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => {
|
||||||
|
deviceLat = pos.coords.latitude;
|
||||||
|
deviceLng = pos.coords.longitude;
|
||||||
|
lat = pos.coords.latitude;
|
||||||
|
lng = pos.coords.longitude;
|
||||||
|
locationLoading = false;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
locationLoading = false;
|
||||||
|
locationError = 'Location access denied. GPS is required to register a sighting.';
|
||||||
|
},
|
||||||
|
{ enableHighAccuracy: true, timeout: 10000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function initMap() {
|
||||||
|
if (!mapContainer || lat === null || lng === null) return;
|
||||||
|
const { Map, Marker, config } = await import('@maptiler/sdk');
|
||||||
|
const { env } = await import('$env/dynamic/public');
|
||||||
|
config.apiKey = env.PUBLIC_MAPTILER_API_KEY;
|
||||||
|
|
||||||
|
map = new Map({
|
||||||
|
container: mapContainer,
|
||||||
|
style: 'https://api.maptiler.com/maps/satellite/style.json?key=' + env.PUBLIC_MAPTILER_API_KEY,
|
||||||
|
center: [lng!, lat!],
|
||||||
|
zoom: 15
|
||||||
|
});
|
||||||
|
|
||||||
|
map.on('load', () => {
|
||||||
|
marker = new Marker({ draggable: true }).setLngLat([lng!, lat!]).addTo(map!);
|
||||||
|
marker.on('dragend', () => {
|
||||||
|
const lngLat = marker!.getLngLat();
|
||||||
|
lat = lngLat.lat;
|
||||||
|
lng = lngLat.lng;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePhotoChange(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
const newFiles = Array.from(input.files ?? []).slice(0, 10 - photoFiles.length);
|
||||||
|
photoFiles = [...photoFiles, ...newFiles].slice(0, 10);
|
||||||
|
photoPreviews = photoFiles.map((f) => URL.createObjectURL(f));
|
||||||
|
if (photoFiles.length === 1 && !isNewSighting) runAIDetection(photoFiles[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePhoto(index: number) {
|
||||||
|
photoFiles = photoFiles.filter((_, i) => i !== index);
|
||||||
|
photoPreviews = photoPreviews.filter((_, i) => i !== index);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAIDetection(file: File) {
|
||||||
|
aiLoading = true;
|
||||||
|
aiDismissed = false;
|
||||||
|
try {
|
||||||
|
const presignRes = await fetch('/api/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mimeType: file.type })
|
||||||
|
});
|
||||||
|
if (!presignRes.ok) return;
|
||||||
|
const { uploadUrl, publicUrl } = await presignRes.json();
|
||||||
|
await fetch(uploadUrl, { method: 'PUT', body: file, headers: { 'Content-Type': file.type } });
|
||||||
|
const aiRes = await fetch('/api/detect-breed', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ imageUrl: publicUrl })
|
||||||
|
});
|
||||||
|
if (!aiRes.ok) return;
|
||||||
|
const result = await aiRes.json();
|
||||||
|
if (result.breed) {
|
||||||
|
aiSuggestion = result;
|
||||||
|
if (!breed) breed = result.breed;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* silent */
|
||||||
|
} finally {
|
||||||
|
aiLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (submitting) return;
|
||||||
|
submitError = '';
|
||||||
|
if (!finalSpecies && !isNewSighting) {
|
||||||
|
submitError = 'Please select or enter a species.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (photoFiles.length === 0 && !isNewSighting) {
|
||||||
|
submitError = 'At least one photo is required.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lat === null || lng === null || deviceLat === null || deviceLng === null) {
|
||||||
|
submitError = 'Location is required.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitting = true;
|
||||||
|
try {
|
||||||
|
const photoKeys: string[] = [];
|
||||||
|
if (photoFiles.length > 0) {
|
||||||
|
for (const file of photoFiles) {
|
||||||
|
const presignRes = await fetch('/api/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mimeType: file.type })
|
||||||
|
});
|
||||||
|
if (!presignRes.ok) throw new Error('Failed to get upload URL');
|
||||||
|
const { key, uploadUrl } = await presignRes.json();
|
||||||
|
const uploadRes = await fetch(uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: file,
|
||||||
|
headers: { 'Content-Type': file.type }
|
||||||
|
});
|
||||||
|
if (!uploadRes.ok) throw new Error('Photo upload failed');
|
||||||
|
photoKeys.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (reporterName) localStorage.setItem('reporterName', reporterName);
|
||||||
|
|
||||||
|
const endpoint = isNewSighting ? `/api/animals/${animalId}/sightings` : '/api/animals';
|
||||||
|
const body = isNewSighting
|
||||||
|
? {
|
||||||
|
animalId,
|
||||||
|
reporterName: reporterName || undefined,
|
||||||
|
seenAt: new Date(seenAt).toISOString(),
|
||||||
|
lat,
|
||||||
|
lng,
|
||||||
|
deviceLat,
|
||||||
|
deviceLng,
|
||||||
|
photoKeys
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
species: finalSpecies,
|
||||||
|
breed: breed || undefined,
|
||||||
|
animalName: animalName || undefined,
|
||||||
|
description: description || undefined,
|
||||||
|
reporterName: reporterName || undefined,
|
||||||
|
aiBreedSuggestion: aiSuggestion?.breed,
|
||||||
|
aiBreedConfidence: aiSuggestion?.confidence,
|
||||||
|
seenAt: new Date(seenAt).toISOString(),
|
||||||
|
lat,
|
||||||
|
lng,
|
||||||
|
deviceLat,
|
||||||
|
deviceLng,
|
||||||
|
photoKeys
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.message ?? 'Submission failed');
|
||||||
|
}
|
||||||
|
goto(resolve('/?submitted=1'));
|
||||||
|
} catch (e: unknown) {
|
||||||
|
submitError = e instanceof Error ? e.message : 'Something went wrong.';
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Animaldex - Log a sighting</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.maptiler.com/maptiler-sdk-js/latest/maptiler-sdk.css" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
<div
|
||||||
|
class="sticky top-0 z-10 flex items-center gap-3 border-b border-gray-100 bg-white px-4 py-3"
|
||||||
|
>
|
||||||
|
<a href={resolve('/')} class="text-gray-400 hover:text-gray-600">←</a>
|
||||||
|
<h1 class="font-semibold text-gray-900">
|
||||||
|
{isNewSighting ? 'Log another sighting' : 'Register an animal'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-lg space-y-5 px-4 py-6">
|
||||||
|
{#if locationError}
|
||||||
|
<div class="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||||
|
<p class="font-medium">📍 Location required</p>
|
||||||
|
<p class="mt-1">{locationError}</p>
|
||||||
|
<p class="mt-2 text-xs">
|
||||||
|
Go to <strong>Settings → Site permissions → Location</strong> and allow this site.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<h2 class="mb-3 text-sm font-semibold text-gray-700">
|
||||||
|
Photos
|
||||||
|
{#if isNewSighting}
|
||||||
|
<span class="text-xs font-normal text-gray-400">(optional)</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-red-400">*</span>
|
||||||
|
{/if}
|
||||||
|
</h2>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each photoPreviews as preview, i (i)}
|
||||||
|
<div class="relative h-20 w-20 overflow-hidden rounded-xl">
|
||||||
|
<img src={preview} alt="" class="h-full w-full object-cover" />
|
||||||
|
<button
|
||||||
|
onclick={() => removePhoto(i)}
|
||||||
|
class="absolute top-1 right-1 flex h-5 w-5 items-center justify-center rounded-full bg-black/60 text-xs text-white"
|
||||||
|
>✕</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if photoFiles.length < 10}
|
||||||
|
<label
|
||||||
|
class="flex h-20 w-20 cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-gray-200 text-gray-400 hover:border-orange-300"
|
||||||
|
>
|
||||||
|
<span class="text-2xl">+</span>
|
||||||
|
<span class="text-xs">Photo</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/heic"
|
||||||
|
multiple
|
||||||
|
class="hidden"
|
||||||
|
onchange={handlePhotoChange}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-gray-400">Max 10 photos · JPEG, PNG, WebP, HEIC</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if aiLoading}
|
||||||
|
<div class="flex items-center gap-2 rounded-xl bg-orange-50 p-3 text-sm text-orange-700">
|
||||||
|
<span class="animate-spin">⟳</span> Identifying breed…
|
||||||
|
</div>
|
||||||
|
{:else if aiSuggestion && !aiDismissed}
|
||||||
|
<div class="rounded-xl border border-orange-200 bg-orange-50 p-3">
|
||||||
|
<p class="text-sm font-medium text-orange-800">
|
||||||
|
🤖 AI thinks this is a <strong>{aiSuggestion.breed}</strong>
|
||||||
|
<span class="text-xs font-normal text-orange-600"
|
||||||
|
>({Math.round(aiSuggestion.confidence * 100)}% confident)</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
<div class="mt-2 flex gap-2">
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
if (aiSuggestion) breed = aiSuggestion.breed;
|
||||||
|
}}
|
||||||
|
class="rounded-lg bg-orange-500 px-3 py-1 text-xs font-semibold text-white"
|
||||||
|
>Use this</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
aiDismissed = true;
|
||||||
|
aiSuggestion = null;
|
||||||
|
}}
|
||||||
|
class="rounded-lg border border-orange-200 px-3 py-1 text-xs text-orange-700"
|
||||||
|
>Dismiss</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !isNewSighting}
|
||||||
|
<section class="rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<h2 class="mb-3 text-sm font-semibold text-gray-700">
|
||||||
|
Species <span class="text-red-400">*</span>
|
||||||
|
</h2>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each COMMON_SPECIES as s (s)}
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
species = s;
|
||||||
|
customSpecies = '';
|
||||||
|
}}
|
||||||
|
class="rounded-full px-3 py-1.5 text-sm {species === s
|
||||||
|
? 'bg-orange-500 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'}">{s}</button
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
species = '__custom__';
|
||||||
|
}}
|
||||||
|
class="rounded-full px-3 py-1.5 text-sm {species === '__custom__'
|
||||||
|
? 'bg-orange-500 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'}">Other…</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{#if species === '__custom__'}
|
||||||
|
<input
|
||||||
|
bind:value={customSpecies}
|
||||||
|
placeholder="Enter species"
|
||||||
|
class="mt-3 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<h2 class="mb-1 text-sm font-semibold text-gray-700">
|
||||||
|
Breed <span class="text-xs font-normal text-gray-400">(optional)</span>
|
||||||
|
</h2>
|
||||||
|
<input
|
||||||
|
bind:value={breed}
|
||||||
|
placeholder="e.g. Golden Retriever"
|
||||||
|
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="space-y-3 rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-1 text-sm font-semibold text-gray-700">
|
||||||
|
Animal name <span class="text-xs font-normal text-gray-400">(optional)</span>
|
||||||
|
</h2>
|
||||||
|
<input
|
||||||
|
bind:value={animalName}
|
||||||
|
placeholder="e.g. Fluffy"
|
||||||
|
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-1 text-sm font-semibold text-gray-700">
|
||||||
|
Description <span class="text-xs font-normal text-gray-400">(optional)</span>
|
||||||
|
</h2>
|
||||||
|
<textarea
|
||||||
|
bind:value={description}
|
||||||
|
placeholder="Behaviour, features, context…"
|
||||||
|
rows="3"
|
||||||
|
class="w-full resize-none rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="space-y-3 rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-1 text-sm font-semibold text-gray-700">
|
||||||
|
When did you see it? <span class="text-red-400">*</span>
|
||||||
|
</h2>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
bind:value={seenAt}
|
||||||
|
max={new Date().toISOString().slice(0, 16)}
|
||||||
|
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-1 text-sm font-semibold text-gray-700">
|
||||||
|
Your name <span class="text-xs font-normal text-gray-400">(optional)</span>
|
||||||
|
</h2>
|
||||||
|
<input
|
||||||
|
bind:value={reporterName}
|
||||||
|
placeholder="Saved for next time"
|
||||||
|
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<h2 class="mb-2 text-sm font-semibold text-gray-700">
|
||||||
|
Location <span class="text-red-400">*</span>
|
||||||
|
</h2>
|
||||||
|
{#if locationLoading}
|
||||||
|
<div
|
||||||
|
class="flex h-40 items-center justify-center rounded-xl bg-gray-100 text-sm text-gray-400"
|
||||||
|
>
|
||||||
|
Getting your location…
|
||||||
|
</div>
|
||||||
|
{:else if lat !== null}
|
||||||
|
<div bind:this={mapContainer} class="h-48 w-full overflow-hidden rounded-xl"></div>
|
||||||
|
<p class="mt-2 text-xs text-gray-400">Drag the pin to fine-tune (max 30 km from you).</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if submitError}
|
||||||
|
<div class="rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||||
|
{submitError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={submit}
|
||||||
|
disabled={submitting || !!locationError || lat === null}
|
||||||
|
class="w-full rounded-xl bg-orange-500 py-3 font-semibold text-white hover:bg-orange-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? 'Submitting…' : isNewSighting ? 'Submit sighting' : 'Register animal'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="pb-8 text-center text-xs text-gray-400">
|
||||||
|
All submissions are reviewed before appearing on the map.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
1
src/routes/projects/animaldex/register/+page.ts
Normal file
1
src/routes/projects/animaldex/register/+page.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const ssr = false;
|
||||||
5
src/routes/projects/catch-em-all/+layout.server.ts
Normal file
5
src/routes/projects/catch-em-all/+layout.server.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||||
|
return { user: locals.user ?? null };
|
||||||
|
};
|
||||||
30
src/routes/projects/catch-em-all/+layout.svelte
Normal file
30
src/routes/projects/catch-em-all/+layout.svelte
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import './layout.css';
|
||||||
|
import type { LayoutProps } from './$types';
|
||||||
|
|
||||||
|
let { data, children }: LayoutProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-h-screen flex-col">
|
||||||
|
<header class="flex justify-center border-b border-gray-200 px-6 py-3">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
{#if data.user}
|
||||||
|
<span class="text-sm text-gray-600">Logged in as {data.user.name}</span>
|
||||||
|
<form method="POST" action="/logout">
|
||||||
|
<button
|
||||||
|
class="cursor-pointer rounded-lg bg-gray-200 px-4 py-1.5 text-sm font-bold hover:bg-gray-300"
|
||||||
|
>Log out</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
class="rounded-lg bg-blue-500 px-4 py-1.5 text-sm font-bold text-white hover:bg-blue-600"
|
||||||
|
>Log in</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="flex-1 p-4">{@render children()}</main>
|
||||||
|
</div>
|
||||||
107
src/routes/projects/catch-em-all/+page.server.ts
Normal file
107
src/routes/projects/catch-em-all/+page.server.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { game, pokemonGameEntry, userEntry, userGame } from '$lib/server/db/schema';
|
||||||
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
import { availabilityValues } from '$lib/availability';
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
export type WireEntry = [id: number, gameId: number, availability: number, note?: string];
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
const pokemonRows = await db.query.pokemon.findMany({
|
||||||
|
with: {
|
||||||
|
entry: true
|
||||||
|
},
|
||||||
|
orderBy: (pokemon) => pokemon.number
|
||||||
|
});
|
||||||
|
const pokemon = pokemonRows.map(({ entry, ...rest }) => ({
|
||||||
|
...rest,
|
||||||
|
entries: entry.map((e): WireEntry => {
|
||||||
|
const code = availabilityValues.indexOf(e.availability);
|
||||||
|
return e.note === null ? [e.id, e.gameId, code] : [e.id, e.gameId, code, e.note];
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
const games = await db.query.game.findMany({
|
||||||
|
orderBy: (game) => [game.generation, game.id]
|
||||||
|
});
|
||||||
|
|
||||||
|
let caughtIds: number[] = [];
|
||||||
|
let selectedGameIds: number[] = [];
|
||||||
|
if (locals.user) {
|
||||||
|
const caughtRows = await db
|
||||||
|
.select({ id: userEntry.pokemonGameEntryId })
|
||||||
|
.from(userEntry)
|
||||||
|
.where(eq(userEntry.userId, locals.user.id));
|
||||||
|
caughtIds = caughtRows.map((r) => r.id);
|
||||||
|
|
||||||
|
const gameRows = await db
|
||||||
|
.select({ id: userGame.gameId })
|
||||||
|
.from(userGame)
|
||||||
|
.where(eq(userGame.userId, locals.user.id));
|
||||||
|
selectedGameIds = gameRows.map((r) => r.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pokemon, games, caughtIds, selectedGameIds };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
togglePokemonCaught: async ({ request, locals }) => {
|
||||||
|
if (!locals.user) return fail(401, { message: 'Log in to track your progress' });
|
||||||
|
|
||||||
|
const data = await request.formData();
|
||||||
|
const entryId = Number(data.get('entryId'));
|
||||||
|
if (!Number.isInteger(entryId) || entryId <= 0) return fail(400, { message: 'Invalid entry' });
|
||||||
|
|
||||||
|
// only catchable entries can be toggled — the client disables these
|
||||||
|
// checkboxes but we can't trust that
|
||||||
|
const [entry] = await db
|
||||||
|
.select({ availability: pokemonGameEntry.availability })
|
||||||
|
.from(pokemonGameEntry)
|
||||||
|
.where(eq(pokemonGameEntry.id, entryId));
|
||||||
|
if (!entry) return fail(404, { message: 'Unknown entry' });
|
||||||
|
if (entry.availability !== 'catchable') {
|
||||||
|
return fail(400, { message: 'This pokemon is not catchable in this game' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const caught = data.get('caught') === 'on';
|
||||||
|
|
||||||
|
if (caught) {
|
||||||
|
await db
|
||||||
|
.insert(userEntry)
|
||||||
|
.values({ pokemonGameEntryId: entryId, userId: locals.user.id })
|
||||||
|
.onConflictDoNothing();
|
||||||
|
} else {
|
||||||
|
await db
|
||||||
|
.delete(userEntry)
|
||||||
|
.where(
|
||||||
|
and(eq(userEntry.pokemonGameEntryId, entryId), eq(userEntry.userId, locals.user.id))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setGames: async ({ request, locals }) => {
|
||||||
|
if (!locals.user) return fail(401, { message: 'Log in to set rules' });
|
||||||
|
|
||||||
|
const data = await request.formData();
|
||||||
|
const gameIds = data
|
||||||
|
.getAll('gameIds')
|
||||||
|
.map(Number)
|
||||||
|
.filter((id) => Number.isInteger(id) && id > 0);
|
||||||
|
|
||||||
|
if (gameIds.length > 0) {
|
||||||
|
const validGames = await db
|
||||||
|
.select({ id: game.id })
|
||||||
|
.from(game)
|
||||||
|
.where(inArray(game.id, gameIds));
|
||||||
|
if (validGames.length !== gameIds.length) return fail(400, { message: 'Unknown game' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = locals.user.id;
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx.delete(userGame).where(eq(userGame.userId, userId));
|
||||||
|
if (gameIds.length > 0) {
|
||||||
|
await tx.insert(userGame).values(gameIds.map((gameId) => ({ userId, gameId })));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} satisfies Actions;
|
||||||
329
src/routes/projects/catch-em-all/+page.svelte
Normal file
329
src/routes/projects/catch-em-all/+page.svelte
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { deserialize, enhance } from '$app/forms';
|
||||||
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
import type { Game } from '$lib/server/db/schema';
|
||||||
|
|
||||||
|
type Entry = { id: number; availability: Availability; note: string | null };
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
|
||||||
|
const caught = new SvelteSet(data.caughtIds);
|
||||||
|
|
||||||
|
// the full table is ~1000 rows x ~40 games; only render the rows inside
|
||||||
|
// the scroll viewport (plus some overscan) to keep the DOM small
|
||||||
|
const ROW_HEIGHT = 37; // keep in sync with h-[37px] on the rows
|
||||||
|
const OVERSCAN = 10;
|
||||||
|
let scrollEl = $state<HTMLDivElement>();
|
||||||
|
let scrollTop = $state(0);
|
||||||
|
let viewportHeight = $state(600);
|
||||||
|
const startIndex = $derived(Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN));
|
||||||
|
const endIndex = $derived(
|
||||||
|
Math.min(data.pokemon.length, Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + OVERSCAN)
|
||||||
|
);
|
||||||
|
const visiblePokemon = $derived(data.pokemon.slice(startIndex, endIndex));
|
||||||
|
|
||||||
|
// optimistic toggle without a <form> per cell — one shared handler instead
|
||||||
|
// of ~10k use:enhance initializations
|
||||||
|
async function toggleCaught(entryId: number, checked: boolean) {
|
||||||
|
if (checked) caught.add(entryId);
|
||||||
|
else caught.delete(entryId);
|
||||||
|
|
||||||
|
const body = new FormData();
|
||||||
|
body.set('entryId', String(entryId));
|
||||||
|
if (checked) body.set('caught', 'on');
|
||||||
|
let failed = false;
|
||||||
|
try {
|
||||||
|
const response = await fetch('?/togglePokemonCaught', { method: 'POST', body });
|
||||||
|
const result = deserialize(await response.text());
|
||||||
|
failed = result.type === 'failure' || result.type === 'error';
|
||||||
|
} catch {
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
if (failed) {
|
||||||
|
// server rejected the toggle, roll back the optimistic update
|
||||||
|
if (checked) caught.delete(entryId);
|
||||||
|
else caught.add(entryId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pokemonId -> gameId -> entry, for quick cell lookup
|
||||||
|
const entryMap = $derived(
|
||||||
|
new Map(
|
||||||
|
data.pokemon.map((pkmn) => [
|
||||||
|
pkmn.id,
|
||||||
|
new Map(
|
||||||
|
pkmn.entries.map(([id, gameId, code, note]) => [
|
||||||
|
gameId,
|
||||||
|
{ id, availability: availabilityValues[code], note: note ?? null } satisfies Entry
|
||||||
|
])
|
||||||
|
)
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// empty selection = user hasn't set rules yet, show everything
|
||||||
|
const selectedGameIds = $derived(new Set(data.selectedGameIds));
|
||||||
|
const visibleGames = $derived(
|
||||||
|
selectedGameIds.size > 0 ? data.games.filter((g) => selectedGameIds.has(g.id)) : data.games
|
||||||
|
);
|
||||||
|
const visibleGameIds = $derived(new Set(visibleGames.map((g) => g.id)));
|
||||||
|
|
||||||
|
const catchableEntries = $derived(
|
||||||
|
data.pokemon
|
||||||
|
.flatMap((pkmn) => pkmn.entries)
|
||||||
|
.filter(
|
||||||
|
([, gameId, code]) => availabilityValues[code] === 'catchable' && visibleGameIds.has(gameId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const total = $derived(catchableEntries.length);
|
||||||
|
const caughtCount = $derived(
|
||||||
|
catchableEntries.reduce((n, [id]) => n + (caught.has(id) ? 1 : 0), 0)
|
||||||
|
);
|
||||||
|
const percentage = $derived(total > 0 ? ((caughtCount / total) * 100).toFixed(2) : '0.00');
|
||||||
|
|
||||||
|
const gamesByGeneration = $derived(
|
||||||
|
[...Map.groupBy(data.games, (g) => g.generation)].sort(([a], [b]) => a - b)
|
||||||
|
);
|
||||||
|
|
||||||
|
// rules modal
|
||||||
|
let rulesDialog: HTMLDialogElement;
|
||||||
|
let draft = $state(new SvelteSet<number>());
|
||||||
|
|
||||||
|
function openRules() {
|
||||||
|
draft = new SvelteSet(selectedGameIds.size > 0 ? selectedGameIds : data.games.map((g) => g.id));
|
||||||
|
rulesDialog.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tooltip(entry: Entry | undefined, game: Game): string | null {
|
||||||
|
if (!data.user) return 'Log in to track your progress';
|
||||||
|
if (!entry) return `Not available in ${game.name}`;
|
||||||
|
const reason = {
|
||||||
|
catchable: null,
|
||||||
|
trade_only: 'Only obtainable by trading in this game',
|
||||||
|
transfer_only: 'Only obtainable by transferring from another game',
|
||||||
|
event_only: 'Only obtainable through an event distribution'
|
||||||
|
}[entry.availability];
|
||||||
|
if (reason === null) return null;
|
||||||
|
return entry.note ? `${reason} (${entry.note})` : reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeColors: Record<string, string> = {
|
||||||
|
Normal: '#A8A77A',
|
||||||
|
Fire: '#EE8130',
|
||||||
|
Water: '#6390F0',
|
||||||
|
Electric: '#F7D02C',
|
||||||
|
Grass: '#7AC74C',
|
||||||
|
Ice: '#96D9D6',
|
||||||
|
Fighting: '#C22E28',
|
||||||
|
Poison: '#A33EA1',
|
||||||
|
Ground: '#E2BF65',
|
||||||
|
Flying: '#A98FF3',
|
||||||
|
Psychic: '#F95587',
|
||||||
|
Bug: '#A6B91A',
|
||||||
|
Rock: '#B6A136',
|
||||||
|
Ghost: '#735797',
|
||||||
|
Dragon: '#6F35FC',
|
||||||
|
Dark: '#705746',
|
||||||
|
Steel: '#B7B7CE',
|
||||||
|
Fairy: '#D685AD'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>catchemall</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<h1 class="py-4 text-center text-2xl font-bold">Gotta catch 'em all!</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<p class="max-w-200 py-6 text-center">
|
||||||
|
Below are all {data.pokemon.length} Pokemon across {data.games.length} games. Play through all generations
|
||||||
|
and catch all catchable pokemon in each. Use the "Set rules" button to pick which games you want to
|
||||||
|
complete.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !data.user}
|
||||||
|
<p class="pb-4 text-center text-sm text-gray-500">
|
||||||
|
<a href="/login" class="font-bold text-blue-500 underline">Log in</a> to save your progress.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center gap-4 py-4">
|
||||||
|
<button
|
||||||
|
class="cursor-pointer rounded-lg bg-blue-400 px-4 py-2 font-bold text-white hover:bg-blue-500"
|
||||||
|
onclick={openRules}>Set rules</button
|
||||||
|
>
|
||||||
|
|
||||||
|
<p>{caughtCount}/{total} entries caught ({percentage} %)</p>
|
||||||
|
<progress value={total > 0 ? caughtCount / total : 0}></progress>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={scrollEl}
|
||||||
|
bind:clientHeight={viewportHeight}
|
||||||
|
onscroll={() => (scrollTop = scrollEl?.scrollTop ?? 0)}
|
||||||
|
class="max-h-[75vh] overflow-auto rounded-xl border border-gray-300"
|
||||||
|
>
|
||||||
|
<table class="min-w-full table-fixed border-separate border-spacing-0 text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
class="sticky top-0 left-0 z-30 w-90 border-r border-b border-gray-300 bg-white px-2 text-left"
|
||||||
|
>Pokemon</th
|
||||||
|
>
|
||||||
|
{#each visibleGames as game (game.id)}
|
||||||
|
<th
|
||||||
|
class="sticky top-0 z-20 w-9 border-b border-gray-300 bg-white px-1 pt-2 pb-1 align-bottom"
|
||||||
|
>
|
||||||
|
<span class="mx-auto rotate-180 [writing-mode:vertical-rl]">{game.name}</span>
|
||||||
|
</th>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#if startIndex > 0}
|
||||||
|
<tr aria-hidden="true">
|
||||||
|
<td colspan={visibleGames.length + 1} style="height: {startIndex * ROW_HEIGHT}px"></td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{#each visiblePokemon as pokemon (pokemon.id)}
|
||||||
|
<tr class="h-[37px] hover:bg-blue-50">
|
||||||
|
<th
|
||||||
|
class="sticky left-0 z-10 border-r border-b border-gray-200 bg-white px-2 py-0.5 text-left font-normal whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<img
|
||||||
|
src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{pokemon.number}.png"
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
class="h-8 w-8"
|
||||||
|
/>
|
||||||
|
<span class="min-w-10 font-bold">{pokemon.number}</span>
|
||||||
|
<span class="min-w-30">{pokemon.name}</span>
|
||||||
|
<span
|
||||||
|
class="rounded-lg px-2 py-0.5 text-xs"
|
||||||
|
style:background-color={typeColors[pokemon.type1]}>{pokemon.type1}</span
|
||||||
|
>
|
||||||
|
{#if pokemon.type2}
|
||||||
|
<span
|
||||||
|
class="rounded-lg px-2 py-0.5 text-xs"
|
||||||
|
style:background-color={typeColors[pokemon.type2]}>{pokemon.type2}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
{#each visibleGames as game (game.id)}
|
||||||
|
{@const entry = entryMap.get(pokemon.id)?.get(game.id)}
|
||||||
|
{@const reason = tooltip(entry, game)}
|
||||||
|
<td class="border-b border-gray-200 text-center">
|
||||||
|
{#if entry && reason === null}
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="cursor-pointer"
|
||||||
|
checked={caught.has(entry.id)}
|
||||||
|
onchange={(e) => toggleCaught(entry.id, e.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span title={reason} class="cursor-help">
|
||||||
|
<input type="checkbox" disabled checked={entry ? caught.has(entry.id) : false} />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{#if endIndex < data.pokemon.length}
|
||||||
|
<tr aria-hidden="true">
|
||||||
|
<td
|
||||||
|
colspan={visibleGames.length + 1}
|
||||||
|
style="height: {(data.pokemon.length - endIndex) * ROW_HEIGHT}px"
|
||||||
|
></td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog bind:this={rulesDialog} class="m-auto w-full max-w-2xl rounded-xl p-6 backdrop:bg-black/50">
|
||||||
|
<h2 class="pb-2 text-xl font-bold">Set rules</h2>
|
||||||
|
<p class="pb-4 text-sm text-gray-600">
|
||||||
|
Pick the games you want to catch 'em all in. Only those show up in the table and count towards
|
||||||
|
your progress.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex gap-2 pb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cursor-pointer rounded-lg bg-gray-200 px-3 py-1 text-sm hover:bg-gray-300"
|
||||||
|
onclick={() => (draft = new SvelteSet(data.games.map((g) => g.id)))}>All games</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cursor-pointer rounded-lg bg-gray-200 px-3 py-1 text-sm hover:bg-gray-300"
|
||||||
|
onclick={() => (draft = new SvelteSet())}>None</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/setGames"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
await update();
|
||||||
|
if (result.type === 'success') rulesDialog.close();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="grid max-h-96 grid-cols-2 gap-x-6 overflow-y-auto sm:grid-cols-3">
|
||||||
|
{#each gamesByGeneration as [generation, games] (generation)}
|
||||||
|
<fieldset class="pb-3">
|
||||||
|
<legend class="flex items-center gap-2 pb-1 font-bold">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={games.every((g) => draft.has(g.id))}
|
||||||
|
onchange={(e) => {
|
||||||
|
for (const g of games) {
|
||||||
|
if (e.currentTarget.checked) draft.add(g.id);
|
||||||
|
else draft.delete(g.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Gen {generation}
|
||||||
|
</legend>
|
||||||
|
{#each games as game (game.id)}
|
||||||
|
<label class="flex items-center gap-2 py-0.5 pl-4 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="gameIds"
|
||||||
|
value={game.id}
|
||||||
|
checked={draft.has(game.id)}
|
||||||
|
onchange={(e) => {
|
||||||
|
if (e.currentTarget.checked) draft.add(game.id);
|
||||||
|
else draft.delete(game.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{game.name}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</fieldset>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cursor-pointer rounded-lg bg-gray-200 px-4 py-2 font-bold hover:bg-gray-300"
|
||||||
|
onclick={() => rulesDialog.close()}>Cancel</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="cursor-pointer rounded-lg bg-blue-400 px-4 py-2 font-bold text-white hover:bg-blue-500"
|
||||||
|
disabled={!data.user}
|
||||||
|
title={data.user ? undefined : 'Log in to save rules'}>Save</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
1
src/routes/projects/catch-em-all/layout.css
Normal file
1
src/routes/projects/catch-em-all/layout.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { db } from '$lib/db';
|
import { db } from '$lib/server/db';
|
||||||
import { tasks } from '$lib/db/schema.js';
|
|
||||||
import { desc } from 'drizzle-orm';
|
|
||||||
|
|
||||||
export const load = async () => {
|
export const load = async () => {
|
||||||
return {
|
return {
|
||||||
@@ -12,7 +10,9 @@ export const load = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
orderBy: [desc(tasks.currentPoints)]
|
orderBy: {
|
||||||
|
currentPoints: 'desc'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { db } from '$lib/db';
|
import { db } from '$lib/server/db';
|
||||||
import { tasks, tasksToTopics, topics } from '$lib/db/schema';
|
import { tasks, tasksToTopics, topics } from '$lib/server/db/schema';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
|
||||||
export const load = async ({ cookies, params }) => {
|
export const load = async ({ cookies, params }) => {
|
||||||
@@ -12,7 +12,9 @@ export const load = async ({ cookies, params }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
where: eq(tasks.id, params.id)
|
where: {
|
||||||
|
id: Number(params.id)
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
topics: await db.query.topics.findMany(),
|
topics: await db.query.topics.findMany(),
|
||||||
editable: cookies.get('token')
|
editable: cookies.get('token')
|
||||||
20
src/routes/projects/progress/create/+page.server.ts
Executable file
20
src/routes/projects/progress/create/+page.server.ts
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { tasks } from '$lib/server/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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { db } from '$lib/db';
|
import { db } from '$lib/server/db';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async () => {
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user