base website #10

Merged
stan merged 17 commits from staging into main 2026-08-31 14:47:50 +00:00
71 changed files with 10942 additions and 5413 deletions
Showing only changes of commit c9a7294d13 - Show all commits

View File

@@ -1,6 +1,7 @@
# Stan Website
This is my personal website, including:
- Contact info / social links
- About me
- Blog posts + comments

1128
bun.lock Normal file

File diff suppressed because it is too large Load Diff

BIN
bun.lockb

Binary file not shown.

View File

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

View File

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

View File

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

13593
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,56 +1,71 @@
{
"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",
"@tailwindcss/vite": "^4.0.0",
"@types/eslint": "9.6.1",
"@types/pg": "^8.11.10",
"bits-ui": "^1.4.7",
"clsx": "^2.1.1",
"cmdk-sv": "^0.0.18",
"drizzle-kit": "^0.30.1",
"eslint": "9.17.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-svelte": "^2.46.1",
"globals": "15.14.0",
"lucide-svelte": "^0.473.0",
"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": "^4.0.0",
"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"
}
"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/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",
"mode-watcher": "^1.1.0",
"postgres": "^3.4.9",
"zod": "^4.5.4"
}
}

View File

@@ -53,4 +53,3 @@
text-decoration: none;
}
}

2
src/app.d.ts vendored
View File

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

View File

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

BIN
src/lib/assets/stan.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

12
src/lib/availability.ts Normal file
View File

@@ -0,0 +1,12 @@
// how a pokemon can be obtained in a game. no pokemon_game_entry row at all
// means the pokemon doesn't exist in that game (e.g. a gen 2 mon in gen 1).
// Shared between the db schema and the client, which receives availability as
// an index into this array to keep the page payload small.
export const availabilityValues = [
'catchable',
'trade_only',
'transfer_only',
'event_only'
] as const;
export type Availability = (typeof availabilityValues)[number];

View File

@@ -0,0 +1,51 @@
<script lang="ts">
import type { Component } from 'svelte';
let {
icon: Icon,
label,
value
}: {
icon: Component;
label: string;
value: string;
} = $props();
let copied = $state(false);
async function copy(): Promise<void> {
try {
await navigator.clipboard.writeText(value);
copied = true;
} catch {
// clipboard blocked
}
}
</script>
<div
class="group relative"
role="group"
onmouseleave={(): void => {
copied = false;
}}
>
<button
onclick={copy}
aria-label={`Copy ${label}: ${value}`}
class="text-2xl transition hover:opacity-60 cursor-pointer"
>
<Icon />
</button>
<span
role="tooltip"
class="pointer-events-none absolute top-full left-1/2 mt-2 -translate-x-1/2
rounded bg-neutral-800 px-2 py-1 text-xs whitespace-nowrap text-white
opacity-0 transition group-hover:opacity-100"
>
{copied ? 'Copied!' : value}
</span>
</div>
<div aria-live="polite" class="sr-only">{copied ? `${label} copied` : ''}</div>

View File

@@ -1,15 +1,15 @@
<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";
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"] = [];
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>

View File

@@ -1,34 +1,33 @@
import { type VariantProps, tv } from "tailwind-variants";
import type { Button as ButtonPrimitive } from "bits-ui";
import Root from "./button.svelte";
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",
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",
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",
},
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",
},
variant: 'default',
size: 'default'
}
});
type Variant = VariantProps<typeof buttonVariants>["variant"];
type Size = VariantProps<typeof buttonVariants>["size"];
type Variant = VariantProps<typeof buttonVariants>['variant'];
type Size = VariantProps<typeof buttonVariants>['size'];
type Props = ButtonPrimitive.Props & {
variant?: Variant;
@@ -45,5 +44,5 @@ export {
Root as Button,
type Props as ButtonProps,
type Events as ButtonEvents,
buttonVariants,
buttonVariants
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,14 @@
import { Command as CommandPrimitive } from "cmdk-sv";
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";
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;
@@ -33,5 +33,5 @@ export {
List as CommandList,
Separator as CommandSeparator,
Shortcut as CommandShortcut,
Loading as CommandLoading,
Loading as CommandLoading
};

View File

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

View File

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

View File

@@ -1,15 +1,15 @@
<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<HTMLDivElement>;
let className: $$Props["class"] = undefined;
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)}
class={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...$$restProps}
>
<slot />

View File

@@ -1,13 +1,13 @@
<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<HTMLDivElement>;
let className: $$Props["class"] = undefined;
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}>
<div class={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...$$restProps}>
<slot />
</div>

View File

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

View File

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

View File

@@ -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.TitleProps;
let className: $$Props["class"] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<DialogPrimitive.Title
class={cn("text-lg font-semibold leading-none tracking-tight", className)}
class={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...$$restProps}
>
<slot />

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
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";
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,
@@ -24,5 +24,5 @@ export {
Footer as TableFooter,
Head as TableHead,
Header as TableHeader,
Row as TableRow,
Row as TableRow
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

@@ -1,58 +1,65 @@
<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>
<header class="flex justify-center gap-4">
<a href="https://osu.ppy.sh/users/11212255">
<img src="./osu.png" alt="osu" />
</a>
<a href="https://steamcommunity.com/id/stanrunge">
<img src="./steam.png" alt="steam" />
</a>
<a href="mailto://stan@stanrunge.dev">
<img src="./mail.png" alt="email" />
</a>
<a href="https://twitch.tv/stanrunge_">
<img src="./twitch.png" alt="twitch" />
</a>
<a href="https://open.spotify.com/user/e3o3qr9z7fpsu4gur9zz4y1ld?si=9874beb4481f45c3">
<img src="./spotify.png" alt="spotify" />
</a>
<a href="">
<img src="./signal.png" alt="signal" />
</a>
<a href="">
<img src="./bluesky.png" alt="bluesky" />
</a>
<div>
<img src="./discord.png" alt="discord" />
</div>
<a href="">
<img src="./whatsapp.png" alt="whatsapp" />
</a>
<a href="">
<img src="./threads.png" alt="threads" />
</a>
<a href="https://github.com/stanrunge">
<img src="./github.png" alt="github" />
</a>
<a href="https://youtube.com/@stanrunge">
<img src="./youtube.png" alt="youtube" />
</a>
<a href="https://linkedin.com/in/stanrunge">
<img src="./linkedin.png" alt="linkedin" />
</a>
<a href="https://instagram.com/stan_runge">
<img src="./instagram.png" alt="instagram" />
</a>
<a href="">
<img src="./facebook.png" alt="facebook" />
</a>
<a href="https://x.com/stanrunge">
<img src="./x.png" alt="x" />
</a>
<header 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>
{@render children()}

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
let { data } = $props();
@@ -8,13 +9,15 @@
<div class="text-center py-8 gap-2 flex align-center justify-center">
<h1 class="text-2xl">Blog (Stan's yapping corner)</h1>
<button onclick={() => createNewArticlePopup = true} class="bg-gray-800 rounded px-4 py-2 text-white font-bold">Write new article</button>
<button
onclick={() => (createNewArticlePopup = true)}
class="bg-gray-800 rounded px-4 py-2 text-white font-bold">Write new article</button
>
</div>
{#each data.blogArticles as article}
{#each data.blogArticles as article (article.slug)}
<div class="flex">
<a href="/blog/${article.slug}">{article.title}</a>
<a href={resolve('/blog/{slug}', { slug: article.slug })}>{article.title}</a>
<p>{article.createdAt?.toDateString()}</p>
</div>
{/each}

View File

@@ -1,22 +1,20 @@
import { db } from "$lib/db"
import { blogArticles } from "$lib/db/schema";
import { eq } from "drizzle-orm";
import { db } from '$lib/server/db';
import { blogArticles } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import type { PageServerLoad } from "./$types.js";
import { fail } from "@sveltejs/kit";
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))
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 fail(404, { message: 'Article not found' });
}
return {
blogArticle: article[0]
}
}
};
};
export const actions = {
}
export const actions = {};

View File

@@ -1,2 +1 @@
l
bu

View File

@@ -1,6 +1,5 @@
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() });
@@ -13,6 +12,6 @@ export const POST: RequestHandler = async ({ request }) => {
throw error(400, { message: 'imageUrl is required' });
}
const result = await detectBreed(parsed.data.imageUrl);
// const result = await detectBreed(parsed.data.imageUrl);
return json(result ?? { breed: null, confidence: 0 });
};

View File

@@ -1,12 +1,9 @@
import { db } from '$lib/server/db';
import { game, pokemonGameEntry, userEntry, userGame } from '$lib/server/db/schema';
import { eq, inArray } from 'drizzle-orm';
import type { Actions, PageServerLoad } from './$types';
import { availabilityValues } from '$lib/availability';
import { fail } from '@sveltejs/kit';
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 }) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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