diff --git a/bun.lockb b/bun.lockb
index 508f4e5..1975c7c 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/package.json b/package.json
index 3461051..fc8962b 100755
--- a/package.json
+++ b/package.json
@@ -22,9 +22,9 @@
"@playwright/test": "1.49.1",
"@sveltejs/kit": "^2.15.2",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
+ "@tailwindcss/vite": "^4.0.0",
"@types/eslint": "9.6.1",
"@types/pg": "^8.11.10",
- "autoprefixer": "^10.4.20",
"bits-ui": "^1.4.7",
"clsx": "^2.1.1",
"cmdk-sv": "^0.0.18",
@@ -34,14 +34,13 @@
"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",
+ "tailwindcss": "^4.0.0",
"typescript": "5.7.2",
"typescript-eslint": "8.19.1",
"vite": "^6.0.7",
diff --git a/postcss.config.js b/postcss.config.js
deleted file mode 100755
index 2e7af2b..0000000
--- a/postcss.config.js
+++ /dev/null
@@ -1,6 +0,0 @@
-export default {
- plugins: {
- tailwindcss: {},
- autoprefixer: {},
- },
-}
diff --git a/src/app.css b/src/app.css
index 1460463..af36e8e 100755
--- a/src/app.css
+++ b/src/app.css
@@ -1,78 +1,56 @@
-@tailwind base;
-@tailwind components;
-@tailwind utilities;
-
-@layer base {
- :root {
- --background: 0 0% 100%;
- --foreground: 222.2 84% 4.9%;
-
- --muted: 210 40% 96.1%;
- --muted-foreground: 215.4 16.3% 46.9%;
-
- --popover: 0 0% 100%;
- --popover-foreground: 222.2 84% 4.9%;
-
- --card: 0 0% 100%;
- --card-foreground: 222.2 84% 4.9%;
-
- --border: 214.3 31.8% 91.4%;
- --input: 214.3 31.8% 91.4%;
-
- --primary: 222.2 47.4% 11.2%;
- --primary-foreground: 210 40% 98%;
-
- --secondary: 210 40% 96.1%;
- --secondary-foreground: 222.2 47.4% 11.2%;
-
- --accent: 210 40% 96.1%;
- --accent-foreground: 222.2 47.4% 11.2%;
-
- --destructive: 0 72.2% 50.6%;
- --destructive-foreground: 210 40% 98%;
-
- --ring: 222.2 84% 4.9%;
-
- --radius: 0.5rem;
- }
-
- .dark {
- --background: 222.2 84% 4.9%;
- --foreground: 210 40% 98%;
-
- --muted: 217.2 32.6% 17.5%;
- --muted-foreground: 215 20.2% 65.1%;
-
- --popover: 222.2 84% 4.9%;
- --popover-foreground: 210 40% 98%;
-
- --card: 222.2 84% 4.9%;
- --card-foreground: 210 40% 98%;
-
- --border: 217.2 32.6% 17.5%;
- --input: 217.2 32.6% 17.5%;
-
- --primary: 210 40% 98%;
- --primary-foreground: 222.2 47.4% 11.2%;
-
- --secondary: 217.2 32.6% 17.5%;
- --secondary-foreground: 210 40% 98%;
-
- --accent: 217.2 32.6% 17.5%;
- --accent-foreground: 210 40% 98%;
-
- --destructive: 0 62.8% 30.6%;
- --destructive-foreground: 210 40% 98%;
-
- --ring: 212.7 26.8% 83.9%;
- }
+@import 'tailwindcss';
+
+@theme {
+ --color-bg: #f6f4f0;
+ --color-surface: #edeae4;
+ --color-surface-hover: #e4e0d8;
+ --color-border: #d6d2ca;
+ --color-border-light: #e8e5de;
+ --color-text: #2c2c2c;
+ --color-text-muted: #6b6b6b;
+ --color-text-dim: #999999;
+ --color-text-ghost: #c5c0b8;
+ --color-accent: #3d6b8e;
+ --color-accent-hover: #4d7fa6;
+ --color-danger: #b44;
+
+ --font-heading: 'DM Serif Display', Georgia, serif;
+ --font-mono: 'IBM Plex Mono', 'Courier New', monospace;
+ --font-body: 'Source Sans 3', 'Helvetica Neue', sans-serif;
}
-
+
@layer base {
- * {
- @apply border-border;
- }
- body {
- @apply bg-background text-foreground;
- }
-}
\ No newline at end of file
+ * {
+ 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;
+ }
+}
+
diff --git a/src/index.test.ts b/src/index.test.ts
deleted file mode 100755
index e07cbbd..0000000
--- a/src/index.test.ts
+++ /dev/null
@@ -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);
- });
-});
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts
index 10445bc..f5a902f 100755
--- a/src/lib/db/schema.ts
+++ b/src/lib/db/schema.ts
@@ -71,3 +71,15 @@ export const publications = personalWebsiteSchema.table('publications', {
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow()
});
+
+// Legacy ^
+// New
+
+export const blogArticles = personalWebsiteSchema.table('blog_articles', {
+ id: serial('id').primaryKey(),
+ title: varchar('title', { length: 256 }),
+ slug: varchar('slug', { length: 256 }),
+ content: varchar('content', { length: 8192 }),
+ createdAt: timestamp('created_at').defaultNow(),
+ updatedAt: timestamp('updated_at').defaultNow()
+})
\ No newline at end of file
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 2a56bb2..6b1e466 100755
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -1,108 +1,58 @@
-
-
-
-
-
-
-
-
-
- {@render children?.()}
-
-
- {#if !data.editable}
-
- (editModalVisible = !editModalVisible)}>Edit
-
- {/if}
-
-
-{#if editModalVisible}
- (editModalVisible = false)}
- >
-
+
+
+{@render children()}
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 42db257..6d906a4 100755
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -1,10 +1,97 @@
-
Stan Runge
-
-
-
-
Vash Software
-
-
Hogeschool Inholland
-
-
Junior
+
+
+
+
+
experience
+
+
+
2021 - 2026
+
+
BSc. Computer Science (Software Engineering)
+
The Hague University of Applied Sciences
+
+
+
+
2026
+
internship at 3webapps
+
+
+
2023
+
internship at h5mag
+
+
+
2021 - 2023
+
junior IT specialist at Finance Plus
+
+
+
2020 - current
+
300+ IT problems solved in Netherlands
+
+
+
+
+
+
+
+
diff --git a/src/routes/blog/+page.server.ts b/src/routes/blog/+page.server.ts
new file mode 100644
index 0000000..ba87e87
--- /dev/null
+++ b/src/routes/blog/+page.server.ts
@@ -0,0 +1,26 @@
+import { db } from "$lib/db"
+import { blogArticles } from "$lib/db/schema";
+import { redirect } from "@sveltejs/kit";
+import type { PageServerLoad } from "./$types.js";
+
+export const load: PageServerLoad = async () => {
+ const articles = await db.select().from(blogArticles)
+
+ return {
+ blogArticles: articles
+ }
+}
+
+export const actions = {
+ default: async ({ request }): Promise
=> {
+ const formData = await request.formData();
+
+ const blogArticle = await db.insert(blogArticles).values({
+ title: formData.get('title')?.toString(),
+ slug: formData.get('slug')?.toString(),
+ content: formData.get('content')?.toString()
+ }).returning();
+
+ return redirect(303, `/blog/${blogArticle[0].slug}`);
+ }
+}
diff --git a/src/routes/blog/+page.svelte b/src/routes/blog/+page.svelte
new file mode 100644
index 0000000..e148cb7
--- /dev/null
+++ b/src/routes/blog/+page.svelte
@@ -0,0 +1,28 @@
+
+
+
+
Blog (Stan's yapping corner)
+ createNewArticlePopup = true} class="bg-gray-800 rounded px-4 py-2 text-white font-bold">Write new article
+
+
+
+{#each data.blogArticles as article}
+
+{/each}
+
+{#if createNewArticlePopup}
+
+{/if}
diff --git a/src/routes/blog/{slug}/+page.server.ts b/src/routes/blog/{slug}/+page.server.ts
new file mode 100644
index 0000000..0688a34
--- /dev/null
+++ b/src/routes/blog/{slug}/+page.server.ts
@@ -0,0 +1,22 @@
+import { db } from "$lib/db"
+import { blogArticles } from "$lib/db/schema";
+import { eq } from "drizzle-orm";
+
+import type { PageServerLoad } from "./$types.js";
+import { fail } from "@sveltejs/kit";
+
+export const load: PageServerLoad = async ({params }) => {
+ const article = await db.select().from(blogArticles).where(eq(blogArticles.slug, params.slug))
+
+ if (article.length === 0) {
+ return fail(404, { message: "Article not found" });
+ }
+
+ return {
+ blogArticle: article[0]
+ }
+}
+
+export const actions = {
+
+}
diff --git a/src/routes/blog/{slug}/+page.svelte b/src/routes/blog/{slug}/+page.svelte
new file mode 100644
index 0000000..d3efcba
--- /dev/null
+++ b/src/routes/blog/{slug}/+page.svelte
@@ -0,0 +1,2 @@
+
+l
\ No newline at end of file
diff --git a/src/routes/projects/animaldex/+layout.svelte b/src/routes/projects/animaldex/+layout.svelte
new file mode 100644
index 0000000..1e47fdb
--- /dev/null
+++ b/src/routes/projects/animaldex/+layout.svelte
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+{@render children()}
diff --git a/src/routes/projects/animaldex/+page.svelte b/src/routes/projects/animaldex/+page.svelte
new file mode 100644
index 0000000..14833f6
--- /dev/null
+++ b/src/routes/projects/animaldex/+page.svelte
@@ -0,0 +1,356 @@
+
+
+
+ Animaldex
+
+
+
+
+
+
+
+
+ + Log a sighting
+
+
(showFilters = !showFilters)}
+ class="relative rounded-full bg-white px-4 py-2 text-sm font-semibold text-gray-700 shadow-lg hover:bg-gray-50"
+ >
+ Filters
+ {#if activeFilterCount > 0}
+ {activeFilterCount}
+ {/if}
+
+
+
+ {#if showFilters}
+
+
+
Filter sightings
+ (showFilters = false)} class="text-gray-400 hover:text-gray-600"
+ >✕
+
+
+
+ Apply
+ Clear
+
+
+ {/if}
+
+ {#if selected}
+
+ {#if selected.trail[0]?.photoUrl}
+
+
+
(selected = null)}
+ class="absolute top-2 right-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/50 text-white"
+ >✕
+
+ {/if}
+
+
+
+
+ {selected.animalName ?? selected.species}
+
+
+ {selected.species}{selected.breed ? ` · ${selected.breed}` : ''}
+
+
+
{selected.sightingCount}
+ {selected.sightingCount === 1 ? 'sighting' : 'sightings'}
+
+
+ {#each [...selected.trail].sort((a, b) => new Date(b.seenAt).getTime() - new Date(a.seenAt).getTime()) as s (s.id)}
+
+ {new Date(s.seenAt).toLocaleDateString('nl-NL', {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric'
+ })}
+ {#if s.reporterName}by {s.reporterName} {/if}
+
+ {/each}
+
+
+ I saw this animal too!
+
+
+
+ {/if}
+
diff --git a/src/routes/projects/animaldex/+page.ts b/src/routes/projects/animaldex/+page.ts
new file mode 100644
index 0000000..a3d1578
--- /dev/null
+++ b/src/routes/projects/animaldex/+page.ts
@@ -0,0 +1 @@
+export const ssr = false;
diff --git a/src/routes/projects/animaldex/admin/+page.server.ts b/src/routes/projects/animaldex/admin/+page.server.ts
new file mode 100644
index 0000000..8fc901c
--- /dev/null
+++ b/src/routes/projects/animaldex/admin/+page.server.ts
@@ -0,0 +1,53 @@
+import type { PageServerLoad } from './$types';
+import { db } from '$lib/server/db';
+import { animals, sightings } from '$lib/server/db/schema';
+import { isAdmin } from '$lib/server/auth';
+import { and, eq, isNull, sql } from 'drizzle-orm';
+
+export const load: PageServerLoad = async (event) => {
+ const admin = isAdmin(event);
+ if (!admin) return { admin: false, queue: [] };
+
+ const pending = await db
+ .select({
+ animalId: animals.id,
+ species: animals.species,
+ breed: animals.breed,
+ animalName: animals.animalName,
+ description: animals.description,
+ aiBreedSuggestion: animals.aiBreedSuggestion,
+ aiBreedConfidence: animals.aiBreedConfidence,
+ submittedAt: animals.submittedAt,
+ acceptedAt: animals.acceptedAt,
+ deniedAt: animals.deniedAt,
+ sightingId: sightings.id,
+ sightingLat: sightings.lat,
+ sightingLng: sightings.lng,
+ seenAt: sightings.seenAt,
+ reporterName: sightings.reporterName,
+ sightingAcceptedAt: sightings.acceptedAt,
+ sightingDeniedAt: sightings.deniedAt,
+ photoUrl: sql`(
+ SELECT url FROM photos
+ WHERE sighting_id = ${sightings.id}
+ ORDER BY sort_order ASC
+ LIMIT 1
+ )`
+ })
+ .from(animals)
+ .innerJoin(
+ sightings,
+ and(
+ eq(sightings.animalId, animals.id),
+ sql`${sightings.id} = (
+ SELECT id FROM sightings
+ WHERE animal_id = ${animals.id}
+ ORDER BY submitted_at ASC
+ LIMIT 1
+ )`
+ )
+ )
+ .orderBy(animals.submittedAt);
+
+ return { admin: true, queue: pending };
+};
diff --git a/src/routes/projects/animaldex/admin/+page.svelte b/src/routes/projects/animaldex/admin/+page.svelte
new file mode 100644
index 0000000..ca73304
--- /dev/null
+++ b/src/routes/projects/animaldex/admin/+page.svelte
@@ -0,0 +1,170 @@
+
+
+Animaldex - Admin
+
+{#if !data.admin}
+
+
+
Admin login
+
e.key === 'Enter' && login()}
+ class="mb-3 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
+ />
+ {#if loginError}
+
{loginError}
+ {/if}
+
+ {loginLoading ? 'Logging in…' : 'Log in'}
+
+
+
+{:else}
+
+
+
+
+ {queue.filter((r) => !r.acceptedAt && !r.deniedAt).length} pending
+
+
+
+
+ {#if queue.length === 0}
+
+ No submissions yet
+
+ {/if}
+
+ {#each queue as item (item.animalId)}
+ {@const status = item.acceptedAt ? 'accepted' : item.deniedAt ? 'denied' : 'pending'}
+
+
+
+ {#if item.photoUrl}
+
+ {:else}
+
🐾
+ {/if}
+
+
+
+
+
{item.animalName ?? item.species}
+
+ {item.species}{item.breed ? ` · ${item.breed}` : ''}
+
+
+
+ {status}
+
+
+ {#if item.aiBreedSuggestion}
+
+ 🤖 AI: {item.aiBreedSuggestion} ({Math.round(
+ (item.aiBreedConfidence ?? 0) * 100
+ )}%)
+
+ {/if}
+
+ {new Date(item.submittedAt).toLocaleDateString('nl-NL', {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit'
+ })}
+ {#if item.reporterName}· by {item.reporterName}{/if}
+
+
+ 📍 {item.sightingLat.toFixed(5)}, {item.sightingLng.toFixed(5)}
+
+
+
+
+ moderate('animals', item.animalId, 'accept')}
+ disabled={actionLoading !== null || status === 'accepted'}
+ class="flex-1 rounded-lg py-1.5 text-sm font-medium {status === 'accepted'
+ ? 'cursor-default bg-green-100 text-green-700'
+ : 'bg-gray-100 text-gray-700 hover:bg-green-100 hover:text-green-700'}"
+ >
+ {actionLoading === `animals-${item.animalId}-accept` ? '…' : '✓ Accept'}
+
+ moderate('animals', item.animalId, 'deny')}
+ disabled={actionLoading !== null || status === 'denied'}
+ class="flex-1 rounded-lg py-1.5 text-sm font-medium {status === 'denied'
+ ? 'cursor-default bg-red-100 text-red-700'
+ : 'bg-gray-100 text-gray-700 hover:bg-red-100 hover:text-red-700'}"
+ >
+ {actionLoading === `animals-${item.animalId}-deny` ? '…' : '✕ Deny'}
+
+
+
+ {/each}
+
+
+{/if}
diff --git a/src/routes/projects/animaldex/admin/login/+server.ts b/src/routes/projects/animaldex/admin/login/+server.ts
new file mode 100644
index 0000000..e2cd02c
--- /dev/null
+++ b/src/routes/projects/animaldex/admin/login/+server.ts
@@ -0,0 +1,25 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { env } from '$env/dynamic/private';
+import { z } from 'zod';
+
+const loginSchema = z.object({ password: z.string().min(1) });
+
+export const POST: RequestHandler = async ({ request, cookies }) => {
+ const body = await request.json().catch(() => null);
+ const parsed = loginSchema.safeParse(body);
+
+ if (!parsed.success || parsed.data.password !== env.ADMIN_SECRET) {
+ throw error(401, { message: 'Invalid password' });
+ }
+
+ cookies.set('admin_token', env.ADMIN_SECRET, {
+ path: '/',
+ httpOnly: true,
+ sameSite: 'strict',
+ secure: process.env.NODE_ENV === 'production',
+ maxAge: 60 * 60 * 24 * 7
+ });
+
+ return json({ ok: true });
+};
diff --git a/src/routes/projects/animaldex/api/animals/+server.ts b/src/routes/projects/animaldex/api/animals/+server.ts
new file mode 100644
index 0000000..49b5028
--- /dev/null
+++ b/src/routes/projects/animaldex/api/animals/+server.ts
@@ -0,0 +1,73 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { db } from '$lib/server/db';
+import { animals, sightings, photos } from '$lib/server/db/schema';
+import { registerAnimalSchema } from '$lib/validation/sighting';
+import { sql } from 'drizzle-orm';
+import { env } from '$env/dynamic/private';
+
+const MAX_DISTANCE_METERS = 30_000;
+
+export const POST: RequestHandler = async ({ request }) => {
+ const body = await request.json().catch(() => null);
+ const parsed = registerAnimalSchema.safeParse(body);
+
+ if (!parsed.success) {
+ throw error(400, { message: parsed.error.issues[0]?.message ?? 'Invalid request' });
+ }
+
+ const data = parsed.data;
+
+ const [distRow] = await db.execute(sql`
+ SELECT ST_Distance(
+ ST_MakePoint(${data.deviceLng}, ${data.deviceLat})::geography,
+ ST_MakePoint(${data.lng}, ${data.lat})::geography
+ ) AS dist
+ `);
+
+ const dist = Number((distRow as Record).dist);
+ if (dist > MAX_DISTANCE_METERS) {
+ throw error(422, {
+ message: `Location is ${Math.round(dist / 1000)}km from your device. Maximum is 30km.`
+ });
+ }
+
+ const result = await db.transaction(async (tx) => {
+ const [animal] = await tx
+ .insert(animals)
+ .values({
+ species: data.species,
+ breed: data.breed,
+ animalName: data.animalName,
+ description: data.description,
+ aiBreedSuggestion: data.aiBreedSuggestion,
+ aiBreedConfidence: data.aiBreedConfidence
+ })
+ .returning({ id: animals.id });
+
+ const [sighting] = await tx
+ .insert(sightings)
+ .values({
+ animalId: animal.id,
+ reporterName: data.reporterName,
+ seenAt: new Date(data.seenAt),
+ lat: data.lat,
+ lng: data.lng
+ })
+ .returning({ id: sightings.id });
+
+ await tx.insert(photos).values(
+ data.photoKeys.map((key, i) => ({
+ sightingId: sighting.id,
+ r2Key: key,
+ url: `${env.R2_PUBLIC_URL}/${key}`,
+ sortOrder: i
+ }))
+ );
+
+ return { animalId: animal.id, sightingId: sighting.id };
+ });
+
+ console.log(`[animals] created animal ${result.animalId}, sighting ${result.sightingId}`);
+ return json(result, { status: 201 });
+};
diff --git a/src/routes/projects/animaldex/api/animals/[id]/moderate/+server.ts b/src/routes/projects/animaldex/api/animals/[id]/moderate/+server.ts
new file mode 100644
index 0000000..7497f0d
--- /dev/null
+++ b/src/routes/projects/animaldex/api/animals/[id]/moderate/+server.ts
@@ -0,0 +1,51 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { db } from '$lib/server/db';
+import { animals, sightings } from '$lib/server/db/schema';
+import { moderationActionSchema } from '$lib/validation/sighting';
+import { eq, isNull } from 'drizzle-orm';
+import { requireAdmin } from '$lib/server/auth';
+
+export const POST: RequestHandler = async (event) => {
+ requireAdmin(event);
+
+ const body = await event.request.json().catch(() => null);
+ const parsed = moderationActionSchema.safeParse(body);
+
+ if (!parsed.success) {
+ throw error(400, { message: 'action must be "accept" or "deny"' });
+ }
+
+ const now = new Date();
+ const isAccept = parsed.data.action === 'accept';
+
+ const [updated] = await db.transaction(async (tx) => {
+ const result = await tx
+ .update(animals)
+ .set({
+ acceptedAt: isAccept ? now : null,
+ deniedAt: isAccept ? null : now
+ })
+ .where(eq(animals.id, event.params.id))
+ .returning({ id: animals.id });
+
+ if (result.length && isAccept) {
+ await tx
+ .update(sightings)
+ .set({ acceptedAt: now, deniedAt: null })
+ .where(eq(sightings.animalId, event.params.id));
+ }
+
+ return result;
+ });
+
+ if (!updated) {
+ throw error(404, { message: 'Animal not found' });
+ }
+
+ console.log(
+ `[moderate] animal ${event.params.id} → ${parsed.data.action}, updated ${updated?.id}`
+ );
+
+ return json({ ok: true, action: parsed.data.action });
+};
diff --git a/src/routes/projects/animaldex/api/animals/[id]/sightings/+server.ts b/src/routes/projects/animaldex/api/animals/[id]/sightings/+server.ts
new file mode 100644
index 0000000..9a0c875
--- /dev/null
+++ b/src/routes/projects/animaldex/api/animals/[id]/sightings/+server.ts
@@ -0,0 +1,78 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { db } from '$lib/server/db';
+import { animals, sightings, photos } from '$lib/server/db/schema';
+import { addSightingSchema } from '$lib/validation/sighting';
+import { eq, sql } from 'drizzle-orm';
+import { env } from '$env/dynamic/private';
+
+const MAX_DISTANCE_METERS = 30_000;
+
+export const POST: RequestHandler = async ({ request, params }) => {
+ const body = await request.json().catch(() => null);
+ const parsed = addSightingSchema.safeParse({ ...body, animalId: params.id });
+
+ if (!parsed.success) {
+ throw error(400, { message: parsed.error.issues[0]?.message ?? 'Invalid request' });
+ }
+
+ const data = parsed.data;
+
+ const [animal] = await db
+ .select({ id: animals.id, acceptedAt: animals.acceptedAt })
+ .from(animals)
+ .where(eq(animals.id, params.id))
+ .limit(1);
+
+ if (!animal) {
+ throw error(404, { message: 'Animal not found' });
+ }
+
+ const [distRow] = await db.execute(sql`
+ SELECT ST_Distance(
+ ST_MakePoint(${data.deviceLng}, ${data.deviceLat})::geography,
+ ST_MakePoint(${data.lng}, ${data.lat})::geography
+ ) AS dist
+ `);
+
+ const dist = Number((distRow as Record).dist);
+ if (dist > MAX_DISTANCE_METERS) {
+ throw error(422, {
+ message: `Location is ${Math.round(dist / 1000)}km from your device. Maximum is 30km.`
+ });
+ }
+
+ const animalAlreadyAccepted = animal.acceptedAt !== null;
+
+ const result = await db.transaction(async (tx) => {
+ const [sighting] = await tx
+ .insert(sightings)
+ .values({
+ animalId: data.animalId,
+ reporterName: data.reporterName,
+ seenAt: new Date(data.seenAt),
+ lat: data.lat,
+ lng: data.lng,
+ ...(animalAlreadyAccepted ? { acceptedAt: new Date() } : {})
+ })
+ .returning({ id: sightings.id });
+
+ if (data.photoKeys && data.photoKeys.length > 0) {
+ await tx.insert(photos).values(
+ data.photoKeys.map((key, i) => ({
+ sightingId: sighting.id,
+ r2Key: key,
+ url: `${env.R2_PUBLIC_URL}/${key}`,
+ sortOrder: i
+ }))
+ );
+ }
+
+ return { sightingId: sighting.id };
+ });
+
+ console.log(
+ `[sightings] created sighting ${result.sightingId} for animal ${params.id} (auto-accepted: ${animalAlreadyAccepted})`
+ );
+ return json(result, { status: 201 });
+};
diff --git a/src/routes/projects/animaldex/api/animals/[id]/sightings/[id]/moderate/+server.ts b/src/routes/projects/animaldex/api/animals/[id]/sightings/[id]/moderate/+server.ts
new file mode 100644
index 0000000..b1265e6
--- /dev/null
+++ b/src/routes/projects/animaldex/api/animals/[id]/sightings/[id]/moderate/+server.ts
@@ -0,0 +1,36 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { db } from '$lib/server/db';
+import { sightings } from '$lib/server/db/schema';
+import { moderationActionSchema } from '$lib/validation/sighting';
+import { eq } from 'drizzle-orm';
+import { requireAdmin } from '$lib/server/auth';
+
+export const POST: RequestHandler = async (event) => {
+ requireAdmin(event);
+
+ const body = await event.request.json().catch(() => null);
+ const parsed = moderationActionSchema.safeParse(body);
+
+ if (!parsed.success) {
+ throw error(400, { message: 'action must be "accept" or "deny"' });
+ }
+
+ const now = new Date();
+ const isAccept = parsed.data.action === 'accept';
+
+ const [updated] = await db
+ .update(sightings)
+ .set({
+ acceptedAt: isAccept ? now : null,
+ deniedAt: isAccept ? null : now
+ })
+ .where(eq(sightings.id, event.params.id))
+ .returning({ id: sightings.id });
+
+ if (!updated) {
+ throw error(404, { message: 'Sighting not found' });
+ }
+
+ return json({ ok: true, action: parsed.data.action });
+};
diff --git a/src/routes/projects/animaldex/api/detect-breed/+server.ts b/src/routes/projects/animaldex/api/detect-breed/+server.ts
new file mode 100644
index 0000000..9c58bf6
--- /dev/null
+++ b/src/routes/projects/animaldex/api/detect-breed/+server.ts
@@ -0,0 +1,18 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { detectBreed } from '$lib/server/breed-detector';
+import { z } from 'zod';
+
+const schema = z.object({ imageUrl: z.string().url() });
+
+export const POST: RequestHandler = async ({ request }) => {
+ const body = await request.json().catch(() => null);
+ const parsed = schema.safeParse(body);
+
+ if (!parsed.success) {
+ throw error(400, { message: 'imageUrl is required' });
+ }
+
+ const result = await detectBreed(parsed.data.imageUrl);
+ return json(result ?? { breed: null, confidence: 0 });
+};
diff --git a/src/routes/projects/animaldex/api/map/+server.ts b/src/routes/projects/animaldex/api/map/+server.ts
new file mode 100644
index 0000000..d3d1914
--- /dev/null
+++ b/src/routes/projects/animaldex/api/map/+server.ts
@@ -0,0 +1,127 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { db } from '$lib/server/db';
+import { animals, sightings } from '$lib/server/db/schema';
+import { mapQuerySchema } from '$lib/validation/sighting';
+import { and, eq, gte, ilike, isNotNull, isNull, lte, sql } from 'drizzle-orm';
+
+export const GET: RequestHandler = async ({ url }) => {
+ const params = Object.fromEntries(url.searchParams);
+ const parsed = mapQuerySchema.safeParse(params);
+
+ if (!parsed.success) {
+ throw error(400, { message: 'Invalid query parameters' });
+ }
+
+ const q = parsed.data;
+
+ const conditions = [
+ isNotNull(animals.acceptedAt),
+ isNull(animals.deniedAt),
+ isNotNull(sightings.acceptedAt),
+ isNull(sightings.deniedAt),
+ sql`ST_Within(
+ ${sightings}.location::geometry,
+ ST_MakeEnvelope(${q.minLng}, ${q.minLat}, ${q.maxLng}, ${q.maxLat}, 4326)
+ )`
+ ];
+
+ if (q.species) conditions.push(ilike(animals.species, `%${q.species}%`));
+ if (q.breed) conditions.push(ilike(animals.breed, `%${q.breed}%`));
+ if (q.name) conditions.push(ilike(animals.animalName, `%${q.name}%`));
+ if (q.reporter) conditions.push(ilike(sightings.reporterName, `%${q.reporter}%`));
+ if (q.fromDate) conditions.push(gte(sightings.seenAt, new Date(q.fromDate)));
+ if (q.toDate) conditions.push(lte(sightings.seenAt, new Date(q.toDate)));
+
+ const rows = await db
+ .select({
+ animalId: animals.id,
+ species: animals.species,
+ breed: animals.breed,
+ animalName: animals.animalName,
+ sightingId: sightings.id,
+ seenAt: sightings.seenAt,
+ reporterName: sightings.reporterName,
+ lat: sightings.lat,
+ lng: sightings.lng,
+ photoUrl: sql`(
+ SELECT url FROM photos
+ WHERE sighting_id = ${sightings.id}
+ ORDER BY sort_order ASC
+ LIMIT 1
+ )`
+ })
+ .from(sightings)
+ .innerJoin(animals, eq(sightings.animalId, animals.id))
+ .where(and(...conditions))
+ .orderBy(sightings.seenAt);
+
+ const animalMap = new Map<
+ string,
+ {
+ animalId: string;
+ species: string;
+ breed: string | null;
+ animalName: string | null;
+ sightings: Array<{
+ id: string;
+ lat: number;
+ lng: number;
+ seenAt: Date;
+ reporterName: string | null;
+ photoUrl: string | null;
+ }>;
+ }
+ >();
+
+ for (const row of rows) {
+ if (!animalMap.has(row.animalId)) {
+ animalMap.set(row.animalId, {
+ animalId: row.animalId,
+ species: row.species,
+ breed: row.breed,
+ animalName: row.animalName,
+ sightings: []
+ });
+ }
+ animalMap.get(row.animalId)!.sightings.push({
+ id: row.sightingId,
+ lat: row.lat,
+ lng: row.lng,
+ seenAt: row.seenAt,
+ reporterName: row.reporterName,
+ photoUrl: row.photoUrl
+ });
+ }
+
+ const features = Array.from(animalMap.values()).map((animal) => {
+ const sorted = [...animal.sightings].sort((a, b) => b.seenAt.getTime() - a.seenAt.getTime());
+ const latest = sorted[0];
+ return {
+ type: 'Feature' as const,
+ geometry: { type: 'Point' as const, coordinates: [latest.lng, latest.lat] },
+ properties: {
+ animalId: animal.animalId,
+ species: animal.species,
+ breed: animal.breed,
+ animalName: animal.animalName,
+ sightingCount: animal.sightings.length,
+ trail: animal.sightings.map((s) => ({
+ id: s.id,
+ lng: s.lng,
+ lat: s.lat,
+ seenAt: s.seenAt.toISOString(),
+ reporterName: s.reporterName,
+ photoUrl: s.photoUrl
+ }))
+ }
+ };
+ });
+
+ console.log(
+ `[map] query returned ${rows.length} rows for bbox ${q.minLng},${q.minLat} → ${q.maxLng},${q.maxLat}`
+ );
+ console.log(`[map] built ${features.length} features`);
+
+ return json({ type: 'FeatureCollection', features });
+};
diff --git a/src/routes/projects/animaldex/api/upload/+server.ts b/src/routes/projects/animaldex/api/upload/+server.ts
new file mode 100644
index 0000000..8e34691
--- /dev/null
+++ b/src/routes/projects/animaldex/api/upload/+server.ts
@@ -0,0 +1,22 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { createPresignedUpload, isAllowedMimeType } from '$lib/server/r2';
+import { presignedUploadSchema } from '$lib/validation/sighting';
+
+export const POST: RequestHandler = async ({ request }) => {
+ const body = await request.json().catch(() => null);
+ const parsed = presignedUploadSchema.safeParse(body);
+
+ if (!parsed.success) {
+ throw error(400, { message: 'Invalid request body' });
+ }
+
+ const { mimeType } = parsed.data;
+
+ if (!isAllowedMimeType(mimeType)) {
+ throw error(400, { message: 'Unsupported image type' });
+ }
+
+ const result = await createPresignedUpload(mimeType);
+ return json(result);
+};
diff --git a/src/routes/projects/animaldex/layout.css b/src/routes/projects/animaldex/layout.css
new file mode 100644
index 0000000..d4b5078
--- /dev/null
+++ b/src/routes/projects/animaldex/layout.css
@@ -0,0 +1 @@
+@import 'tailwindcss';
diff --git a/src/routes/projects/animaldex/register/+page.svelte b/src/routes/projects/animaldex/register/+page.svelte
new file mode 100644
index 0000000..7e8fbc8
--- /dev/null
+++ b/src/routes/projects/animaldex/register/+page.svelte
@@ -0,0 +1,444 @@
+
+
+
+ Animaldex - Log a sighting
+
+
+
+
+
+
←
+
+ {isNewSighting ? 'Log another sighting' : 'Register an animal'}
+
+
+
+
+ {#if locationError}
+
+
📍 Location required
+
{locationError}
+
+ Go to Settings → Site permissions → Location and allow this site.
+
+
+ {/if}
+
+
+
+ Photos
+ {#if isNewSighting}
+ (optional)
+ {:else}
+ *
+ {/if}
+
+
+ {#each photoPreviews as preview, i (i)}
+
+
+
removePhoto(i)}
+ class="absolute top-1 right-1 flex h-5 w-5 items-center justify-center rounded-full bg-black/60 text-xs text-white"
+ >✕
+
+ {/each}
+ {#if photoFiles.length < 10}
+
+ +
+ Photo
+
+
+ {/if}
+
+ Max 10 photos · JPEG, PNG, WebP, HEIC
+
+
+ {#if aiLoading}
+
+ ⟳ Identifying breed…
+
+ {:else if aiSuggestion && !aiDismissed}
+
+
+ 🤖 AI thinks this is a {aiSuggestion.breed}
+ ({Math.round(aiSuggestion.confidence * 100)}% confident)
+
+
+ {
+ if (aiSuggestion) breed = aiSuggestion.breed;
+ }}
+ class="rounded-lg bg-orange-500 px-3 py-1 text-xs font-semibold text-white"
+ >Use this
+ {
+ aiDismissed = true;
+ aiSuggestion = null;
+ }}
+ class="rounded-lg border border-orange-200 px-3 py-1 text-xs text-orange-700"
+ >Dismiss
+
+
+ {/if}
+
+ {#if !isNewSighting}
+
+
+
+
+
+ {/if}
+
+
+
+
+
+ Location *
+
+ {#if locationLoading}
+
+ Getting your location…
+
+ {:else if lat !== null}
+
+ Drag the pin to fine-tune (max 30 km from you).
+ {/if}
+
+
+ {#if submitError}
+
+ {submitError}
+
+ {/if}
+
+
+ {submitting ? 'Submitting…' : isNewSighting ? 'Submit sighting' : 'Register animal'}
+
+
+
+ All submissions are reviewed before appearing on the map.
+
+
+
diff --git a/src/routes/projects/animaldex/register/+page.ts b/src/routes/projects/animaldex/register/+page.ts
new file mode 100644
index 0000000..a3d1578
--- /dev/null
+++ b/src/routes/projects/animaldex/register/+page.ts
@@ -0,0 +1 @@
+export const ssr = false;
diff --git a/src/routes/projects/catch-em-all/+layout.server.ts b/src/routes/projects/catch-em-all/+layout.server.ts
new file mode 100644
index 0000000..54048b3
--- /dev/null
+++ b/src/routes/projects/catch-em-all/+layout.server.ts
@@ -0,0 +1,5 @@
+import type { LayoutServerLoad } from './$types';
+
+export const load: LayoutServerLoad = async ({ locals }) => {
+ return { user: locals.user ?? null };
+};
diff --git a/src/routes/projects/catch-em-all/+layout.svelte b/src/routes/projects/catch-em-all/+layout.svelte
new file mode 100644
index 0000000..801d902
--- /dev/null
+++ b/src/routes/projects/catch-em-all/+layout.svelte
@@ -0,0 +1,30 @@
+
+
+
+
+
+ {#if data.user}
+
Logged in as {data.user.name}
+
+ {:else}
+
Log in
+ {/if}
+
+
+
+
{@render children()}
+
diff --git a/src/routes/projects/catch-em-all/+page.server.ts b/src/routes/projects/catch-em-all/+page.server.ts
new file mode 100644
index 0000000..e710b6b
--- /dev/null
+++ b/src/routes/projects/catch-em-all/+page.server.ts
@@ -0,0 +1,110 @@
+import { db } from '$lib/server/db';
+import { game, pokemonGameEntry, userEntry, userGame } from '$lib/server/db/schema';
+import { availabilityValues } from '$lib/availability';
+import { fail } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './catchemall/$types';
+import { and, eq, inArray } from 'drizzle-orm';
+
+// compact wire format for the ~19k game entries — full objects with repeated
+// keys and availability strings add megabytes to the page payload
+export type WireEntry = [id: number, gameId: number, availability: number, note?: string];
+
+export const load: PageServerLoad = async ({ locals }) => {
+ const pokemonRows = await db.query.pokemon.findMany({
+ with: {
+ entry: true
+ },
+ orderBy: (pokemon) => pokemon.number
+ });
+ const pokemon = pokemonRows.map(({ entry, ...rest }) => ({
+ ...rest,
+ entries: entry.map((e): WireEntry => {
+ const code = availabilityValues.indexOf(e.availability);
+ return e.note === null ? [e.id, e.gameId, code] : [e.id, e.gameId, code, e.note];
+ })
+ }));
+
+ const games = await db.query.game.findMany({
+ orderBy: (game) => [game.generation, game.id]
+ });
+
+ let caughtIds: number[] = [];
+ let selectedGameIds: number[] = [];
+ if (locals.user) {
+ const caughtRows = await db
+ .select({ id: userEntry.pokemonGameEntryId })
+ .from(userEntry)
+ .where(eq(userEntry.userId, locals.user.id));
+ caughtIds = caughtRows.map((r) => r.id);
+
+ const gameRows = await db
+ .select({ id: userGame.gameId })
+ .from(userGame)
+ .where(eq(userGame.userId, locals.user.id));
+ selectedGameIds = gameRows.map((r) => r.id);
+ }
+
+ return { pokemon, games, caughtIds, selectedGameIds };
+};
+
+export const actions = {
+ togglePokemonCaught: async ({ request, locals }) => {
+ if (!locals.user) return fail(401, { message: 'Log in to track your progress' });
+
+ const data = await request.formData();
+ const entryId = Number(data.get('entryId'));
+ if (!Number.isInteger(entryId) || entryId <= 0) return fail(400, { message: 'Invalid entry' });
+
+ // only catchable entries can be toggled — the client disables these
+ // checkboxes but we can't trust that
+ const [entry] = await db
+ .select({ availability: pokemonGameEntry.availability })
+ .from(pokemonGameEntry)
+ .where(eq(pokemonGameEntry.id, entryId));
+ if (!entry) return fail(404, { message: 'Unknown entry' });
+ if (entry.availability !== 'catchable') {
+ return fail(400, { message: 'This pokemon is not catchable in this game' });
+ }
+
+ const caught = data.get('caught') === 'on';
+
+ if (caught) {
+ await db
+ .insert(userEntry)
+ .values({ pokemonGameEntryId: entryId, userId: locals.user.id })
+ .onConflictDoNothing();
+ } else {
+ await db
+ .delete(userEntry)
+ .where(
+ and(eq(userEntry.pokemonGameEntryId, entryId), eq(userEntry.userId, locals.user.id))
+ );
+ }
+ },
+
+ setGames: async ({ request, locals }) => {
+ if (!locals.user) return fail(401, { message: 'Log in to set rules' });
+
+ const data = await request.formData();
+ const gameIds = data
+ .getAll('gameIds')
+ .map(Number)
+ .filter((id) => Number.isInteger(id) && id > 0);
+
+ if (gameIds.length > 0) {
+ const validGames = await db
+ .select({ id: game.id })
+ .from(game)
+ .where(inArray(game.id, gameIds));
+ if (validGames.length !== gameIds.length) return fail(400, { message: 'Unknown game' });
+ }
+
+ const userId = locals.user.id;
+ await db.transaction(async (tx) => {
+ await tx.delete(userGame).where(eq(userGame.userId, userId));
+ if (gameIds.length > 0) {
+ await tx.insert(userGame).values(gameIds.map((gameId) => ({ userId, gameId })));
+ }
+ });
+ }
+} satisfies Actions;
diff --git a/src/routes/projects/catch-em-all/+page.svelte b/src/routes/projects/catch-em-all/+page.svelte
new file mode 100644
index 0000000..beb229b
--- /dev/null
+++ b/src/routes/projects/catch-em-all/+page.svelte
@@ -0,0 +1,330 @@
+
+
+
+ catchemall
+
+
+
+
Gotta catch 'em all!
+
+
+
+
+ Below are all {data.pokemon.length} Pokemon across {data.games.length} games. Play through all generations
+ and catch all catchable pokemon in each. Use the "Set rules" button to pick which games you want to
+ complete.
+
+
+
+{#if !data.user}
+
+ Log in to save your progress.
+
+{/if}
+
+
+
Set rules
+
+
{caughtCount}/{total} entries caught ({percentage} %)
+
0 ? caughtCount / total : 0}>
+
+
+ (scrollTop = scrollEl?.scrollTop ?? 0)}
+ class="max-h-[75vh] overflow-auto rounded-xl border border-gray-300"
+>
+
+
+
+ Pokemon
+ {#each visibleGames as game (game.id)}
+
+ {game.name}
+
+ {/each}
+
+
+
+ {#if startIndex > 0}
+
+
+
+ {/if}
+ {#each visiblePokemon as pokemon (pokemon.id)}
+
+
+
+
+
{pokemon.number}
+
{pokemon.name}
+
{pokemon.type1}
+ {#if pokemon.type2}
+
{pokemon.type2}
+ {/if}
+
+
+ {#each visibleGames as game (game.id)}
+ {@const entry = entryMap.get(pokemon.id)?.get(game.id)}
+ {@const reason = tooltip(entry, game)}
+
+ {#if entry && reason === null}
+ toggleCaught(entry.id, e.currentTarget.checked)}
+ />
+ {:else}
+
+
+
+ {/if}
+
+ {/each}
+
+ {/each}
+ {#if endIndex < data.pokemon.length}
+
+
+
+ {/if}
+
+
+
+
+
+ Set rules
+
+ Pick the games you want to catch 'em all in. Only those show up in the table and count towards
+ your progress.
+
+
+
+ (draft = new SvelteSet(data.games.map((g) => g.id)))}>All games
+ (draft = new SvelteSet())}>None
+
+
+
+
diff --git a/src/routes/projects/catch-em-all/layout.css b/src/routes/projects/catch-em-all/layout.css
new file mode 100644
index 0000000..d4b5078
--- /dev/null
+++ b/src/routes/projects/catch-em-all/layout.css
@@ -0,0 +1 @@
+@import 'tailwindcss';
diff --git a/src/routes/progress/+page.server.ts b/src/routes/projects/progress/+page.server.ts
similarity index 100%
rename from src/routes/progress/+page.server.ts
rename to src/routes/projects/progress/+page.server.ts
diff --git a/src/routes/progress/+page.svelte b/src/routes/projects/progress/+page.svelte
similarity index 100%
rename from src/routes/progress/+page.svelte
rename to src/routes/projects/progress/+page.svelte
diff --git a/src/routes/progress/[id]/+page.server.ts b/src/routes/projects/progress/[id]/+page.server.ts
similarity index 100%
rename from src/routes/progress/[id]/+page.server.ts
rename to src/routes/projects/progress/[id]/+page.server.ts
diff --git a/src/routes/progress/[id]/+page.svelte b/src/routes/projects/progress/[id]/+page.svelte
similarity index 100%
rename from src/routes/progress/[id]/+page.svelte
rename to src/routes/projects/progress/[id]/+page.svelte
diff --git a/src/routes/progress/create/+page.server.ts b/src/routes/projects/progress/create/+page.server.ts
similarity index 100%
rename from src/routes/progress/create/+page.server.ts
rename to src/routes/projects/progress/create/+page.server.ts
diff --git a/src/routes/progress/create/+page.svelte b/src/routes/projects/progress/create/+page.svelte
similarity index 100%
rename from src/routes/progress/create/+page.svelte
rename to src/routes/projects/progress/create/+page.svelte
diff --git a/src/routes/progress/topics/+page.server.ts b/src/routes/projects/progress/topics/+page.server.ts
similarity index 100%
rename from src/routes/progress/topics/+page.server.ts
rename to src/routes/projects/progress/topics/+page.server.ts
diff --git a/src/routes/progress/topics/+page.svelte b/src/routes/projects/progress/topics/+page.svelte
similarity index 100%
rename from src/routes/progress/topics/+page.svelte
rename to src/routes/projects/progress/topics/+page.svelte
diff --git a/src/routes/progress/topics/[id]/+page.server.ts b/src/routes/projects/progress/topics/[id]/+page.server.ts
similarity index 100%
rename from src/routes/progress/topics/[id]/+page.server.ts
rename to src/routes/projects/progress/topics/[id]/+page.server.ts
diff --git a/src/routes/progress/topics/[id]/+page.svelte b/src/routes/projects/progress/topics/[id]/+page.svelte
similarity index 100%
rename from src/routes/progress/topics/[id]/+page.svelte
rename to src/routes/projects/progress/topics/[id]/+page.svelte
diff --git a/tailwind.config.js b/tailwind.config.js
deleted file mode 100755
index fa1cb36..0000000
--- a/tailwind.config.js
+++ /dev/null
@@ -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) / )",
- input: "hsl(var(--input) / )",
- ring: "hsl(var(--ring) / )",
- background: "hsl(var(--background) / )",
- foreground: "hsl(var(--foreground) / )",
- primary: {
- DEFAULT: "hsl(var(--primary) / )",
- foreground: "hsl(var(--primary-foreground) / )"
- },
- secondary: {
- DEFAULT: "hsl(var(--secondary) / )",
- foreground: "hsl(var(--secondary-foreground) / )"
- },
- destructive: {
- DEFAULT: "hsl(var(--destructive) / )",
- foreground: "hsl(var(--destructive-foreground) / )"
- },
- muted: {
- DEFAULT: "hsl(var(--muted) / )",
- foreground: "hsl(var(--muted-foreground) / )"
- },
- accent: {
- DEFAULT: "hsl(var(--accent) / )",
- foreground: "hsl(var(--accent-foreground) / )"
- },
- popover: {
- DEFAULT: "hsl(var(--popover) / )",
- foreground: "hsl(var(--popover-foreground) / )"
- },
- card: {
- DEFAULT: "hsl(var(--card) / )",
- foreground: "hsl(var(--card-foreground) / )"
- }
- },
- borderRadius: {
- lg: "var(--radius)",
- md: "calc(var(--radius) - 2px)",
- sm: "calc(var(--radius) - 4px)"
- },
- fontFamily: {
- sans: [...fontFamily.sans]
- }
- }
- },
-};
-
-export default config;
diff --git a/vite.config.ts b/vite.config.ts
index 37b6a84..896d46e 100755
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,9 +1,15 @@
import { sveltekit } from '@sveltejs/kit/vite';
+import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vitest/config';
export default defineConfig({
- plugins: [sveltekit()],
+ plugins: [tailwindcss(), sveltekit()],
test: {
include: ['src/**/*.{test,spec}.{js,ts}']
+ },
+ server: {
+ host: '127.0.0.1',
+ allowedHosts: ['dev.stanrunge.dev'],
+ hmr: { host: 'dev.stanrunge.dev', protocol: 'wss', clientPort: 443 }
}
});