fix: build errors and merging projects and homepage

This commit is contained in:
2026-08-30 21:38:59 +00:00
parent 97ff9852cd
commit c9a7294d13
71 changed files with 10942 additions and 5413 deletions

View File

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

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

View File

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

View File

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

8671
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,50 +7,65 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "npm run test:integration && npm run test:unit", "test": "npm run test:integration && npm run test:unit",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsgo --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsgo --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .", "lint": "prettier --check . && eslint .",
"format": "prettier --write .", "format": "prettier --write .",
"test:integration": "playwright test", "test:integration": "playwright test",
"test:unit": "vitest", "test:unit": "vitest",
"generate": "drizzle-kit generate", "db:push": "drizzle-kit push",
"migrate": "drizzle-kit migrate", "db:generate": "drizzle-kit generate",
"studio": "drizzle-kit studio", "db:migrate": "drizzle-kit migrate",
"seed": "bun src/lib/db/seed.ts" "db:studio": "drizzle-kit studio",
"db:seed": "bun scripts/seed.ts",
"db:reset": "bun scripts/reset.ts && drizzle-kit push --force && bun scripts/seed.ts",
"auth:schema": "bunx @better-auth/cli generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.49.1", "@eslint/js": "^10.0.1",
"@sveltejs/kit": "^2.15.2", "@iconify-json/simple-icons": "^1.2.94",
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@playwright/test": "^1.62.1",
"@tailwindcss/vite": "^4.0.0", "@sveltejs/enhanced-img": "^0.11.0",
"@sveltejs/kit": "^2.70.3",
"@sveltejs/vite-plugin-svelte": "^7.3.0",
"@tailwindcss/vite": "^4.3.3",
"@types/eslint": "9.6.1", "@types/eslint": "9.6.1",
"@types/pg": "^8.11.10", "@types/pg": "^8.23.1",
"bits-ui": "^1.4.7", "@typescript/native": "npm:typescript@7",
"better-auth": "^1.7.2",
"bits-ui": "^2.19.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk-sv": "^0.0.18", "cmdk-sv": "^0.0.19",
"drizzle-kit": "^0.30.1", "drizzle-kit": "^1.0.0-rc.4",
"eslint": "9.17.0", "eslint": "^10.9.1",
"eslint-config-prettier": "9.1.0", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^2.46.1", "eslint-plugin-svelte": "^3.23.0",
"globals": "15.14.0", "globals": "^17.11.0",
"lucide-svelte": "^0.473.0", "lucide-svelte": "^1.0.1",
"prettier": "3.4.2", "prettier": "3.9.6",
"prettier-plugin-svelte": "3.3.2", "prettier-plugin-svelte": "^4.1.1",
"svelte": "^5.16.5", "svelte": "^5.57.0",
"svelte-check": "4.1.1", "svelte-check": "4.7.6",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^3.6.0",
"tailwind-variants": "^0.3.1", "tailwind-variants": "^3.3.1",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.3.3",
"typescript": "5.7.2", "typescript": "~6",
"typescript-eslint": "8.19.1", "typescript-eslint": "8.68.0",
"vite": "^6.0.7", "unplugin-icons": "^23.0.1",
"vitest": "2.1.8" "vite": "^8.2.2",
"vitest": "^4.1.11"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@sveltejs/adapter-node": "^5.2.11", "@ai-sdk/anthropic": "^4.0.46",
"drizzle-orm": "^0.38.3", "@aws-sdk/client-s3": "^3.1121.0",
"mode-watcher": "^0.5.0", "@aws-sdk/s3-request-presigner": "^3.1121.0",
"postgres": "^3.4.5" "@maptiler/sdk": "^4.1.0",
"@sveltejs/adapter-node": "^5.5.7",
"ai": "^7.0.85",
"drizzle-orm": "^1.0.0-rc.4",
"mode-watcher": "^1.1.0",
"postgres": "^3.4.9",
"zod": "^4.5.4"
} }
} }

View File

@@ -53,4 +53,3 @@
text-decoration: none; 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 // See https://kit.svelte.dev/docs/types#app
// for information about these interfaces // for information about these interfaces
declare global { declare global {

View File

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

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"> <script lang="ts">
import { Button as ButtonPrimitive } from "bits-ui"; import { Button as ButtonPrimitive } from 'bits-ui';
import { type Events, type Props, buttonVariants } from "./index.js"; import { type Events, type Props, buttonVariants } from './index.js';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
type $$Props = Props; type $$Props = Props;
type $$Events = Events; type $$Events = Events;
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export let variant: $$Props["variant"] = "default"; export let variant: $$Props['variant'] = 'default';
export let size: $$Props["size"] = "default"; export let size: $$Props['size'] = 'default';
export let builders: $$Props["builders"] = []; export let builders: $$Props['builders'] = [];
export { className as class }; export { className as class };
</script> </script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 Root from './command.svelte';
import Dialog from "./command-dialog.svelte"; import Dialog from './command-dialog.svelte';
import Empty from "./command-empty.svelte"; import Empty from './command-empty.svelte';
import Group from "./command-group.svelte"; import Group from './command-group.svelte';
import Item from "./command-item.svelte"; import Item from './command-item.svelte';
import Input from "./command-input.svelte"; import Input from './command-input.svelte';
import List from "./command-list.svelte"; import List from './command-list.svelte';
import Separator from "./command-separator.svelte"; import Separator from './command-separator.svelte';
import Shortcut from "./command-shortcut.svelte"; import Shortcut from './command-shortcut.svelte';
const Loading = CommandPrimitive.Loading; const Loading = CommandPrimitive.Loading;
@@ -33,5 +33,5 @@ export {
List as CommandList, List as CommandList,
Separator as CommandSeparator, Separator as CommandSeparator,
Shortcut as CommandShortcut, Shortcut as CommandShortcut,
Loading as CommandLoading, Loading as CommandLoading
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 Title from './dialog-title.svelte';
import Portal from "./dialog-portal.svelte"; import Portal from './dialog-portal.svelte';
import Footer from "./dialog-footer.svelte"; import Footer from './dialog-footer.svelte';
import Header from "./dialog-header.svelte"; import Header from './dialog-header.svelte';
import Overlay from "./dialog-overlay.svelte"; import Overlay from './dialog-overlay.svelte';
import Content from "./dialog-content.svelte"; import Content from './dialog-content.svelte';
import Description from "./dialog-description.svelte"; import Description from './dialog-description.svelte';
const Root = DialogPrimitive.Root; const Root = DialogPrimitive.Root;
const Trigger = DialogPrimitive.Trigger; const Trigger = DialogPrimitive.Trigger;
@@ -33,5 +33,5 @@ export {
Overlay as DialogOverlay, Overlay as DialogOverlay,
Content as DialogContent, Content as DialogContent,
Description as DialogDescription, Description as DialogDescription,
Close as DialogClose, Close as DialogClose
}; };

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
import Root from "./table.svelte"; import Root from './table.svelte';
import Body from "./table-body.svelte"; import Body from './table-body.svelte';
import Caption from "./table-caption.svelte"; import Caption from './table-caption.svelte';
import Cell from "./table-cell.svelte"; import Cell from './table-cell.svelte';
import Footer from "./table-footer.svelte"; import Footer from './table-footer.svelte';
import Head from "./table-head.svelte"; import Head from './table-head.svelte';
import Header from "./table-header.svelte"; import Header from './table-header.svelte';
import Row from "./table-row.svelte"; import Row from './table-row.svelte';
export { export {
Root, Root,
@@ -24,5 +24,5 @@ export {
Footer as TableFooter, Footer as TableFooter,
Head as TableHead, Head as TableHead,
Header as TableHeader, Header as TableHeader,
Row as TableRow, Row as TableRow
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 { env } from '$env/dynamic/private';
import * as schema from './schema'; import { relations } from './relations';
import { drizzle } from 'drizzle-orm/postgres-js'; import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres'; import postgres from 'postgres';
const client = postgres(env.DB_URL, { prepare: false }); const client = postgres(env.DB_URL, { prepare: false });
export const db = drizzle({ export const db = drizzle({
schema, relations,
client client
}); });

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 { topics } from './schema';
import * as schema from './schema' import * as schema from './schema';
import { drizzle } from 'drizzle-orm/postgres-js'; import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres'; import postgres from 'postgres';
const client = postgres('postgresql://postgres:postgres@localhost:5432/db'); const client = postgres('postgresql://postgres:postgres@localhost:5432/db');
const db = drizzle(client, { schema }); const db = drizzle(client, { schema });
await db.insert(topics).values([{ name: 'Math', emoji: '👍' }, { name: 'Programming', emoji: '👍' }]) await db.insert(topics).values([
{ name: 'Math', emoji: '👍' },
{ name: 'Programming', emoji: '👍' }
]);
process.exit(0) process.exit(0);

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

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
let { data } = $props(); let { data } = $props();
@@ -8,13 +9,15 @@
<div class="text-center py-8 gap-2 flex align-center justify-center"> <div class="text-center py-8 gap-2 flex align-center justify-center">
<h1 class="text-2xl">Blog (Stan's yapping corner)</h1> <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> </div>
{#each data.blogArticles as article (article.slug)}
{#each data.blogArticles as article}
<div class="flex"> <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> <p>{article.createdAt?.toDateString()}</p>
</div> </div>
{/each} {/each}

View File

@@ -1,22 +1,20 @@
import { db } from "$lib/db" import { db } from '$lib/server/db';
import { blogArticles } from "$lib/db/schema"; import { blogArticles } from '$lib/server/db/schema';
import { eq } from "drizzle-orm"; import { eq } from 'drizzle-orm';
import type { PageServerLoad } from "./$types.js"; import type { PageServerLoad } from './$types.js';
import { fail } from "@sveltejs/kit"; import { fail } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params }) => { export const load: PageServerLoad = async ({ params }) => {
const article = await db.select().from(blogArticles).where(eq(blogArticles.slug, params.slug)) const article = await db.select().from(blogArticles).where(eq(blogArticles.slug, params.slug));
if (article.length === 0) { if (article.length === 0) {
return fail(404, { message: "Article not found" }); return fail(404, { message: 'Article not found' });
} }
return { return {
blogArticle: article[0] blogArticle: article[0]
} };
} };
export const actions = { export const actions = {};
}

View File

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

View File

@@ -1,6 +1,5 @@
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { detectBreed } from '$lib/server/breed-detector';
import { z } from 'zod'; import { z } from 'zod';
const schema = z.object({ imageUrl: z.string().url() }); 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' }); 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 }); return json(result ?? { breed: null, confidence: 0 });
}; };

View File

@@ -1,12 +1,9 @@
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { game, pokemonGameEntry, userEntry, userGame } from '$lib/server/db/schema'; 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 { availabilityValues } from '$lib/availability';
import { fail } from '@sveltejs/kit'; 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 type WireEntry = [id: number, gameId: number, availability: number, note?: string];
export const load: PageServerLoad = async ({ locals }) => { export const load: PageServerLoad = async ({ locals }) => {

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { db } from "$lib/db" import { db } from '$lib/server/db';
import { tasks } from "$lib/db/schema" import { tasks } from '$lib/server/db/schema';
import { redirect } from "@sveltejs/kit" import { redirect } from '@sveltejs/kit';
export const actions = { export const actions = {
default: async ({ cookies, request }) => { default: async ({ cookies, request }) => {
@@ -8,10 +8,13 @@ export const actions = {
return { status: 401 }; 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'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => { export const load: PageServerLoad = async () => {

View File

@@ -1,6 +1,4 @@
import { db } from '$lib/db'; import { db } from '$lib/server/db';
import { topics } from '$lib/db/schema';
import { eq } from 'drizzle-orm';
export const load = async ({ params }) => { export const load = async ({ params }) => {
return { 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 { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
import Icons from 'unplugin-icons/vite';
import { enhancedImages } from '@sveltejs/enhanced-img';
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), sveltekit()], plugins: [tailwindcss(), enhancedImages(), sveltekit(), Icons({ compiler: 'svelte' })],
test: { test: {
include: ['src/**/*.{test,spec}.{js,ts}'] include: ['src/**/*.{test,spec}.{js,ts}']
}, },