Compare commits
39 Commits
16186baf7f
...
staging
| Author | SHA1 | Date | |
|---|---|---|---|
| ce5c1cc9ee | |||
| 739d8486b2 | |||
| b11c414130 | |||
| 0994f31281 | |||
| 4328d81408 | |||
| 4822125787 | |||
| 62ae672630 | |||
| d793400336 | |||
| f94d68f539 | |||
| 20dddaa263 | |||
| 3c2b626ea2 | |||
| 07f6a77fc6 | |||
| eba89011aa | |||
| fe225f8a3d | |||
| c76a137090 | |||
| 0e8c3c70b8 | |||
| 99e97ce750 | |||
| 077e9f54b9 | |||
| b005a39f7c | |||
| c9a7294d13 | |||
| 95514c8eea | |||
| 97ff9852cd | |||
| 6452331ee7 | |||
| a337879875 | |||
| 40ff6f795b | |||
| cb5fc9d095 | |||
| b02a07d3bd | |||
| 36cf013eef | |||
| 2bfba0d8fe | |||
| fd06713555 | |||
| e8c8779536 | |||
| 1d53921a73 | |||
| f28c0f56ad | |||
| c55c266f44 | |||
|
|
a27ec15124 | ||
|
|
e77509b909 | ||
| 1a2c7b5f55 | |||
| 3a1877375d | |||
| fe3329fc37 |
40
.gitea/workflows/build.yml
Normal file
40
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
branches: [main, staging]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: git.stanrunge.dev/stan/personal-website:${{ github.sha }}
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: setup key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H 172.17.0.1 >> ~/.ssh/known_hosts
|
||||
|
||||
- name: pick target
|
||||
run: |
|
||||
if [ "${{ github.ref_name }}" = "main" ]; then
|
||||
echo "DIR=/opt/personal-website" >> $GITHUB_ENV
|
||||
else
|
||||
echo "DIR=/opt/personal-website-staging" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: deploy
|
||||
run: |
|
||||
ssh deploy@172.17.0.1 "echo IMAGE_TAG=${{ github.sha }} | sudo tee $DIR/.env && sudo docker compose -f $DIR/compose.prod.yml up -d"
|
||||
22
.gitignore
vendored
22
.gitignore
vendored
@@ -1 +1,21 @@
|
||||
.env
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
15
Dockerfile
Executable file
15
Dockerfile
Executable file
@@ -0,0 +1,15 @@
|
||||
FROM oven/bun AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install
|
||||
COPY . .
|
||||
RUN bun run build
|
||||
|
||||
FROM oven/bun
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/build ./build
|
||||
COPY package.json ./
|
||||
EXPOSE 3000
|
||||
ENV NODE_ENV=production
|
||||
CMD ["bun", "build/index.js"]
|
||||
8
README.md
Executable file
8
README.md
Executable file
@@ -0,0 +1,8 @@
|
||||
# Stan Website
|
||||
|
||||
This is my personal website, including:
|
||||
|
||||
- Contact info / social links
|
||||
- About me
|
||||
- Blog posts + comments
|
||||
- Projects like Animaldex, CatchEmAll
|
||||
16
components.json
Normal file
16
components.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"tailwind": {
|
||||
"css": "src/app.css",
|
||||
"baseColor": "slate"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://tw3.shadcn-svelte.com/registry/default"
|
||||
}
|
||||
@@ -6,18 +6,18 @@ services:
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DB=postgres
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- '5432:5432'
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
- db-data:/var/lib/postgresql
|
||||
|
||||
sveltekit:
|
||||
image: oven/bun
|
||||
volumes:
|
||||
- ./packages/web:/app
|
||||
working_dir: /app
|
||||
command: bun run dev --host
|
||||
command: sh -c "bun install & bun run dev --host"
|
||||
ports:
|
||||
- "80:5173"
|
||||
- '3000:5173'
|
||||
env_file: .env
|
||||
develop:
|
||||
watch:
|
||||
|
||||
@@ -2,12 +2,11 @@ import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
dialect: 'postgresql',
|
||||
schema: './src/lib/db/schema.ts',
|
||||
schema: './src/lib/server/db/schema.ts',
|
||||
out: './drizzle',
|
||||
verbose: true,
|
||||
strict: true,
|
||||
dbCredentials: {
|
||||
url: process.env.DB_URL
|
||||
},
|
||||
schemaFilter: 'personal_website'
|
||||
}
|
||||
});
|
||||
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
@@ -27,6 +27,12 @@ export default [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
languageOptions: {
|
||||
globals: globals.node
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ['build/', '.svelte-kit/', 'dist/']
|
||||
}
|
||||
8737
package-lock.json
generated
Normal file
8737
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
75
package.json
Executable file
75
package.json
Executable file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "npm run test:integration && npm run test:unit",
|
||||
"check": "svelte-kit sync && svelte-check --tsgo --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsgo --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write .",
|
||||
"test:integration": "playwright test",
|
||||
"test:unit": "vitest",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:seed": "bun scripts/seed.ts",
|
||||
"db:reset": "bun scripts/reset.ts && drizzle-kit push --force && bun scripts/seed.ts",
|
||||
"auth:schema": "bunx @better-auth/cli generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@iconify-json/simple-icons": "^1.2.94",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@sveltejs/enhanced-img": "^0.11.0",
|
||||
"@sveltejs/kit": "^2.70.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/eslint": "9.6.1",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@typescript/native": "npm:typescript@7",
|
||||
"better-auth": "^1.7.2",
|
||||
"bits-ui": "^2.19.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk-sv": "^0.0.19",
|
||||
"drizzle-kit": "^1.0.0-rc.4",
|
||||
"eslint": "^10.9.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.23.0",
|
||||
"globals": "^17.11.0",
|
||||
"lucide-svelte": "^1.0.1",
|
||||
"prettier": "3.9.6",
|
||||
"prettier-plugin-svelte": "^4.1.1",
|
||||
"svelte": "^5.57.0",
|
||||
"svelte-check": "4.7.6",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwind-variants": "^3.3.1",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6",
|
||||
"typescript-eslint": "8.68.0",
|
||||
"unplugin-icons": "^23.0.1",
|
||||
"vite": "^8.2.2",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^4.0.46",
|
||||
"@aws-sdk/client-s3": "^3.1121.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1121.0",
|
||||
"@maptiler/sdk": "^4.1.0",
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
"ai": "^7.0.85",
|
||||
"drizzle-orm": "^1.0.0-rc.4",
|
||||
"highlight.js": "^11.12.0",
|
||||
"marked": "^18.0.11",
|
||||
"marked-highlight": "^2.2.4",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"postgres": "^3.4.9",
|
||||
"zod": "^4.5.4"
|
||||
}
|
||||
}
|
||||
21
packages/web/.gitignore
vendored
21
packages/web/.gitignore
vendored
@@ -1,21 +0,0 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
@@ -1,25 +0,0 @@
|
||||
FROM oven/bun AS builder
|
||||
|
||||
RUN bun
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json bun.lockb ./
|
||||
|
||||
RUN bun install
|
||||
COPY . .
|
||||
|
||||
|
||||
RUN bun run build
|
||||
|
||||
|
||||
|
||||
FROM oven/bun
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app ./
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
CMD ["bun", "build/index.js"]
|
||||
@@ -1,38 +0,0 @@
|
||||
# create-svelte
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```bash
|
||||
# create a new project in the current directory
|
||||
npm create svelte@latest
|
||||
|
||||
# create a new project in my-app
|
||||
npm create svelte@latest my-app
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.
|
||||
Binary file not shown.
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"style": "default",
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/app.css",
|
||||
"baseColor": "slate"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils"
|
||||
},
|
||||
"typescript": 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
|
||||
}
|
||||
]
|
||||
}
|
||||
4921
packages/web/package-lock.json
generated
4921
packages/web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,57 +0,0 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "npm run test:integration && npm run test:unit",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write .",
|
||||
"test:integration": "playwright test",
|
||||
"test:unit": "vitest",
|
||||
"generate": "drizzle-kit generate",
|
||||
"migrate": "drizzle-kit migrate",
|
||||
"studio": "drizzle-kit studio",
|
||||
"seed": "bun src/lib/db/seed.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.49.1",
|
||||
"@sveltejs/kit": "^2.15.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@types/eslint": "9.6.1",
|
||||
"@types/pg": "^8.11.10",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"bits-ui": "^0.22.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk-sv": "^0.0.18",
|
||||
"drizzle-kit": "^0.30.1",
|
||||
"eslint": "9.17.0",
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"eslint-plugin-svelte": "^2.46.1",
|
||||
"globals": "15.14.0",
|
||||
"lucide-svelte": "^0.473.0",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "3.4.2",
|
||||
"prettier-plugin-svelte": "3.3.2",
|
||||
"svelte": "^5.16.5",
|
||||
"svelte-check": "4.1.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwind-variants": "^0.3.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "5.7.2",
|
||||
"typescript-eslint": "8.19.1",
|
||||
"vite": "^6.0.7",
|
||||
"vitest": "2.1.8"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@sveltejs/adapter-node": "^5.2.11",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"mode-watcher": "^0.5.0",
|
||||
"postgres": "^3.4.5"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--destructive: 0 72.2% 50.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--ring: 222.2 84% 4.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-7K10F5HJMQ"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() {dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', 'G-7K10F5HJMQ');
|
||||
</script>
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="%sveltekit.assets%/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="%sveltekit.assets%/favicon-16x16.png">
|
||||
<link rel="manifest" href="%sveltekit.assets%/site.webmanifest">
|
||||
<title>Stan Runge</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button as ButtonPrimitive } from "bits-ui";
|
||||
import { type Events, type Props, buttonVariants } from "./index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = Props;
|
||||
type $$Events = Events;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export let variant: $$Props["variant"] = "default";
|
||||
export let size: $$Props["size"] = "default";
|
||||
export let builders: $$Props["builders"] = [];
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<ButtonPrimitive.Root
|
||||
{builders}
|
||||
class={cn(buttonVariants({ variant, size, className }))}
|
||||
type="button"
|
||||
{...$$restProps}
|
||||
on:click
|
||||
on:keydown
|
||||
>
|
||||
<slot />
|
||||
</ButtonPrimitive.Root>
|
||||
@@ -1,49 +0,0 @@
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
import type { Button as ButtonPrimitive } from "bits-ui";
|
||||
import Root from "./button.svelte";
|
||||
|
||||
const buttonVariants = tv({
|
||||
base: "ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border-input bg-background hover:bg-accent hover:text-accent-foreground border",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
|
||||
type Variant = VariantProps<typeof buttonVariants>["variant"];
|
||||
type Size = VariantProps<typeof buttonVariants>["size"];
|
||||
|
||||
type Props = ButtonPrimitive.Props & {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
};
|
||||
|
||||
type Events = ButtonPrimitive.Events;
|
||||
|
||||
export {
|
||||
Root,
|
||||
type Props,
|
||||
type Events,
|
||||
//
|
||||
Root as Button,
|
||||
type Props as ButtonProps,
|
||||
type Events as ButtonEvents,
|
||||
buttonVariants,
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
|
||||
import Root from "./command.svelte";
|
||||
import Dialog from "./command-dialog.svelte";
|
||||
import Empty from "./command-empty.svelte";
|
||||
import Group from "./command-group.svelte";
|
||||
import Item from "./command-item.svelte";
|
||||
import Input from "./command-input.svelte";
|
||||
import List from "./command-list.svelte";
|
||||
import Separator from "./command-separator.svelte";
|
||||
import Shortcut from "./command-shortcut.svelte";
|
||||
|
||||
const Loading = CommandPrimitive.Loading;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Dialog,
|
||||
Empty,
|
||||
Group,
|
||||
Item,
|
||||
Input,
|
||||
List,
|
||||
Separator,
|
||||
Shortcut,
|
||||
Loading,
|
||||
//
|
||||
Root as Command,
|
||||
Dialog as CommandDialog,
|
||||
Empty as CommandEmpty,
|
||||
Group as CommandGroup,
|
||||
Item as CommandItem,
|
||||
Input as CommandInput,
|
||||
List as CommandList,
|
||||
Separator as CommandSeparator,
|
||||
Shortcut as CommandShortcut,
|
||||
Loading as CommandLoading,
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -1,13 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<div class={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...$$restProps}>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -1,21 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { fade } from "svelte/transition";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = DialogPrimitive.OverlayProps;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export let transition: $$Props["transition"] = fade;
|
||||
export let transitionConfig: $$Props["transitionConfig"] = {
|
||||
duration: 150,
|
||||
};
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Overlay
|
||||
{transition}
|
||||
{transitionConfig}
|
||||
class={cn("bg-background/80 fixed inset-0 z-50 backdrop-blur-sm", className)}
|
||||
{...$$restProps}
|
||||
/>
|
||||
@@ -1,16 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = DialogPrimitive.TitleProps;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Title
|
||||
class={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</DialogPrimitive.Title>
|
||||
@@ -1,22 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
import { cn, flyAndScale } from "$lib/utils.js";
|
||||
|
||||
type $$Props = PopoverPrimitive.ContentProps;
|
||||
let className: $$Props["class"] = undefined;
|
||||
export let transition: $$Props["transition"] = flyAndScale;
|
||||
export let transitionConfig: $$Props["transitionConfig"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Content
|
||||
{transition}
|
||||
{transitionConfig}
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-md outline-none",
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</PopoverPrimitive.Content>
|
||||
@@ -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()
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { ModeWatcher } from 'mode-watcher';
|
||||
import '../app.css';
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
const { children, data }: Props = $props();
|
||||
|
||||
let editModalVisible = $state(false);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="min-h-screen bg-black text-white bg-center bg-[url('/moon.jpg')] bg-auto bg-no-repeat flex flex-col"
|
||||
>
|
||||
<nav class="flex justify-between bg-gray-800 items-center px-4 py-2">
|
||||
<div class="m-2">
|
||||
<a href="/" class="font-bold text-xl sm:text-2xl">Stan Runge</a>
|
||||
</div>
|
||||
<div class="m-2 flex gap-4 items-center">
|
||||
<a href="/progress" class="hover:underline">Progress</a>
|
||||
<span>|</span>
|
||||
<div class="flex gap-2 items-center">
|
||||
<a href="mailto:stan@stanrunge.dev" class="hover:text-gray-300" aria-label="Mail">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h16q.825 0 1.413.588T22 6v12q0 .825-.587 1.413T20 20zm8-7L4 8v10h16V8zm0-2l8-5H4zM4 8V6v12z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="https://github.com/stanrunge" class="hover:text-gray-300" aria-label="GitHub">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34
|
||||
c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83
|
||||
c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64
|
||||
c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02
|
||||
c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21
|
||||
c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="https://x.com/stanrunge" class="hover:text-gray-300" aria-label="X">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m17.687 3.063l-4.996 5.711l-4.32-5.711H2.112l7.477 9.776l-7.086 8.099h3.034l5.469-6.25l4.78 6.25h6.102
|
||||
l-7.794-10.304l6.625-7.571zm-1.064 16.06L5.654 4.782h1.803l10.846 14.34z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://linkedin.com/in/stanrunge"
|
||||
class="hover:text-gray-300"
|
||||
aria-label="LinkedIn"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M19 3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zm-.5 15.5v-5.3
|
||||
a3.26 3.26 0 0 0-3.26-3.26c-.85 0-1.84.52-2.32 1.3v-1.11h-2.79v8.37h2.79v-4.93c0-.77.62-1.4 1.39-1.4a1.4 1.4 0 0 1
|
||||
1.4 1.4v4.93zM6.88 8.56a1.68 1.68 0 0 0 1.68-1.68c0-.93-.75-1.69-1.68-1.69a1.69 1.69 0 0 0-1.69
|
||||
1.69c0 .93.76 1.68 1.69 1.68m1.39 9.94v-8.37H5.5v8.37z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- page content goes here -->
|
||||
<main class="mx-auto py-8 px-4 max-w-5xl flex-grow">
|
||||
<ModeWatcher />
|
||||
{@render children?.()}
|
||||
</main>
|
||||
|
||||
{#if !data.editable}
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
class="p-3 rounded border border-white m-4"
|
||||
onclick={() => (editModalVisible = !editModalVisible)}>Edit</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if editModalVisible}
|
||||
<div
|
||||
class="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50"
|
||||
onclick={() => (editModalVisible = false)}
|
||||
>
|
||||
<form
|
||||
class="bg-white rounded-lg shadow-lg p-6 max-w-md w-full flex flex-col items-center"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
action="/?/authenticate"
|
||||
method="post"
|
||||
>
|
||||
<h2 class="text-lg font-semibold m-4 text-black">WHO ARE YOU?!?!?!??!</h2>
|
||||
<input type="text" class="border border-black text-center rounded my-4" name="token" />
|
||||
<button class="px-4 py-2 bg-gray-800 text-white rounded hover:bg-gray-700">
|
||||
Authenticate
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,10 +0,0 @@
|
||||
<h1 class="my-8 text-4xl font-bold text-center">Stan Runge</h1>
|
||||
<img src="/moon.jpg" alt="Moon" />
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div>Vash Software</div>
|
||||
|
||||
<div>Hogeschool Inholland</div>
|
||||
|
||||
<div>Junior</div>
|
||||
</div>
|
||||
@@ -1,18 +0,0 @@
|
||||
import { db } from '$lib/db';
|
||||
import { tasks } from '$lib/db/schema.js';
|
||||
import { desc } from 'drizzle-orm';
|
||||
|
||||
export const load = async ({ cookies }) => {
|
||||
return {
|
||||
tasks: await db.query.tasks.findMany({
|
||||
with: {
|
||||
tasksToTopics: {
|
||||
with: {
|
||||
topic: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: [desc(tasks.currentPoints)]
|
||||
})
|
||||
};
|
||||
};
|
||||
@@ -1,128 +0,0 @@
|
||||
<script lang="ts">
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center p-4">
|
||||
<div class="w-full max-w-4xl flex flex-col gap-4 items-center mt-4 mb-8">
|
||||
<div class="flex justify-center gap-8">
|
||||
<h1 class="font-bold text-2xl">
|
||||
Tasks ({data.tasks.length})
|
||||
</h1>
|
||||
{#if data.editable}
|
||||
<a href="/progress/create">
|
||||
<button class="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 transition">
|
||||
Create
|
||||
</button>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
Total progress: {data.tasks.reduce((acc, task) => acc + task.currentPoints, 0)} / {data.tasks.reduce(
|
||||
(acc, task) => acc + task.totalPoints,
|
||||
0
|
||||
)} ({(
|
||||
(data.tasks.reduce((acc, task) => acc + task.currentPoints, 0) /
|
||||
data.tasks.reduce((acc, task) => acc + task.totalPoints, 0)) *
|
||||
100
|
||||
).toFixed(3)}%)
|
||||
</div>
|
||||
|
||||
{#if data.tasks.length > 0}
|
||||
<div class="overflow-x-auto w-full">
|
||||
{#if data.tasks.length > 0}
|
||||
<!-- Desktop Table View -->
|
||||
<div class="hidden md:block">
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-white">
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold">Name</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold">Topics</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold min-w-[200px]">Progress</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.tasks as task, index}
|
||||
<tr class="{index % 2 === 0 ? 'bg-black' : 'bg-gray-950'} hover:bg-gray-800">
|
||||
<td class="px-6 py-2 border-b">
|
||||
<a href={`/progress/${task.id}`} class="text-blue-500 hover:underline">
|
||||
{task.name}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-6 py-2 border-b">
|
||||
<a href={`/progress/${task.id}`} class="flex gap-2 flex-wrap">
|
||||
{#each task.tasksToTopics as topic}
|
||||
<div
|
||||
class="relative bg-blue-500 text-white rounded-full px-2 py-1 shadow font-medium hover:bg-blue-600 transition cursor-pointer"
|
||||
title="Click for more details about {topic.topic.name}"
|
||||
>
|
||||
{topic.topic.name}
|
||||
</div>
|
||||
{/each}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-6 py-2 border-b min-w-[200px]">
|
||||
<div class="relative w-full h-6 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="absolute h-full bg-green-500"
|
||||
style="width: {(task.currentPoints / task.totalPoints) * 100}%"
|
||||
></div>
|
||||
<span class="absolute inset-0 flex items-center justify-center text-sm">
|
||||
{task.currentPoints} / {task.totalPoints} ({(
|
||||
(task.currentPoints / task.totalPoints) *
|
||||
100
|
||||
).toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Card View -->
|
||||
<div class="md:hidden space-y-4">
|
||||
{#each data.tasks as task}
|
||||
<div class="bg-gray-800 rounded-lg p-2 w-full">
|
||||
<a
|
||||
href={`/progress/${task.id}`}
|
||||
class="text-blue-500 hover:underline text-lg font-bold"
|
||||
>
|
||||
{task.name}
|
||||
</a>
|
||||
<div class="mt-1">
|
||||
<div class="mb-1"></div>
|
||||
<span class="font-medium">Topics:</span>
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
{#each task.tasksToTopics as topic}
|
||||
<div
|
||||
class="bg-blue-500 text-white rounded-full px-2 py-1 shadow font-medium hover:bg-blue-600 transition cursor-pointer"
|
||||
title="Click for more details about {topic.topic.name}"
|
||||
>
|
||||
{topic.topic.name}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full h-4 bg-gray-700 rounded-full relative overflow-hidden">
|
||||
<div
|
||||
class="absolute h-full bg-green-500"
|
||||
style="width: {(task.currentPoints / task.totalPoints) * 100}%"
|
||||
></div>
|
||||
<span class="absolute inset-0 flex items-center justify-center text-xs">
|
||||
{task.currentPoints} / {task.totalPoints} ({(
|
||||
(task.currentPoints / task.totalPoints) *
|
||||
100
|
||||
).toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-gray-500">No tasks available. Click "Create" to add a new task.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-gray-500">No tasks available. Click "Create" to add a new task.</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { fontFamily } from "tailwindcss/defaultTheme";
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
const config = {
|
||||
darkMode: ["class"],
|
||||
content: ["./src/**/*.{html,js,svelte,ts}"],
|
||||
safelist: ["dark"],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px"
|
||||
}
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border) / <alpha-value>)",
|
||||
input: "hsl(var(--input) / <alpha-value>)",
|
||||
ring: "hsl(var(--ring) / <alpha-value>)",
|
||||
background: "hsl(var(--background) / <alpha-value>)",
|
||||
foreground: "hsl(var(--foreground) / <alpha-value>)",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary) / <alpha-value>)",
|
||||
foreground: "hsl(var(--primary-foreground) / <alpha-value>)"
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary) / <alpha-value>)",
|
||||
foreground: "hsl(var(--secondary-foreground) / <alpha-value>)"
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
|
||||
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)"
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted) / <alpha-value>)",
|
||||
foreground: "hsl(var(--muted-foreground) / <alpha-value>)"
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent) / <alpha-value>)",
|
||||
foreground: "hsl(var(--accent-foreground) / <alpha-value>)"
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover) / <alpha-value>)",
|
||||
foreground: "hsl(var(--popover-foreground) / <alpha-value>)"
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card) / <alpha-value>)",
|
||||
foreground: "hsl(var(--card-foreground) / <alpha-value>)"
|
||||
}
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)"
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [...fontFamily.sans]
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,9 +0,0 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
test: {
|
||||
include: ['src/**/*.{test,spec}.{js,ts}']
|
||||
}
|
||||
});
|
||||
56
src/app.css
Executable file
56
src/app.css
Executable file
@@ -0,0 +1,56 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
@theme {
|
||||
--color-bg: #f6f4f0;
|
||||
--color-surface: #edeae4;
|
||||
--color-surface-hover: #e4e0d8;
|
||||
--color-border: #d6d2ca;
|
||||
--color-border-light: #e8e5de;
|
||||
--color-text: #2c2c2c;
|
||||
--color-text-muted: #6b6b6b;
|
||||
--color-text-dim: #999999;
|
||||
--color-text-ghost: #c5c0b8;
|
||||
--color-accent: #3d6b8e;
|
||||
--color-accent-hover: #4d7fa6;
|
||||
--color-danger: #b44;
|
||||
|
||||
--font-heading: 'DM Serif Display', Georgia, serif;
|
||||
--font-mono: 'IBM Plex Mono', 'Courier New', monospace;
|
||||
--font-body: 'Source Sans 3', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
section[id] {
|
||||
scroll-margin-top: 88px;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
line-height: 1.65;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
2
packages/web/src/app.d.ts → src/app.d.ts
vendored
2
packages/web/src/app.d.ts → src/app.d.ts
vendored
@@ -1,3 +1,5 @@
|
||||
/// <reference types="unplugin-icons/types/svelte" />
|
||||
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
29
src/app.html
Executable file
29
src/app.html
Executable file
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-7K10F5HJMQ"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', 'G-7K10F5HJMQ');
|
||||
</script>
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="%sveltekit.assets%/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="%sveltekit.assets%/favicon-16x16.png" />
|
||||
<link rel="manifest" href="%sveltekit.assets%/site.webmanifest" />
|
||||
<title>Stan Runge</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
BIN
src/lib/assets/stan.jpg
Normal file
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>
|
||||
25
src/lib/components/ui/button/button.svelte
Normal file
25
src/lib/components/ui/button/button.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { Button as ButtonPrimitive } from 'bits-ui';
|
||||
import { type Events, type Props, buttonVariants } from './index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
type $$Props = Props;
|
||||
type $$Events = Events;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export let variant: $$Props['variant'] = 'default';
|
||||
export let size: $$Props['size'] = 'default';
|
||||
export let builders: $$Props['builders'] = [];
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<ButtonPrimitive.Root
|
||||
{builders}
|
||||
class={cn(buttonVariants({ variant, size, className }))}
|
||||
type="button"
|
||||
{...$$restProps}
|
||||
on:click
|
||||
on:keydown
|
||||
>
|
||||
<slot />
|
||||
</ButtonPrimitive.Root>
|
||||
48
src/lib/components/ui/button/index.ts
Normal file
48
src/lib/components/ui/button/index.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { type VariantProps, tv } from 'tailwind-variants';
|
||||
import type { Button as ButtonPrimitive } from 'bits-ui';
|
||||
import Root from './button.svelte';
|
||||
|
||||
const buttonVariants = tv({
|
||||
base: 'ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border-input bg-background hover:bg-accent hover:text-accent-foreground border',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
});
|
||||
|
||||
type Variant = VariantProps<typeof buttonVariants>['variant'];
|
||||
type Size = VariantProps<typeof buttonVariants>['size'];
|
||||
|
||||
type Props = ButtonPrimitive.Props & {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
};
|
||||
|
||||
type Events = ButtonPrimitive.Events;
|
||||
|
||||
export {
|
||||
Root,
|
||||
type Props,
|
||||
type Events,
|
||||
//
|
||||
Root as Button,
|
||||
type Props as ButtonProps,
|
||||
type Events as ButtonEvents,
|
||||
buttonVariants
|
||||
};
|
||||
@@ -1,13 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import type { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import Command from "./command.svelte";
|
||||
import * as Dialog from "$lib/components/ui/dialog/index.js";
|
||||
import type { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
import type { Command as CommandPrimitive } from 'cmdk-sv';
|
||||
import Command from './command.svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
|
||||
type $$Props = DialogPrimitive.Props & CommandPrimitive.CommandProps;
|
||||
|
||||
export let open: $$Props["open"] = false;
|
||||
export let value: $$Props["value"] = undefined;
|
||||
export let open: $$Props['open'] = false;
|
||||
export let value: $$Props['value'] = undefined;
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open {...$$restProps}>
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Command as CommandPrimitive } from 'cmdk-sv';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
type $$Props = CommandPrimitive.EmptyProps;
|
||||
let className: string | undefined | null = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Empty class={cn("py-6 text-center text-sm", className)} {...$$restProps}>
|
||||
<CommandPrimitive.Empty class={cn('py-6 text-center text-sm', className)} {...$$restProps}>
|
||||
<slot />
|
||||
</CommandPrimitive.Empty>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Command as CommandPrimitive } from 'cmdk-sv';
|
||||
import { cn } from '$lib/utils.js';
|
||||
type $$Props = CommandPrimitive.GroupProps;
|
||||
|
||||
let className: string | undefined | null = undefined;
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<CommandPrimitive.Group
|
||||
class={cn(
|
||||
"text-foreground [&_[data-cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[data-cmdk-group-heading]]:px-2 [&_[data-cmdk-group-heading]]:py-1.5 [&_[data-cmdk-group-heading]]:text-xs [&_[data-cmdk-group-heading]]:font-medium",
|
||||
'text-foreground [&_[data-cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[data-cmdk-group-heading]]:px-2 [&_[data-cmdk-group-heading]]:py-1.5 [&_[data-cmdk-group-heading]]:text-xs [&_[data-cmdk-group-heading]]:font-medium',
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
@@ -1,20 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import Search from "lucide-svelte/icons/search";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Command as CommandPrimitive } from 'cmdk-sv';
|
||||
import Search from 'lucide-svelte/icons/search';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
type $$Props = CommandPrimitive.InputProps;
|
||||
|
||||
let className: string | undefined | null = undefined;
|
||||
export { className as class };
|
||||
export let value: string = "";
|
||||
export let value: string = '';
|
||||
</script>
|
||||
|
||||
<div class="flex items-center border-b px-2" data-cmdk-input-wrapper="">
|
||||
<Search class="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
class={cn(
|
||||
"placeholder:text-muted-foreground flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
'placeholder:text-muted-foreground flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Command as CommandPrimitive } from 'cmdk-sv';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
type $$Props = CommandPrimitive.ItemProps;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<CommandPrimitive.Item
|
||||
{asChild}
|
||||
class={cn(
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
'aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Command as CommandPrimitive } from 'cmdk-sv';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
type $$Props = CommandPrimitive.ListProps;
|
||||
let className: string | undefined | null = undefined;
|
||||
@@ -8,7 +8,7 @@
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.List
|
||||
class={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
class={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Command as CommandPrimitive } from 'cmdk-sv';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
type $$Props = CommandPrimitive.SeparatorProps;
|
||||
let className: string | undefined | null = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Separator class={cn("bg-border -mx-1 h-px", className)} {...$$restProps} />
|
||||
<CommandPrimitive.Separator class={cn('bg-border -mx-1 h-px', className)} {...$$restProps} />
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
type $$Props = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
|
||||
class={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Command as CommandPrimitive } from 'cmdk-sv';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
type $$Props = CommandPrimitive.CommandProps;
|
||||
|
||||
export let value: $$Props["value"] = undefined;
|
||||
export let value: $$Props['value'] = undefined;
|
||||
|
||||
let className: string | undefined | null = undefined;
|
||||
export { className as class };
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<CommandPrimitive.Root
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
|
||||
className
|
||||
)}
|
||||
bind:value
|
||||
37
src/lib/components/ui/command/index.ts
Normal file
37
src/lib/components/ui/command/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Command as CommandPrimitive } from 'cmdk-sv';
|
||||
|
||||
import Root from './command.svelte';
|
||||
import Dialog from './command-dialog.svelte';
|
||||
import Empty from './command-empty.svelte';
|
||||
import Group from './command-group.svelte';
|
||||
import Item from './command-item.svelte';
|
||||
import Input from './command-input.svelte';
|
||||
import List from './command-list.svelte';
|
||||
import Separator from './command-separator.svelte';
|
||||
import Shortcut from './command-shortcut.svelte';
|
||||
|
||||
const Loading = CommandPrimitive.Loading;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Dialog,
|
||||
Empty,
|
||||
Group,
|
||||
Item,
|
||||
Input,
|
||||
List,
|
||||
Separator,
|
||||
Shortcut,
|
||||
Loading,
|
||||
//
|
||||
Root as Command,
|
||||
Dialog as CommandDialog,
|
||||
Empty as CommandEmpty,
|
||||
Group as CommandGroup,
|
||||
Item as CommandItem,
|
||||
Input as CommandInput,
|
||||
List as CommandList,
|
||||
Separator as CommandSeparator,
|
||||
Shortcut as CommandShortcut,
|
||||
Loading as CommandLoading
|
||||
};
|
||||
@@ -1,15 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import X from "lucide-svelte/icons/x";
|
||||
import * as Dialog from "./index.js";
|
||||
import { cn, flyAndScale } from "$lib/utils.js";
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
import X from 'lucide-svelte/icons/x';
|
||||
import * as Dialog from './index.js';
|
||||
import { cn, flyAndScale } from '$lib/utils.js';
|
||||
|
||||
type $$Props = DialogPrimitive.ContentProps;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export let transition: $$Props["transition"] = flyAndScale;
|
||||
export let transitionConfig: $$Props["transitionConfig"] = {
|
||||
duration: 200,
|
||||
let className: $$Props['class'] = undefined;
|
||||
export let transition: $$Props['transition'] = flyAndScale;
|
||||
export let transitionConfig: $$Props['transitionConfig'] = {
|
||||
duration: 200
|
||||
};
|
||||
export { className as class };
|
||||
</script>
|
||||
@@ -20,7 +20,7 @@
|
||||
{transition}
|
||||
{transitionConfig}
|
||||
class={cn(
|
||||
"bg-background fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg md:w-full",
|
||||
'bg-background fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg md:w-full',
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
@@ -1,15 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
type $$Props = DialogPrimitive.DescriptionProps;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
let className: $$Props['class'] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Description
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
class={cn('text-muted-foreground text-sm', className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
16
src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
16
src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
type $$Props = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
13
src/lib/components/ui/dialog/dialog-header.svelte
Normal file
13
src/lib/components/ui/dialog/dialog-header.svelte
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
type $$Props = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<div class={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...$$restProps}>
|
||||
<slot />
|
||||
</div>
|
||||
21
src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
21
src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
type $$Props = DialogPrimitive.OverlayProps;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export let transition: $$Props['transition'] = fade;
|
||||
export let transitionConfig: $$Props['transitionConfig'] = {
|
||||
duration: 150
|
||||
};
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Overlay
|
||||
{transition}
|
||||
{transitionConfig}
|
||||
class={cn('bg-background/80 fixed inset-0 z-50 backdrop-blur-sm', className)}
|
||||
{...$$restProps}
|
||||
/>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
type $$Props = DialogPrimitive.PortalProps;
|
||||
</script>
|
||||
|
||||
16
src/lib/components/ui/dialog/dialog-title.svelte
Normal file
16
src/lib/components/ui/dialog/dialog-title.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
type $$Props = DialogPrimitive.TitleProps;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Title
|
||||
class={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</DialogPrimitive.Title>
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
|
||||
import Title from "./dialog-title.svelte";
|
||||
import Portal from "./dialog-portal.svelte";
|
||||
import Footer from "./dialog-footer.svelte";
|
||||
import Header from "./dialog-header.svelte";
|
||||
import Overlay from "./dialog-overlay.svelte";
|
||||
import Content from "./dialog-content.svelte";
|
||||
import Description from "./dialog-description.svelte";
|
||||
import Title from './dialog-title.svelte';
|
||||
import Portal from './dialog-portal.svelte';
|
||||
import Footer from './dialog-footer.svelte';
|
||||
import Header from './dialog-header.svelte';
|
||||
import Overlay from './dialog-overlay.svelte';
|
||||
import Content from './dialog-content.svelte';
|
||||
import Description from './dialog-description.svelte';
|
||||
|
||||
const Root = DialogPrimitive.Root;
|
||||
const Trigger = DialogPrimitive.Trigger;
|
||||
@@ -33,5 +33,5 @@ export {
|
||||
Overlay as DialogOverlay,
|
||||
Content as DialogContent,
|
||||
Description as DialogDescription,
|
||||
Close as DialogClose,
|
||||
Close as DialogClose
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
import Content from "./popover-content.svelte";
|
||||
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||
import Content from './popover-content.svelte';
|
||||
const Root = PopoverPrimitive.Root;
|
||||
const Trigger = PopoverPrimitive.Trigger;
|
||||
const Close = PopoverPrimitive.Close;
|
||||
@@ -13,5 +13,5 @@ export {
|
||||
Root as Popover,
|
||||
Content as PopoverContent,
|
||||
Trigger as PopoverTrigger,
|
||||
Close as PopoverClose,
|
||||
Close as PopoverClose
|
||||
};
|
||||
22
src/lib/components/ui/popover/popover-content.svelte
Normal file
22
src/lib/components/ui/popover/popover-content.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||
import { cn, flyAndScale } from '$lib/utils.js';
|
||||
|
||||
type $$Props = PopoverPrimitive.ContentProps;
|
||||
let className: $$Props['class'] = undefined;
|
||||
export let transition: $$Props['transition'] = flyAndScale;
|
||||
export let transitionConfig: $$Props['transitionConfig'] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Content
|
||||
{transition}
|
||||
{transitionConfig}
|
||||
class={cn(
|
||||
'bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-md outline-none',
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</PopoverPrimitive.Content>
|
||||
28
src/lib/components/ui/table/index.ts
Normal file
28
src/lib/components/ui/table/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import Root from './table.svelte';
|
||||
import Body from './table-body.svelte';
|
||||
import Caption from './table-caption.svelte';
|
||||
import Cell from './table-cell.svelte';
|
||||
import Footer from './table-footer.svelte';
|
||||
import Head from './table-head.svelte';
|
||||
import Header from './table-header.svelte';
|
||||
import Row from './table-row.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
Body,
|
||||
Caption,
|
||||
Cell,
|
||||
Footer,
|
||||
Head,
|
||||
Header,
|
||||
Row,
|
||||
//
|
||||
Root as Table,
|
||||
Body as TableBody,
|
||||
Caption as TableCaption,
|
||||
Cell as TableCell,
|
||||
Footer as TableFooter,
|
||||
Head as TableHead,
|
||||
Header as TableHeader,
|
||||
Row as TableRow
|
||||
};
|
||||
16
src/lib/components/ui/table/table-body.svelte
Normal file
16
src/lib/components/ui/table/table-body.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import type { WithElementRef } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
|
||||
</script>
|
||||
|
||||
<tbody bind:this={ref} class={cn('[&_tr:last-child]:border-0', className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</tbody>
|
||||
16
src/lib/components/ui/table/table-caption.svelte
Normal file
16
src/lib/components/ui/table/table-caption.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import type { WithElementRef } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||
</script>
|
||||
|
||||
<caption bind:this={ref} class={cn('text-muted-foreground mt-4 text-sm', className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</caption>
|
||||
20
src/lib/components/ui/table/table-cell.svelte
Normal file
20
src/lib/components/ui/table/table-cell.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLTdAttributes } from 'svelte/elements';
|
||||
import type { WithElementRef } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLTdAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<td
|
||||
bind:this={ref}
|
||||
class={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</td>
|
||||
16
src/lib/components/ui/table/table-footer.svelte
Normal file
16
src/lib/components/ui/table/table-footer.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import type { WithElementRef } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
|
||||
</script>
|
||||
|
||||
<tfoot bind:this={ref} class={cn('bg-muted/50 font-medium', className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</tfoot>
|
||||
23
src/lib/components/ui/table/table-head.svelte
Normal file
23
src/lib/components/ui/table/table-head.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLThAttributes } from 'svelte/elements';
|
||||
import type { WithElementRef } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLThAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<th
|
||||
bind:this={ref}
|
||||
class={cn(
|
||||
'text-muted-foreground h-12 px-4 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</th>
|
||||
16
src/lib/components/ui/table/table-header.svelte
Normal file
16
src/lib/components/ui/table/table-header.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import type { WithElementRef } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
|
||||
</script>
|
||||
|
||||
<thead bind:this={ref} class={cn('[&_tr]:border-b', className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</thead>
|
||||
23
src/lib/components/ui/table/table-row.svelte
Normal file
23
src/lib/components/ui/table/table-row.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import type { WithElementRef } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLTableRowElement>> = $props();
|
||||
</script>
|
||||
|
||||
<tr
|
||||
bind:this={ref}
|
||||
class={cn(
|
||||
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</tr>
|
||||
18
src/lib/components/ui/table/table.svelte
Normal file
18
src/lib/components/ui/table/table.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLTableAttributes } from 'svelte/elements';
|
||||
import type { WithElementRef } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLTableAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<div class="relative w-full overflow-auto">
|
||||
<table bind:this={ref} class={cn('w-full caption-bottom text-sm', className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</table>
|
||||
</div>
|
||||
35
src/lib/server/auth.ts
Normal file
35
src/lib/server/auth.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { betterAuth } from 'better-auth/minimal';
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||
import { sveltekitCookies } from 'better-auth/svelte-kit';
|
||||
import { getRequestEvent } from '$app/server';
|
||||
import { db } from '$lib/server/db';
|
||||
import * as schema from '$lib/server/db/schema';
|
||||
import { error, type RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
export const auth = betterAuth({
|
||||
baseURL: env.ORIGIN ?? 'http://localhost:3000',
|
||||
secret: env.BETTER_AUTH_SECRET ?? 'build-time-placeholder',
|
||||
database: drizzleAdapter(db, { provider: 'pg', schema }),
|
||||
emailAndPassword: { enabled: true },
|
||||
socialProviders: {
|
||||
github: {
|
||||
clientId: env.GITHUB_CLIENT_ID,
|
||||
clientSecret: env.GITHUB_CLIENT_SECRET
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
sveltekitCookies(getRequestEvent) // make sure this is the last plugin in the array
|
||||
]
|
||||
});
|
||||
|
||||
export function isAdmin(event: RequestEvent): boolean {
|
||||
const token = event.cookies.get('admin_token');
|
||||
return !!token && token === env.ADMIN_SECRET;
|
||||
}
|
||||
|
||||
export function requireAdmin(event: RequestEvent): void {
|
||||
if (!isAdmin(event)) {
|
||||
error(401, { message: 'Unauthorized' });
|
||||
}
|
||||
}
|
||||
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 * as schema from './schema';
|
||||
import { relations } from './relations';
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
|
||||
const client = postgres(env.DB_URL, { prepare: false });
|
||||
|
||||
export const db = drizzle({
|
||||
schema,
|
||||
relations,
|
||||
client
|
||||
});
|
||||
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
|
||||
})
|
||||
}
|
||||
}));
|
||||
239
src/lib/server/db/schema.ts
Executable file
239
src/lib/server/db/schema.ts
Executable file
@@ -0,0 +1,239 @@
|
||||
import {
|
||||
doublePrecision,
|
||||
index,
|
||||
integer,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
real,
|
||||
serial,
|
||||
text,
|
||||
timestamp,
|
||||
unique,
|
||||
uuid,
|
||||
varchar
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
import { user } from './auth.schema';
|
||||
import { availabilityValues } from '../../availability';
|
||||
|
||||
export const tasks = pgTable('tasks', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 256 }),
|
||||
currentPoints: integer('current_points').default(0),
|
||||
totalPoints: integer('total_points').default(1),
|
||||
notes: varchar('notes', { length: 8192 }),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow()
|
||||
});
|
||||
|
||||
export const topics = pgTable('topics', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 256 }),
|
||||
emoji: varchar('emoji', { length: 256 }),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow()
|
||||
});
|
||||
|
||||
export const tasksToTopics = pgTable('tasks_to_topics', {
|
||||
taskId: integer('task_id')
|
||||
.notNull()
|
||||
.references(() => tasks.id),
|
||||
topicId: integer('topic_id')
|
||||
.notNull()
|
||||
.references(() => topics.id)
|
||||
});
|
||||
|
||||
export const universities = pgTable('universities', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 256 }),
|
||||
progress: integer('progress').default(0),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow()
|
||||
});
|
||||
|
||||
export const certificates = pgTable('certificates', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 256 }),
|
||||
progress: integer('progress').default(0),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow()
|
||||
});
|
||||
|
||||
export const publications = pgTable('publications', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 256 }),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow()
|
||||
});
|
||||
|
||||
// Legacy ^
|
||||
// New
|
||||
|
||||
export const blogArticleStatus = pgEnum('blog_article_status', ['draft', 'published']);
|
||||
|
||||
export const blogArticles = pgTable(
|
||||
'blog_articles',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
title: varchar('title', { length: 256 }).notNull(),
|
||||
slug: varchar('slug', { length: 256 }).notNull(),
|
||||
content: text('content').default(''),
|
||||
status: blogArticleStatus('status').notNull().default('draft'),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow()
|
||||
},
|
||||
(table) => [unique().on(table.slug)]
|
||||
);
|
||||
|
||||
// CatchEmAll
|
||||
|
||||
export const availability = pgEnum('availability', availabilityValues);
|
||||
|
||||
export const pokemon = pgTable('pokemon', {
|
||||
id: serial('id').primaryKey(),
|
||||
number: integer('number').notNull(),
|
||||
generation: integer('generation').notNull().default(1),
|
||||
name: varchar('name', { length: 256 }).notNull(),
|
||||
type1: varchar('type1', { length: 256 }).notNull(),
|
||||
type2: varchar('type2', { length: 256 }),
|
||||
hp: integer('hp').notNull().default(0),
|
||||
attack: integer('attack').notNull().default(0),
|
||||
defense: integer('defense').notNull().default(0),
|
||||
specialAttack: integer('special_attack').notNull().default(0),
|
||||
specialDefense: integer('special_defense').notNull().default(0),
|
||||
speed: integer('speed').notNull().default(0)
|
||||
});
|
||||
|
||||
export const game = pgTable('game', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 256 }).notNull(),
|
||||
generation: integer('generation').notNull().default(1)
|
||||
});
|
||||
|
||||
export const pokemonGameEntry = pgTable(
|
||||
'pokemon_game_entry',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
pokemonId: integer('pokemon_id')
|
||||
.notNull()
|
||||
.references(() => pokemon.id, { onDelete: 'cascade' }),
|
||||
gameId: integer('game_id')
|
||||
.notNull()
|
||||
.references(() => game.id, { onDelete: 'cascade' }),
|
||||
availability: availability('availability').notNull().default('catchable'),
|
||||
// optional extra detail for the tooltip, e.g. "trade with a Blue version owner"
|
||||
note: varchar('note', { length: 256 })
|
||||
},
|
||||
(table) => [unique().on(table.pokemonId, table.gameId), index().on(table.gameId)]
|
||||
);
|
||||
|
||||
export const userEntry = pgTable(
|
||||
'user_entry',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
pokemonGameEntryId: integer('pokemon_game_entry_id')
|
||||
.notNull()
|
||||
.references(() => pokemonGameEntry.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' })
|
||||
},
|
||||
(table) => [unique().on(table.pokemonGameEntryId, table.userId), index().on(table.userId)]
|
||||
);
|
||||
|
||||
// which games a user has chosen to track ("rules")
|
||||
export const userGame = pgTable(
|
||||
'user_game',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
gameId: integer('game_id')
|
||||
.notNull()
|
||||
.references(() => game.id, { onDelete: 'cascade' })
|
||||
},
|
||||
(table) => [unique().on(table.userId, table.gameId), index().on(table.userId)]
|
||||
);
|
||||
|
||||
// Animaldex
|
||||
|
||||
export const animals = pgTable('animals', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
|
||||
species: text('species').notNull(),
|
||||
breed: text('breed'),
|
||||
animalName: text('animal_name'),
|
||||
description: text('description'),
|
||||
|
||||
// AI detection stored on the animal (from the first/primary photo)
|
||||
aiBreedSuggestion: text('ai_breed_suggestion'),
|
||||
aiBreedConfidence: real('ai_breed_confidence'),
|
||||
|
||||
// Moderation — the animal entity is moderated once.
|
||||
// Accepted = visible on map (as long as it has accepted sightings too).
|
||||
// Denied animals are hidden but not deleted.
|
||||
submittedAt: timestamp('submitted_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
acceptedAt: timestamp('accepted_at', { withTimezone: true }),
|
||||
deniedAt: timestamp('denied_at', { withTimezone: true })
|
||||
});
|
||||
|
||||
// The PostGIS `location geography(Point, 4326)` column is managed outside Drizzle
|
||||
// via a trigger in the migration — it auto-syncs from lat/lng on insert/update.
|
||||
// Never write to `location` directly from application code.
|
||||
|
||||
export const sightings = pgTable('sightings', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
animalId: uuid('animal_id')
|
||||
.notNull()
|
||||
.references(() => animals.id, { onDelete: 'cascade' }),
|
||||
|
||||
reporterName: text('reporter_name'),
|
||||
|
||||
seenAt: timestamp('seen_at', { withTimezone: true }).notNull(),
|
||||
lat: doublePrecision('lat').notNull(),
|
||||
lng: doublePrecision('lng').notNull(),
|
||||
// `location` geography column exists in DB but not mapped in Drizzle — use sql`` for spatial queries
|
||||
|
||||
submittedAt: timestamp('submitted_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
acceptedAt: timestamp('accepted_at', { withTimezone: true }),
|
||||
deniedAt: timestamp('denied_at', { withTimezone: true })
|
||||
});
|
||||
|
||||
export const photos = pgTable('photos', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
sightingId: uuid('sighting_id')
|
||||
.notNull()
|
||||
.references(() => sightings.id, { onDelete: 'cascade' }),
|
||||
r2Key: text('r2_key').notNull(),
|
||||
url: text('url').notNull(),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
uploadedAt: timestamp('uploaded_at', { withTimezone: true }).notNull().defaultNow()
|
||||
});
|
||||
|
||||
export type Pokemon = typeof pokemon.$inferSelect;
|
||||
export type Game = typeof game.$inferSelect;
|
||||
export type PokemonGameEntry = typeof pokemonGameEntry.$inferSelect;
|
||||
export type UserEntry = typeof userEntry.$inferSelect;
|
||||
export type UserGame = typeof userGame.$inferSelect;
|
||||
export type Availability = (typeof availability.enumValues)[number];
|
||||
|
||||
export type Animal = typeof animals.$inferSelect;
|
||||
export type NewAnimal = typeof animals.$inferInsert;
|
||||
export type Sighting = typeof sightings.$inferSelect;
|
||||
export type NewSighting = typeof sightings.$inferInsert;
|
||||
export type Photo = typeof photos.$inferSelect;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ModerationStatus = 'pending' | 'accepted' | 'denied';
|
||||
|
||||
export function getModerationStatus(
|
||||
row: Pick<Animal | Sighting, 'acceptedAt' | 'deniedAt'>
|
||||
): ModerationStatus {
|
||||
if (row.deniedAt) return 'denied';
|
||||
if (row.acceptedAt) return 'accepted';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
export * from './auth.schema';
|
||||
@@ -1,11 +1,14 @@
|
||||
import { topics } from "./schema"
|
||||
import * as schema from './schema'
|
||||
import { topics } from './schema';
|
||||
import * as schema from './schema';
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
|
||||
const client = postgres('postgresql://postgres:postgres@localhost:5432/db');
|
||||
const db = drizzle(client, { schema });
|
||||
|
||||
await db.insert(topics).values([{ name: 'Math', emoji: '👍' }, { name: 'Programming', emoji: '👍' }])
|
||||
await db.insert(topics).values([
|
||||
{ name: 'Math', emoji: '👍' },
|
||||
{ name: 'Programming', emoji: '👍' }
|
||||
]);
|
||||
|
||||
process.exit(0)
|
||||
process.exit(0);
|
||||
18
src/lib/server/markdown.ts
Normal file
18
src/lib/server/markdown.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Marked } from 'marked';
|
||||
import { markedHighlight } from 'marked-highlight';
|
||||
import hljs from 'highlight.js';
|
||||
|
||||
const marked = new Marked(
|
||||
markedHighlight({
|
||||
emptyLangClass: 'hljs',
|
||||
langPrefix: 'hljs language-',
|
||||
highlight(code, lang) {
|
||||
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
|
||||
return hljs.highlight(code, { language }).value;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
export function renderMarkdown(content: string): string {
|
||||
return marked.parse(content, { async: false });
|
||||
}
|
||||
86
src/lib/server/r2.ts
Normal file
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 }));
|
||||
}
|
||||
29
src/lib/server/slugify.ts
Normal file
29
src/lib/server/slugify.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { blogArticles } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export function slugify(title: string): string {
|
||||
return (
|
||||
title
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'article'
|
||||
);
|
||||
}
|
||||
|
||||
export async function uniqueSlug(title: string): Promise<string> {
|
||||
const base = slugify(title);
|
||||
let slug = base;
|
||||
let suffix = 2;
|
||||
|
||||
while (
|
||||
(await db.select({ id: blogArticles.id }).from(blogArticles).where(eq(blogArticles.slug, slug)))
|
||||
.length > 0
|
||||
) {
|
||||
slug = `${base}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
|
||||
return slug;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
import type { TransitionConfig } from "svelte/transition";
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import type { TransitionConfig } from 'svelte/transition';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
@@ -19,13 +19,9 @@ export const flyAndScale = (
|
||||
params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 }
|
||||
): TransitionConfig => {
|
||||
const style = getComputedStyle(node);
|
||||
const transform = style.transform === "none" ? "" : style.transform;
|
||||
const transform = style.transform === 'none' ? '' : style.transform;
|
||||
|
||||
const scaleConversion = (
|
||||
valueA: number,
|
||||
scaleA: [number, number],
|
||||
scaleB: [number, number]
|
||||
) => {
|
||||
const scaleConversion = (valueA: number, scaleA: [number, number], scaleB: [number, number]) => {
|
||||
const [minA, maxA] = scaleA;
|
||||
const [minB, maxB] = scaleB;
|
||||
|
||||
@@ -35,13 +31,11 @@ export const flyAndScale = (
|
||||
return valueB;
|
||||
};
|
||||
|
||||
const styleToString = (
|
||||
style: Record<string, number | string | undefined>
|
||||
): string => {
|
||||
const styleToString = (style: Record<string, number | string | undefined>): string => {
|
||||
return Object.keys(style).reduce((str, key) => {
|
||||
if (style[key] === undefined) return str;
|
||||
return str + `${key}:${style[key]};`;
|
||||
}, "");
|
||||
}, '');
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -59,4 +53,4 @@ export const flyAndScale = (
|
||||
},
|
||||
easing: cubicOut
|
||||
};
|
||||
};
|
||||
};
|
||||
10
src/lib/validation/blog.ts
Normal file
10
src/lib/validation/blog.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createArticleSchema = z.object({
|
||||
title: z.string().min(1).max(256)
|
||||
});
|
||||
|
||||
export const updateArticleSchema = z.object({
|
||||
title: z.string().min(1).max(256),
|
||||
content: z.string().max(50000)
|
||||
});
|
||||
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'])
|
||||
});
|
||||
78
src/routes/+layout.svelte
Executable file
78
src/routes/+layout.svelte
Executable file
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
|
||||
import SiOsu from '~icons/simple-icons/osu';
|
||||
import SiSteam from '~icons/simple-icons/steam';
|
||||
import SiTwitch from '~icons/simple-icons/twitch';
|
||||
import SiSpotify from '~icons/simple-icons/spotify';
|
||||
import SiGithub from '~icons/simple-icons/github';
|
||||
import SiYoutube from '~icons/simple-icons/youtube';
|
||||
import SiLinkedin from '~icons/simple-icons/linkedin';
|
||||
import SiInstagram from '~icons/simple-icons/instagram';
|
||||
import SiX from '~icons/simple-icons/x';
|
||||
// import SiBluesky from '~icons/simple-icons/bluesky';
|
||||
// import SiThreads from '~icons/simple-icons/threads';
|
||||
import SiMail from '~icons/simple-icons/gmail';
|
||||
|
||||
import CopyButton from '$lib/components/CopyButton.svelte';
|
||||
import SiDiscord from '~icons/simple-icons/discord';
|
||||
import SiSignal from '~icons/simple-icons/signal';
|
||||
import SiWhatsapp from '~icons/simple-icons/whatsapp';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const links = [
|
||||
{ icon: SiMail, label: 'Email', href: 'mailto:stan@stanrunge.dev' },
|
||||
{ icon: SiGithub, label: 'GitHub', href: 'https://github.com/stanrunge' },
|
||||
{ icon: SiLinkedin, label: 'LinkedIn', href: 'https://linkedin.com/in/stanrunge' },
|
||||
{ icon: SiX, label: 'X', href: 'https://x.com/stanrunge' },
|
||||
{ icon: SiYoutube, label: 'YouTube', href: 'https://youtube.com/@stanrunge' },
|
||||
{ icon: SiInstagram, label: 'Instagram', href: 'https://instagram.com/stan_runge' },
|
||||
// { icon: SiBluesky, label: 'Bluesky', href: 'https://bsky.app/profile/stanrunge.bsky.social' },
|
||||
// { icon: SiThreads, label: 'Threads', href: 'https://www.threads.com/@stan_runge' },
|
||||
{ icon: SiTwitch, label: 'Twitch', href: 'https://twitch.tv/stanrunge_' },
|
||||
{
|
||||
icon: SiSpotify,
|
||||
label: 'Spotify',
|
||||
href: 'https://open.spotify.com/user/e3o3qr9z7fpsu4gur9zz4y1ld'
|
||||
},
|
||||
{ icon: SiSteam, label: 'Steam', href: 'https://steamcommunity.com/id/stanrunge' },
|
||||
{ icon: SiOsu, label: 'osu!', href: 'https://osu.ppy.sh/users/11212255' }
|
||||
];
|
||||
|
||||
const copies = [
|
||||
{ icon: SiWhatsapp, label: 'WhatsApp', value: 'stanrunge' },
|
||||
{ icon: SiDiscord, label: 'Discord', value: 'stanrunge' },
|
||||
{ icon: SiSignal, label: 'Signal', value: 'stan.443' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col min-h-screen">
|
||||
<header class="flex justify-between items-center gap-4 py-4 px-4">
|
||||
<a href={resolve('/')} class="text-2xl font-bold">Stan Runge</a>
|
||||
<nav class="flex flex-wrap justify-center gap-4" aria-label="Social links">
|
||||
{#each copies as c (c.label)}
|
||||
<CopyButton {...c} />
|
||||
{/each}
|
||||
{#each links as { icon: Icon, label, href } (label)}
|
||||
<a {href} aria-label={label} class="text-2xl transition hover:opacity-60">
|
||||
<Icon />
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="flex-1">
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<footer class="flex justify-center py-8 bg-gray-200">
|
||||
<p>
|
||||
Hosted on <a
|
||||
class="underline text-blue-500"
|
||||
href="https://git.stanrunge.dev/stan/personal-website">my git server :)</a
|
||||
>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user