This commit is contained in:
137
src/app.css
137
src/app.css
@@ -2,22 +2,113 @@
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
@theme {
|
||||
--color-bg: #f6f4f0;
|
||||
--color-surface: #edeae4;
|
||||
--color-surface-hover: #e4e0d8;
|
||||
--color-border: #d6d2ca;
|
||||
--color-border-light: #e8e5de;
|
||||
--color-text: #2c2c2c;
|
||||
--color-text-muted: #6b6b6b;
|
||||
--color-text-dim: #999999;
|
||||
--color-text-ghost: #c5c0b8;
|
||||
--color-accent: #3d6b8e;
|
||||
--color-accent-hover: #4d7fa6;
|
||||
--color-bg: #000;
|
||||
--color-surface: #0a0a0a;
|
||||
--color-surface-hover: #171717;
|
||||
--color-border: #262626;
|
||||
--color-border-light: #1a1a1a;
|
||||
--color-text: #ededed;
|
||||
--color-text-muted: #a1a1a1;
|
||||
--color-text-dim: #888;
|
||||
--color-text-ghost: #666;
|
||||
--color-accent: #ededed;
|
||||
--color-accent-hover: #fff;
|
||||
--color-danger: #b44;
|
||||
|
||||
--font-heading: 'DM Serif Display', Georgia, serif;
|
||||
--font-heading: Montserrat, Arial, Helvetica, sans-serif;
|
||||
--font-mono: 'IBM Plex Mono', 'Courier New', monospace;
|
||||
--font-body: 'Source Sans 3', 'Helvetica Neue', sans-serif;
|
||||
--font-body: Montserrat, Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--site-bg: #000;
|
||||
--site-surface: #0a0a0a;
|
||||
--site-hover: #171717;
|
||||
--site-text: #ededed;
|
||||
--site-muted: #a1a1a1;
|
||||
--site-border: #292929;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
--site-bg: #fff;
|
||||
--site-surface: #fafafa;
|
||||
--site-hover: #f4f4f5;
|
||||
--site-text: #171717;
|
||||
--site-muted: #646464;
|
||||
--site-border: #e5e5e5;
|
||||
--color-bg: #fff;
|
||||
--color-surface: #fafafa;
|
||||
--color-surface-hover: #f4f4f5;
|
||||
--color-border: #e5e5e5;
|
||||
--color-border-light: #eee;
|
||||
--color-text: #171717;
|
||||
--color-text-muted: #646464;
|
||||
--color-text-dim: #777;
|
||||
--color-text-ghost: #999;
|
||||
--color-accent: #171717;
|
||||
--color-accent-hover: #000;
|
||||
}
|
||||
|
||||
.article-content {
|
||||
--tw-prose-body: var(--site-text);
|
||||
--tw-prose-headings: var(--site-text);
|
||||
--tw-prose-lead: var(--site-text);
|
||||
--tw-prose-links: var(--site-text);
|
||||
--tw-prose-bold: var(--site-text);
|
||||
--tw-prose-counters: var(--site-muted);
|
||||
--tw-prose-bullets: var(--site-muted);
|
||||
--tw-prose-hr: var(--site-border);
|
||||
--tw-prose-quotes: var(--site-text);
|
||||
--tw-prose-quote-borders: var(--site-border);
|
||||
--tw-prose-captions: var(--site-muted);
|
||||
--tw-prose-code: var(--site-text);
|
||||
--tw-prose-th-borders: var(--site-border);
|
||||
--tw-prose-td-borders: var(--site-border);
|
||||
}
|
||||
|
||||
/* Keep older project pages readable while their individual styles use light utility colors. */
|
||||
:root:not([data-theme='light']) .bg-white,
|
||||
:root:not([data-theme='light']) .bg-gray-50,
|
||||
:root:not([data-theme='light']) .bg-neutral-50 {
|
||||
background-color: #0a0a0a;
|
||||
}
|
||||
|
||||
:root:not([data-theme='light']) .bg-gray-100,
|
||||
:root:not([data-theme='light']) .bg-neutral-100 {
|
||||
background-color: #171717;
|
||||
}
|
||||
|
||||
:root:not([data-theme='light']) .bg-gray-200,
|
||||
:root:not([data-theme='light']) .bg-neutral-200 {
|
||||
background-color: #262626;
|
||||
}
|
||||
|
||||
:root:not([data-theme='light']) .text-black,
|
||||
:root:not([data-theme='light']) .text-gray-900,
|
||||
:root:not([data-theme='light']) .text-gray-800 {
|
||||
color: #ededed;
|
||||
}
|
||||
|
||||
:root:not([data-theme='light']) .text-gray-700 {
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
:root:not([data-theme='light']) .text-gray-600,
|
||||
:root:not([data-theme='light']) .text-gray-500 {
|
||||
color: #a1a1a1;
|
||||
}
|
||||
|
||||
:root:not([data-theme='light']) .border-gray-100,
|
||||
:root:not([data-theme='light']) .border-gray-200,
|
||||
:root:not([data-theme='light']) .border-gray-300 {
|
||||
border-color: #292929;
|
||||
}
|
||||
|
||||
:root:not([data-theme='light']) .hover\:bg-gray-50:hover,
|
||||
:root:not([data-theme='light']) .hover\:bg-gray-100:hover {
|
||||
background-color: #171717;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -36,15 +127,27 @@
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
background: var(--site-bg);
|
||||
color: var(--site-text);
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
line-height: 1.65;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--site-text);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||
}
|
||||
|
||||
5
src/app.d.ts
vendored
5
src/app.d.ts
vendored
@@ -5,7 +5,10 @@
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
interface Locals {
|
||||
user: typeof import('$lib/server/auth').auth.$Infer.Session.user | null;
|
||||
session: typeof import('$lib/server/auth').auth.$Infer.Session.session | null;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
|
||||
26
src/app.html
26
src/app.html
@@ -1,6 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<script>
|
||||
let themePreference = 'dark';
|
||||
try {
|
||||
const storedTheme = localStorage.getItem('theme');
|
||||
if (storedTheme === 'light' || storedTheme === 'system') themePreference = storedTheme;
|
||||
} catch {
|
||||
// Keep the dark default if storage is unavailable.
|
||||
}
|
||||
document.documentElement.dataset.themePreference = themePreference;
|
||||
document.documentElement.dataset.theme =
|
||||
themePreference === 'system'
|
||||
? window.matchMedia('(prefers-color-scheme: light)').matches
|
||||
? 'light'
|
||||
: 'dark'
|
||||
: themePreference;
|
||||
document.querySelector('meta[name="theme-color"]').content =
|
||||
document.documentElement.dataset.theme === 'light' ? '#ffffff' : '#000000';
|
||||
</script>
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-7K10F5HJMQ"></script>
|
||||
<script>
|
||||
@@ -18,7 +37,12 @@
|
||||
<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>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
13
src/hooks.server.ts
Normal file
13
src/hooks.server.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { building } from '$app/environment';
|
||||
import { auth } from '$lib/server/auth';
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Match the endpoint path here: the dev proxy may terminate HTTPS before Vite.
|
||||
// Better Auth validates request origins against its configured public URL.
|
||||
if (!building && event.url.pathname.startsWith('/api/auth/')) return auth.handler(event.request);
|
||||
const session = building ? null : await auth.api.getSession({ headers: event.request.headers });
|
||||
event.locals.user = session?.user ?? null;
|
||||
event.locals.session = session?.session ?? null;
|
||||
return resolve(event);
|
||||
};
|
||||
2
src/lib/auth-client.ts
Normal file
2
src/lib/auth-client.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { createAuthClient } from 'better-auth/svelte';
|
||||
export const authClient = createAuthClient();
|
||||
26
src/lib/auth-redirect.test.ts
Normal file
26
src/lib/auth-redirect.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { safeReturnTo } from './auth-redirect';
|
||||
|
||||
describe('login return destinations', () => {
|
||||
it.each([
|
||||
null,
|
||||
'',
|
||||
'https://evil.example',
|
||||
'//evil.example',
|
||||
'/\\evil.example',
|
||||
'/\nevil.example',
|
||||
'/api/auth/callback/osu',
|
||||
'/logout',
|
||||
'/login?returnTo=/blog'
|
||||
])('rejects %j', (value) => {
|
||||
expect(safeReturnTo(value)).toBe('/account');
|
||||
});
|
||||
it('keeps a local project destination and its query', () => {
|
||||
expect(safeReturnTo('/projects/catch-em-all?game=red#progress')).toBe(
|
||||
'/projects/catch-em-all?game=red#progress'
|
||||
);
|
||||
});
|
||||
it('normalizes paths before rejecting auth endpoints', () => {
|
||||
expect(safeReturnTo('/projects/../logout')).toBe('/account');
|
||||
});
|
||||
});
|
||||
13
src/lib/auth-redirect.ts
Normal file
13
src/lib/auth-redirect.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export function safeReturnTo(value: string | null): string {
|
||||
if (!value || !value.startsWith('/') || value.startsWith('//') || /[\\\s]/.test(value)) {
|
||||
return '/account';
|
||||
}
|
||||
const url = new URL(value, 'https://local.invalid');
|
||||
if (
|
||||
url.origin !== 'https://local.invalid' ||
|
||||
/^\/(api\/auth|login|register|logout)(\/|$)/.test(url.pathname)
|
||||
) {
|
||||
return '/account';
|
||||
}
|
||||
return url.pathname + url.search + url.hash;
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
// 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).
|
||||
// How a Pokémon can be obtained in a game. Missing rows mean no source record;
|
||||
// they do not prove absence. Unknown rows establish presence, not availability.
|
||||
// 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'
|
||||
'event_only',
|
||||
'unknown'
|
||||
] as const;
|
||||
|
||||
export type Availability = (typeof availabilityValues)[number];
|
||||
|
||||
149
src/lib/components/AuthForm.svelte
Normal file
149
src/lib/components/AuthForm.svelte
Normal file
@@ -0,0 +1,149 @@
|
||||
<script lang="ts">
|
||||
import { authClient } from '$lib/auth-client';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
let {
|
||||
mode,
|
||||
providers,
|
||||
returnTo,
|
||||
oauthError
|
||||
}: {
|
||||
mode: 'login' | 'register';
|
||||
providers: { google: boolean; osu: boolean };
|
||||
returnTo: string;
|
||||
oauthError: boolean;
|
||||
} = $props();
|
||||
let name = $state('');
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let busy = $state(false);
|
||||
let ready = $state(false);
|
||||
onMount(() => {
|
||||
ready = true;
|
||||
});
|
||||
let message = $state('');
|
||||
async function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const destination = `${resolve('/')}${returnTo.slice(1)}`;
|
||||
busy = true;
|
||||
message = '';
|
||||
try {
|
||||
const result =
|
||||
mode === 'register'
|
||||
? await authClient.signUp.email({ name: name.trim(), email, password })
|
||||
: await authClient.signIn.email({ email, password });
|
||||
if (result.error) {
|
||||
message = result.error.message || 'Unable to sign in. Please try again.';
|
||||
return;
|
||||
}
|
||||
// Destination is a validated local path, prefixed by resolve('/') above.
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
await goto(destination, { invalidateAll: true });
|
||||
} catch {
|
||||
message = 'Unable to connect. Please try again.';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
async function social(provider: 'google' | 'osu') {
|
||||
busy = true;
|
||||
message = '';
|
||||
try {
|
||||
const result = await authClient.signIn.social({
|
||||
provider,
|
||||
callbackURL: returnTo,
|
||||
errorCallbackURL: `/login?returnTo=${encodeURIComponent(returnTo)}`
|
||||
});
|
||||
if (result.error) {
|
||||
message = result.error.message || 'Unable to connect to this provider.';
|
||||
busy = false;
|
||||
}
|
||||
} catch {
|
||||
message = 'Unable to connect. Please try again.';
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="mx-auto my-12 max-w-md px-4 sm:my-20">
|
||||
<h1 class="mb-8 text-3xl font-semibold tracking-tight">
|
||||
{mode === 'register' ? 'create account' : 'sign in'}
|
||||
</h1>
|
||||
{#if message || oauthError}
|
||||
<p role="alert" class="mb-4 rounded bg-red-50 p-3 text-red-800">
|
||||
{message ||
|
||||
'Provider sign-in failed or was cancelled. Try again, or use your existing login and link the provider from your account.'}
|
||||
</p>
|
||||
{/if}
|
||||
<form method="POST" onsubmit={submit} class="flex flex-col gap-4">
|
||||
{#if mode === 'register'}
|
||||
<label class="flex flex-col gap-1"
|
||||
>Name<input
|
||||
class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
|
||||
disabled={!ready || busy}
|
||||
name="name"
|
||||
autocomplete="name"
|
||||
required
|
||||
maxlength="100"
|
||||
bind:value={name}
|
||||
/></label
|
||||
>
|
||||
{/if}
|
||||
<label class="flex flex-col gap-1"
|
||||
>Email<input
|
||||
class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
|
||||
disabled={!ready || busy}
|
||||
name="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
required
|
||||
bind:value={email}
|
||||
/></label
|
||||
>
|
||||
<label class="flex flex-col gap-1"
|
||||
>Password<input
|
||||
class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
|
||||
disabled={!ready || busy}
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete={mode === 'register' ? 'new-password' : 'current-password'}
|
||||
required
|
||||
minlength={mode === 'register' ? 12 : undefined}
|
||||
maxlength="128"
|
||||
bind:value={password}
|
||||
/></label
|
||||
>
|
||||
{#if mode === 'register'}<p class="text-sm text-gray-600">Use at least 12 characters.</p>{/if}
|
||||
<button
|
||||
disabled={!ready || busy}
|
||||
class="rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)] disabled:opacity-50"
|
||||
>{busy ? 'please wait…' : mode === 'register' ? 'create account' : 'sign in'}</button
|
||||
>
|
||||
</form>
|
||||
{#if providers.google || providers.osu}
|
||||
<div class="my-6 flex flex-col gap-3 border-t border-[var(--site-border)] pt-4">
|
||||
{#if providers.google}<button
|
||||
disabled={!ready || busy}
|
||||
onclick={() => social('google')}
|
||||
class="rounded border border-[var(--site-border)] px-4 py-2 disabled:opacity-50"
|
||||
>Continue with Google</button
|
||||
>{/if}
|
||||
{#if providers.osu}<button
|
||||
disabled={!ready || busy}
|
||||
onclick={() => social('osu')}
|
||||
class="rounded border border-[var(--site-border)] px-4 py-2 disabled:opacity-50"
|
||||
>Continue with osu!</button
|
||||
>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<p class="mt-6 text-base">
|
||||
<a
|
||||
class="underline underline-offset-4"
|
||||
href={resolve(
|
||||
`${mode === 'register' ? '/login' : '/register'}?returnTo=${encodeURIComponent(returnTo)}`
|
||||
)}>{mode === 'register' ? 'sign in' : 'Create account'}</a
|
||||
>
|
||||
</p>
|
||||
<noscript><p class="mt-4">Enable JavaScript to securely sign in or register.</p></noscript>
|
||||
</section>
|
||||
@@ -33,7 +33,7 @@
|
||||
<button
|
||||
onclick={copy}
|
||||
aria-label={`Copy ${label}: ${value}`}
|
||||
class="text-2xl transition hover:opacity-60 cursor-pointer"
|
||||
class="flex h-10 w-10 cursor-pointer items-center justify-center text-xl transition hover:text-[var(--site-text)]"
|
||||
>
|
||||
<Icon />
|
||||
</button>
|
||||
|
||||
64
src/lib/components/ListPictureDropzone.svelte
Normal file
64
src/lib/components/ListPictureDropzone.svelte
Normal file
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { label = 'Picture (optional)' }: { label?: string } = $props();
|
||||
let input: HTMLInputElement;
|
||||
let fileName = $state('');
|
||||
let dragging = $state(false);
|
||||
|
||||
function selected() {
|
||||
fileName = input.files?.[0]?.name ?? '';
|
||||
}
|
||||
|
||||
function drop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
dragging = false;
|
||||
const file = event.dataTransfer?.files?.[0];
|
||||
if (!file) return;
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(file);
|
||||
input.files = transfer.files;
|
||||
selected();
|
||||
}
|
||||
|
||||
function clear() {
|
||||
input.value = '';
|
||||
fileName = '';
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const form = input.form;
|
||||
form?.addEventListener('reset', clear);
|
||||
return () => form?.removeEventListener('reset', clear);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<span class="block font-medium">{label}</span>
|
||||
<input
|
||||
bind:this={input}
|
||||
name="picture"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onchange={selected}
|
||||
class="sr-only"
|
||||
aria-label={label}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => input.click()}
|
||||
ondragover={(event) => {
|
||||
event.preventDefault();
|
||||
dragging = true;
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
||||
}}
|
||||
ondragleave={() => (dragging = false)}
|
||||
ondrop={drop}
|
||||
class={`mt-2 block w-full rounded border border-dashed p-5 text-center transition-colors ${dragging ? 'border-[var(--site-text)] bg-[var(--site-hover)]' : 'border-[var(--site-border)] hover:border-[var(--site-muted)]'}`}
|
||||
>
|
||||
{fileName ? `Selected: ${fileName}` : 'Drop an image here or click to browse'}
|
||||
</button>
|
||||
<p class="mt-2 text-base text-[var(--site-muted)]">JPEG, PNG, or WebP · up to 5 MB</p>
|
||||
{#if fileName}<button type="button" onclick={clear} class="mt-2 underline">Clear selection</button
|
||||
>{/if}
|
||||
</div>
|
||||
43
src/lib/components/ProgressBar.svelte
Normal file
43
src/lib/components/ProgressBar.svelte
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
value,
|
||||
max,
|
||||
label,
|
||||
small = false
|
||||
}: {
|
||||
value: number;
|
||||
max: number;
|
||||
label: string;
|
||||
small?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<progress class:small {value} max={max || 1} aria-label={label}></progress>
|
||||
|
||||
<style>
|
||||
progress {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 0.625rem;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
background: var(--site-border);
|
||||
}
|
||||
progress.small {
|
||||
height: 0.375rem;
|
||||
}
|
||||
progress::-webkit-progress-bar {
|
||||
background: var(--site-border);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
progress::-webkit-progress-value {
|
||||
background: var(--site-text);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
progress::-moz-progress-bar {
|
||||
background: var(--site-text);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
</style>
|
||||
26
src/lib/derby/generic.test.ts
Normal file
26
src/lib/derby/generic.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { defaultsFor, gPredict } from './generic';
|
||||
import { placings } from './model';
|
||||
import { races } from './races';
|
||||
|
||||
describe('generic race model', () => {
|
||||
for (const race of races) {
|
||||
it(`${race.slug}: probabilities sum to one and placings are consistent`, () => {
|
||||
const preds = gPredict(race.runners, defaultsFor(race));
|
||||
expect(preds.reduce((a, r) => a + r.p, 0)).toBeCloseTo(1, 9);
|
||||
expect(preds.length).toBe(race.runners.length);
|
||||
const { top, trio } = placings(preds, 0.8);
|
||||
expect(top.reduce((a, t) => a + t.win, 0)).toBeCloseTo(1, 9);
|
||||
expect(trio.reduce((a, t) => a + t.p, 0)).toBeCloseTo(1, 9);
|
||||
for (const r of preds) expect(Number.isFinite(r.logit)).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
it('debutants get the field average and a higher fail risk', () => {
|
||||
const race = races.find((r) => r.slug === 'south-holland')!;
|
||||
const preds = gPredict(race.runners, defaultsFor(race));
|
||||
const deb = preds.find((r) => r.horse.n === 1)!;
|
||||
expect(deb.features.estimated).toEqual(['form', 'speed', 'level']);
|
||||
expect(deb.features.pFail).toBeCloseTo(0.12);
|
||||
});
|
||||
});
|
||||
132
src/lib/derby/generic.ts
Normal file
132
src/lib/derby/generic.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
// Algemeen kansmodel voor de overige koersen: zelfde opzet als het Derby-model
|
||||
// (z-scores × gewichten → softmax), maar zonder proef- en marktsignaal.
|
||||
// Signalen: uitslagen, snelheid (beste recente km-tijd), niveau (prijzengeld
|
||||
// van de laatste starts), startpositie en uitvalrisico.
|
||||
|
||||
import type { Result } from './data';
|
||||
import type { Race, Runner } from './races';
|
||||
|
||||
export type GWeights = {
|
||||
form: number;
|
||||
speed: number;
|
||||
level: number;
|
||||
draw: number;
|
||||
risk: number;
|
||||
halfLife: number;
|
||||
temperature: number;
|
||||
lambda: number;
|
||||
};
|
||||
|
||||
export const gFeatureKeys = ['form', 'speed', 'level', 'draw'] as const;
|
||||
export type GFeatureKey = (typeof gFeatureKeys)[number];
|
||||
|
||||
export const gDefaults: GWeights = {
|
||||
form: 1,
|
||||
speed: 1,
|
||||
level: 0.6,
|
||||
draw: 0.4,
|
||||
risk: 1,
|
||||
halfLife: 2,
|
||||
temperature: 1,
|
||||
lambda: 0.8
|
||||
};
|
||||
|
||||
/** rensport: geen tijden, startbox minder belangrijk over 1900 m */
|
||||
export const defaultsFor = (race: Race): GWeights =>
|
||||
race.discipline === 'ren' ? { ...gDefaults, speed: 0, draw: 0.2, level: 0.8 } : { ...gDefaults };
|
||||
|
||||
export const gPresets: Record<string, Partial<GWeights>> = {
|
||||
Standaard: {},
|
||||
Vorm: { form: 1.6, speed: 0.5, level: 0.4 },
|
||||
Snelheid: { form: 0.5, speed: 1.6, level: 0.4 },
|
||||
Niveau: { form: 0.6, speed: 0.6, level: 1.5 }
|
||||
};
|
||||
|
||||
const SCALE = 0.55;
|
||||
const POSITION_POINTS: Record<number, number> = { 1: 1, 2: 0.75, 3: 0.6, 4: 0.45, 5: 0.35, 6: 0.25 };
|
||||
const positionPoints = (r: Result) => (r === 'D' ? 0 : (POSITION_POINTS[r] ?? 0.1));
|
||||
const DRAW_SCORE = [0, 0.6, 0.7, 0.6, 0.45, 0.4, 0.25, 0.1, 0, -0.2, -0.3];
|
||||
|
||||
export type GFeatures = {
|
||||
form: number;
|
||||
speed: number;
|
||||
level: number;
|
||||
draw: number;
|
||||
pFail: number;
|
||||
/** welke signalen ontbraken en het veldgemiddelde kregen */
|
||||
estimated: GFeatureKey[];
|
||||
};
|
||||
|
||||
const mean = (xs: number[]) => xs.reduce((a, b) => a + b, 0) / xs.length;
|
||||
|
||||
function rawForm(h: Runner, w: GWeights): number | null {
|
||||
if (h.form.length === 0) return null;
|
||||
const decay = h.form.map((_, i) => Math.pow(0.5, i / w.halfLife));
|
||||
return h.form.reduce((s: number, r, i) => s + decay[i] * positionPoints(r), 0) / decay.reduce((a, b) => a + b, 0);
|
||||
}
|
||||
|
||||
export function gFeatures(runners: Runner[], w: GWeights): GFeatures[] {
|
||||
const raw = runners.map((h) => ({
|
||||
form: rawForm(h, w),
|
||||
speed: h.times.length ? -Math.min(...h.times) : null,
|
||||
level: h.purses.length ? mean(h.purses.map((p) => Math.log(p))) : null
|
||||
}));
|
||||
const fill = (k: 'form' | 'speed' | 'level') => {
|
||||
const known = raw.map((r) => r[k]).filter((v): v is number => v !== null);
|
||||
return known.length ? mean(known) : 0;
|
||||
};
|
||||
const avg = { form: fill('form'), speed: fill('speed'), level: fill('level') };
|
||||
|
||||
return runners.map((h, i) => {
|
||||
const estimated: GFeatureKey[] = [];
|
||||
const pick = (k: 'form' | 'speed' | 'level') => {
|
||||
const v = raw[i][k];
|
||||
if (v === null) estimated.push(k);
|
||||
return v ?? avg[k];
|
||||
};
|
||||
const disq = h.form.filter((r) => r === 'D').length;
|
||||
const poor = h.form.filter((r) => r !== 'D' && r >= 7).length;
|
||||
// debutanten: geen bewijs dat ze in de draf blijven
|
||||
const base = h.form.length === 0 ? 0.12 : 0.03;
|
||||
const pFail = Math.min(0.5, base + 0.1 * disq + 0.03 * poor);
|
||||
return { form: pick('form'), speed: pick('speed'), level: pick('level'), draw: DRAW_SCORE[h.n] ?? 0, pFail, estimated };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ondergrens voor de spreiding per signaal, anders wordt 0,2 s verschil in een
|
||||
* veld met drie bijna gelijke tijden een enorme z-score
|
||||
*/
|
||||
const SD_FLOOR: Record<GFeatureKey, number> = { form: 0.08, speed: 1, level: 0.3, draw: 0.15 };
|
||||
|
||||
function zscores(values: number[], floor: number): number[] {
|
||||
const m = mean(values);
|
||||
const sd = Math.max(floor, Math.sqrt(mean(values.map((v) => (v - m) ** 2))));
|
||||
return values.map((v) => (v - m) / sd);
|
||||
}
|
||||
|
||||
export type GPrediction = {
|
||||
horse: Runner;
|
||||
features: GFeatures;
|
||||
contributions: Record<GFeatureKey, number>;
|
||||
riskPenalty: number;
|
||||
logit: number;
|
||||
p: number;
|
||||
};
|
||||
|
||||
export function gPredict(runners: Runner[], w: GWeights): GPrediction[] {
|
||||
if (runners.length === 0) return [];
|
||||
const feats = gFeatures(runners, w);
|
||||
const z = Object.fromEntries(gFeatureKeys.map((k) => [k, zscores(feats.map((f) => f[k]), SD_FLOOR[k])])) as Record<GFeatureKey, number[]>;
|
||||
const rows = runners.map((horse, i) => {
|
||||
const contributions = Object.fromEntries(gFeatureKeys.map((k) => [k, w[k] * z[k][i] * SCALE])) as Record<GFeatureKey, number>;
|
||||
const riskPenalty = w.risk * Math.log(1 - feats[i].pFail);
|
||||
const logit = Object.values(contributions).reduce((a, b) => a + b, 0) + riskPenalty;
|
||||
return { horse, features: feats[i], contributions, riskPenalty, logit, p: 0 };
|
||||
});
|
||||
const max = Math.max(...rows.map((r) => r.logit));
|
||||
const exp = rows.map((r) => Math.exp((r.logit - max) / w.temperature));
|
||||
const total = exp.reduce((a, b) => a + b, 0);
|
||||
rows.forEach((r, i) => (r.p = exp[i] / total));
|
||||
return rows.sort((a, b) => b.p - a.p);
|
||||
}
|
||||
@@ -129,7 +129,7 @@ export type Placings = {
|
||||
* Stern-demping: voor de 2e en 3e plaats wordt p^lambda gebruikt, wat de
|
||||
* neiging van pure PL corrigeert om de favoriet te vaak tweede te laten worden.
|
||||
*/
|
||||
export function placings(preds: Prediction[], lambda: number): Placings {
|
||||
export function placings(preds: { p: number }[], lambda: number): Placings {
|
||||
const n = preds.length;
|
||||
const p1 = preds.map((r) => r.p);
|
||||
const p2 = p1.map((p) => Math.pow(p, lambda));
|
||||
|
||||
128
src/lib/derby/races.ts
Normal file
128
src/lib/derby/races.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
// Duindigt · zondag 20 september 2026 · koersen 3, 4 en 5
|
||||
// Bron: ZEturf koerskaarten en paardenfiches (laatste starts, kilometertijden, prijzengeld).
|
||||
|
||||
import type { Result } from './data';
|
||||
|
||||
export type Runner = {
|
||||
n: number;
|
||||
name: string;
|
||||
driver: string;
|
||||
trainer: string;
|
||||
sex: 'M' | 'H' | 'R';
|
||||
age: number;
|
||||
/** laatste vijf uitslagen, nieuwste eerst; D = gediskwalificeerd/gevallen; leeg = debutant */
|
||||
form: Result[];
|
||||
/** kilometertijden (s) van de laatste starts, nieuwste eerst; alleen bekende */
|
||||
times: number[];
|
||||
/** prijzengeld (€) van de laatste starts, nieuwste eerst: niveau waarop het paard uitkomt */
|
||||
purses: number[];
|
||||
/** beste kilometertijd ooit, null = onbekend */
|
||||
record: number | null;
|
||||
/** gedragen gewicht (alleen rensport) */
|
||||
kg?: number;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type Race = {
|
||||
slug: string;
|
||||
number: number;
|
||||
name: string;
|
||||
short: string;
|
||||
time: string;
|
||||
distance: number;
|
||||
start: string;
|
||||
purse: number;
|
||||
surface: string;
|
||||
discipline: 'draf' | 'ren';
|
||||
conditions: string;
|
||||
/** voorbeschouwing ZEturf (Kees Nakoersen) */
|
||||
preview: string;
|
||||
tips: number[];
|
||||
runners: Runner[];
|
||||
sources: { label: string; href: string }[];
|
||||
};
|
||||
|
||||
const t = (m: number, s: number) => m * 60 + s;
|
||||
const ze = (c: number, slug: string) => `https://www.zeturf.be/nl/course-du-jour/2026-09-20/R14C${c}-duindigt-${slug}`;
|
||||
|
||||
export const races: Race[] = [
|
||||
{
|
||||
slug: 'sheikh-mansour',
|
||||
number: 3,
|
||||
name: 'HH Sheikh Mansour bin Zayed Al Nahyan Prijs',
|
||||
short: 'Sheikh Mansour Prijs',
|
||||
time: '16:10',
|
||||
distance: 1900,
|
||||
start: 'Startboxen',
|
||||
purse: 10000,
|
||||
surface: 'Grasbaan · standaard',
|
||||
discipline: 'ren',
|
||||
conditions: 'Ren voor Arabische volbloeds, 4 jaar en ouder',
|
||||
preview:
|
||||
'Tex D.A. (3) kan mogelijk op herhaling op deze baan waar hij op 26 juli sterk won voor tweede Ektor D.A. (6) en met als vierde Orszana JR (5). Maar de ruin treft in El Zarka D.A. (1) en Farida P (2) twee sterke tegenstanders. Beide bleven weliswaar op iets meer dan 8 lengte op deze baan ongeplaatst, maar dat was tegen de top van Europa. Princess T (4) versloeg Tex D.A. met een neusje verschil op de grasbaan van Joure, dus ook deze merrie moeten we serieus nemen. Een koers waarin veel mogelijk is.',
|
||||
tips: [3, 1, 2, 6],
|
||||
sources: [{ label: 'ZEturf koerskaart', href: ze(3, 'hh-sheikh-mansour-bin-zayed-al-nahyan-prijs') }],
|
||||
runners: [
|
||||
{ n: 1, name: 'El Zarka D.A.', driver: 'Jurico Bogaerts', trainer: 'Johan Verstrepen', sex: 'R', age: 12, form: [7, 9, 5, 5, 3], times: [], purses: [250000, 100000, 26400, 92764, 6000], record: null, kg: 64, note: 'loopt tegen de Europese top' },
|
||||
{ n: 2, name: 'Farida P', driver: 'Anne Engels', trainer: 'Peter Deckers', sex: 'M', age: 7, form: ['D', 10, 3, 3, 1], times: [], purses: [250000, 100000, 26400, 92764, 6000], record: null, kg: 62, note: 'D = gevallen in Waregem' },
|
||||
{ n: 3, name: 'Tex D.A.', driver: 'Luca van Toor', trainer: 'Bianca Dunk', sex: 'R', age: 9, form: [10, 1, 2, 2, 2], times: [], purses: [250000, 5000, 4000, 6000, 6000], record: null, kg: 61, note: 'won 26 juli op Duindigt' },
|
||||
{ n: 4, name: 'Princess T', driver: 'Mirko Sanna', trainer: 'Gerard Zoetelief', sex: 'M', age: 6, form: [1, 4, 3, 5, 4], times: [], purses: [4000, 6000, 4000, 6000, 6000], record: null, kg: 57, note: 'versloeg Tex D.A. in Joure' },
|
||||
{ n: 5, name: 'Orszana JR', driver: 'Josephine Bruning', trainer: "Manouk van 't Slot", sex: 'M', age: 8, form: [4, 4, 4, 7], times: [], purses: [5000, 4000, 4200, 5100], record: null, kg: 56 },
|
||||
{ n: 6, name: 'Ektor D.A.', driver: 'Siem Bos', trainer: 'Johan Verstrepen', sex: 'H', age: 4, form: [3, 2, 5, 3, 7], times: [], purses: [6000, 5000, 6000, 6000, 7000], record: null, kg: 55 }
|
||||
]
|
||||
},
|
||||
{
|
||||
slug: 'schieweg',
|
||||
number: 4,
|
||||
name: 'Handelsonderneming Schieweg B.V. Prijs',
|
||||
short: 'Schieweg Prijs',
|
||||
time: '16:40',
|
||||
distance: 2000,
|
||||
start: 'Autostart',
|
||||
purse: 4750,
|
||||
surface: 'Sintelbaan · goed',
|
||||
discipline: 'draf',
|
||||
conditions: 'Draverij voor 4 jaar en ouder, winsom lager dan € 18.000',
|
||||
preview:
|
||||
'Paradepaard van Duindigt 2025 Louis Vuiton (7) wordt na deze koers gehuldigd, maar of dat als winnaar is staat niet vast. Pasion d’Oro (4) is waarschijnlijk het te kloppen paard: de hengst van Jacky Mollema verkeert in topvorm. Michel D (6) is eveneens in grote vorm, won tweemaal in Duitsland maar nog niet op Duindigt, al zat hij daar steeds dichtbij. Nobell Starro (5) werd in zijn laatste koers vierde op één lijn met tweede aankomende Louis Vuiton. Nolana Boko (8) won dit jaar al driemaal, laatst in Alkmaar. Otini Bianco (2) is hoogstwaarschijnlijk het Paradepaard van Duindigt 2026; met een juiste koers is de ruin er vandaag ook bij.',
|
||||
tips: [4, 7, 6, 5],
|
||||
sources: [{ label: 'ZEturf koerskaart', href: ze(4, 'handelsonderneming-schieweg-b-v-prijs') }],
|
||||
runners: [
|
||||
{ n: 1, name: 'Puck Ballaco', driver: 'Micha Brouwer', trainer: 'Rob de Vlieger', sex: 'M', age: 4, form: [4, 4, 1, 4, 3], times: [t(1, 17.9), t(1, 16.3), t(1, 16.6), t(1, 16.5), t(1, 17.8)], purses: [2750, 4000, 4000, 4400, 1100], record: t(1, 13.0) },
|
||||
{ n: 2, name: 'Otini Bianco', driver: 'Rob de Vlieger', trainer: 'Rob de Vlieger', sex: 'R', age: 5, form: ['D', 6, 1, 2, 'D'], times: [t(1, 15.5), t(1, 15.9), t(1, 16.4)], purses: [2750, 4400, 2750, 2750, 2750], record: t(1, 14.7), note: 'won 31 juli op Duindigt' },
|
||||
{ n: 3, name: 'Memory de Legume', driver: 'Patrick de Haan', trainer: 'Patrick de Haan', sex: 'M', age: 7, form: [6, 7, 8, 4, 4], times: [t(1, 19.7), t(1, 16.9), t(1, 15.6), t(1, 18.2), t(1, 14.1)], purses: [1600, 4400, 4400, 1500, 2200], record: t(1, 14.1) },
|
||||
{ n: 4, name: "Pasion d'Oro", driver: 'Jaap van Rijn', trainer: 'Jack Mollema', sex: 'H', age: 4, form: [1, 5, 1, 1, 5], times: [t(1, 16.6), t(1, 11.6), t(1, 12.2), t(1, 21.1), t(1, 14.0)], purses: [2750, 19567, 2200, 1650, 13806], record: t(1, 11.6), note: 'liep in Zweden tegen sterkere' },
|
||||
{ n: 5, name: 'Nobell Starro', driver: 'Robin Bakker', trainer: 'Fons Bakker', sex: 'R', age: 6, form: [4, 2, 1, 2, 'D'], times: [t(1, 14.0), t(1, 16.2), t(1, 11.1)], purses: [2750, 5000, 2000, 2200, 4400], record: t(1, 11.1) },
|
||||
{ n: 6, name: 'Michel D', driver: 'Jim Veldman', trainer: 'Huug van Dijk', sex: 'H', age: 7, form: [3, 2, 1, 2, 1], times: [t(1, 20.2), t(1, 15.6), t(1, 14.1), t(1, 16.9), t(1, 14.9)], purses: [5000, 2750, 2000, 5500, 1500], record: t(1, 13.8), note: 'laatste tijd over 3375 m' },
|
||||
{ n: 7, name: 'Louis Vuiton', driver: 'Hugo Langeweg', trainer: 'Hugo Langeweg', sex: 'R', age: 8, form: [2, 5, 4, 'D', 1], times: [t(1, 17.8), t(1, 12.1), t(1, 16.9)], purses: [2750, 11000, 2200, 1650, 2750], record: t(1, 12.1), note: 'Paradepaard 2025' },
|
||||
{ n: 8, name: 'Nolana Boko', driver: 'John Dekker', trainer: 'Carel Roodenrijs', sex: 'R', age: 6, form: [1, 'D', 5, 4, 4], times: [t(1, 19.1), t(1, 20.9), t(1, 13.9), t(1, 23.0)], purses: [1600, 2750, 1100, 2000, 1210], record: t(1, 13.4), note: 'vaak op tragere banen (Alkmaar)' }
|
||||
]
|
||||
},
|
||||
{
|
||||
slug: 'south-holland',
|
||||
number: 5,
|
||||
name: 'South Holland Prijs · TCT Tweejarigen Satellietkoers',
|
||||
short: 'South Holland Prijs',
|
||||
time: '17:10',
|
||||
distance: 1609,
|
||||
start: 'Autostart',
|
||||
purse: 4000,
|
||||
surface: 'Sintelbaan · goed',
|
||||
discipline: 'draf',
|
||||
conditions: 'Draverij voor tweejarigen (sprint, 1609 m)',
|
||||
preview:
|
||||
'Sisi Hazelaar (5) is op basis van haar laatste tweede plaats in Wolvega, waar ze een puike indruk maakte, kansrijk. Zo ook Seabiscuit Lobell (4), die vorige week bij haar debuut tweede werd achter Salina Meerswal; met een fijn stuk speed kan ze binnendoor plek twee veroveren. Spirit Meerswal (2) debuteerde met een derde plaats in Wolvega in 15,2 en is kanshebber nummer drie. Sophie Starlake (1) debuteert voor de stal van Dion Tesselaar, en dan zijn we sowieso gewaarschuwd.',
|
||||
tips: [5, 4, 2, 1],
|
||||
sources: [{ label: 'ZEturf koerskaart', href: ze(5, 'south-holland-prijs-tct-tweejarigen-satellietkoers') }],
|
||||
runners: [
|
||||
{ n: 1, name: 'Sophie Starlake', driver: 'Dion Tesselaar', trainer: 'Dion Tesselaar', sex: 'M', age: 2, form: [], times: [], purses: [], record: null, note: 'debutant · stal Tesselaar' },
|
||||
{ n: 2, name: 'Spirit Meerswal', driver: 'Jaap van Rijn', trainer: 'Jack Mollema', sex: 'H', age: 2, form: [3], times: [t(1, 15.2)], purses: [5500], record: t(1, 15.2) },
|
||||
{ n: 3, name: 'Shiva Jabi', driver: 'Jan Baas', trainer: 'Jan Baas', sex: 'H', age: 2, form: ['D', 'D'], times: [], purses: [3300, 5500], record: null, note: 'twee keer uit de draf' },
|
||||
{ n: 4, name: 'Seabiscuit Lobell', driver: 'Hugo Langeweg', trainer: 'Hugo Langeweg', sex: 'M', age: 2, form: [2], times: [t(1, 15.2)], purses: [3300], record: t(1, 15.2), note: '2e bij debuut, 10 september' },
|
||||
{ n: 5, name: 'Sisi Hazelaar', driver: 'Jeffrey Mieras', trainer: 'Dion Tesselaar', sex: 'M', age: 2, form: [2, 'D'], times: [t(1, 15.0)], purses: [5500, 5500], record: t(1, 15.0) },
|
||||
{ n: 6, name: 'Sir Bianco', driver: 'Frans van der Blonk', trainer: 'Frans van der Blonk', sex: 'R', age: 2, form: [], times: [], purses: [], record: null, note: 'debutant' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const raceBySlug = (slug: string) => races.find((r) => r.slug === slug);
|
||||
10
src/lib/geo.test.ts
Normal file
10
src/lib/geo.test.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { distanceMeters } from './geo';
|
||||
|
||||
describe('distanceMeters', () => {
|
||||
it('keeps nearby sightings within the 30 km limit and rejects distant ones', () => {
|
||||
expect(distanceMeters(52.009, 4.708, 52.009, 4.708)).toBe(0);
|
||||
expect(distanceMeters(52.009, 4.708, 52.01, 4.71)).toBeLessThan(30_000);
|
||||
expect(distanceMeters(52.009, 4.708, 52.37, 4.9)).toBeGreaterThan(30_000);
|
||||
});
|
||||
});
|
||||
11
src/lib/geo.ts
Normal file
11
src/lib/geo.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
const EARTH_RADIUS_METERS = 6_371_000;
|
||||
|
||||
export function distanceMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||
const toRad = (degrees: number) => (degrees * Math.PI) / 180;
|
||||
const deltaLat = toRad(lat2 - lat1);
|
||||
const deltaLng = toRad(lng2 - lng1);
|
||||
const a =
|
||||
Math.sin(deltaLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(deltaLng / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_METERS * Math.asin(Math.sqrt(a));
|
||||
}
|
||||
23
src/lib/map-finder/Artwork.svelte
Normal file
23
src/lib/map-finder/Artwork.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
let { src, class: className = '' }: { src?: string; class?: string } = $props();
|
||||
let failed = $state(false);
|
||||
$effect(() => {
|
||||
void src;
|
||||
failed = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class={`overflow-hidden rounded bg-gray-200 ${className}`}>
|
||||
{#if src?.startsWith('https://') && !failed}<img
|
||||
{src}
|
||||
alt=""
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
onerror={() => (failed = true)}
|
||||
/>{:else}<div
|
||||
class="flex h-full items-center justify-center text-2xl text-gray-500"
|
||||
aria-hidden="true"
|
||||
>
|
||||
♫
|
||||
</div>{/if}
|
||||
</div>
|
||||
26
src/lib/map-finder/MapCard.svelte
Normal file
26
src/lib/map-finder/MapCard.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { ScoredBeatmapset } from './types';
|
||||
import Artwork from './Artwork.svelte';
|
||||
let { map }: { map: ScoredBeatmapset } = $props();
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={`https://osu.ppy.sh/beatmapsets/${map.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block h-full overflow-hidden rounded border border-gray-300 hover:border-blue-600 focus-visible:outline-2 focus-visible:outline-blue-600"
|
||||
>
|
||||
<Artwork src={map.covers?.card} class="h-24 rounded-none" />
|
||||
<div class="space-y-1 p-3">
|
||||
<h4 class="font-semibold">
|
||||
{map.artist_unicode || map.artist} – {map.title_unicode || map.title}
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600">{map.score}/100 · {map.confidence} confidence</p>
|
||||
<p class="text-xs text-gray-600">{map.reasons.join(' · ')}</p>
|
||||
{#if map.status || map.beatmaps}<p class="text-xs text-gray-600">
|
||||
{map.status ?? ''}{map.status && map.beatmaps ? ' · ' : ''}{map.beatmaps
|
||||
? `${map.beatmaps.length} difficulties`
|
||||
: ''}
|
||||
</p>{/if}
|
||||
</div>
|
||||
</a>
|
||||
90
src/lib/map-finder/finder.test.ts
Normal file
90
src/lib/map-finder/finder.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseSpotifyInput } from './input';
|
||||
import { normalize, cleanTitle, scoreBeatmapset, rankCandidates, MAX_CANDIDATES } from './matching';
|
||||
import type { SpotifyTrack } from './types';
|
||||
const id = '6PUzxtIHkv346yP89NzP9X';
|
||||
const track: SpotifyTrack = {
|
||||
id,
|
||||
name: 'Song Title',
|
||||
artists: [{ name: 'Beyoncé & Friends' }],
|
||||
album: { name: 'Album', images: [] }
|
||||
};
|
||||
const map = { id: 1, title: 'Song Title', artist: 'Beyonce and Friends' };
|
||||
describe('Spotify input', () => {
|
||||
it.each([
|
||||
`https://open.spotify.com/track/${id}`,
|
||||
` https://open.spotify.com/track/${id}?si=abc `,
|
||||
`http://www.open.spotify.com/track/${id}/`,
|
||||
`https://open.spotify.com/intl-nl/track/${id}/?si=abc`,
|
||||
`spotify:track:${id}`
|
||||
])('parses %s', (value) => expect(parseSpotifyInput(value)).toEqual({ kind: 'track', id }));
|
||||
it.each([`spotify:playlist:${id}`, `https://open.spotify.com/playlist/${id}?si=abc`])(
|
||||
'parses playlist %s',
|
||||
(value) => expect(parseSpotifyInput(value)).toEqual({ kind: 'playlist', id })
|
||||
);
|
||||
it.each(['artist', 'episode', 'show'])('rejects unsupported %s', (resource) =>
|
||||
expect(parseSpotifyInput(`https://open.spotify.com/${resource}/${id}`).kind).toBe('unsupported')
|
||||
);
|
||||
it.each([
|
||||
'',
|
||||
' ',
|
||||
'spotify:track:bad',
|
||||
'https://open.spotify.com/track/',
|
||||
'https://open.spotify.com/track/bad',
|
||||
'x'.repeat(501)
|
||||
])('validates %s', (value) => expect(() => parseSpotifyInput(value)).toThrow());
|
||||
it('preserves plain search and rejects lookalike host recognition', () => {
|
||||
expect(parseSpotifyInput(' Deftones Change ')).toEqual({
|
||||
kind: 'search',
|
||||
query: 'Deftones Change'
|
||||
});
|
||||
expect(parseSpotifyInput(`https://open.spotify.com.evil.test/track/${id}`).kind).toBe('search');
|
||||
});
|
||||
});
|
||||
describe('matching', () => {
|
||||
it.each([
|
||||
'Song Title',
|
||||
'Song Title (Remastered 2011)',
|
||||
'Song Title - Remaster',
|
||||
'Song Title (feat. Someone)',
|
||||
'Song Title [Radio Edit]',
|
||||
'Song Title - Album Version'
|
||||
])('cleans %s', (value) => expect(cleanTitle(value)).toBe('song title'));
|
||||
it('normalizes punctuation, diacritics, ampersands and whitespace', () =>
|
||||
expect(normalize(' Beyoncé & Friends! ')).toBe('beyonce and friends'));
|
||||
it('keeps non-Latin text', () => expect(normalize('世界の歌')).toBe('世界の歌'));
|
||||
it('scores exact and unicode aliases highly', () => {
|
||||
expect(scoreBeatmapset(track, map).score).toBe(100);
|
||||
expect(
|
||||
scoreBeatmapset(
|
||||
{ ...track, name: '世界の歌' },
|
||||
{ ...map, title: 'Sekai no uta', title_unicode: '世界の歌' }
|
||||
).confidence
|
||||
).toBe('high');
|
||||
});
|
||||
it('does not confidently match the wrong artist', () =>
|
||||
expect(scoreBeatmapset(track, { ...map, artist: 'Unrelated Singer' }).confidence).toBe('low'));
|
||||
it('penalizes remix mismatch', () =>
|
||||
expect(scoreBeatmapset(track, { ...map, title: 'Song Title (Club Remix)' }).confidence).toBe(
|
||||
'low'
|
||||
));
|
||||
it('sorts, merges duplicate IDs and limits results', () => {
|
||||
const candidates = rankCandidates(track, [
|
||||
{ ...map, title: 'Song Title remix' },
|
||||
...Array.from({ length: 10 }, (_, id) => ({ ...map, id }))
|
||||
]);
|
||||
expect(candidates).toHaveLength(MAX_CANDIDATES);
|
||||
expect(new Set(candidates.map((m) => m.id)).size).toBe(MAX_CANDIDATES);
|
||||
expect(candidates[0].score).toBe(100);
|
||||
expect(candidates.every((m, i) => !i || candidates[i - 1].score >= m.score)).toBe(true);
|
||||
});
|
||||
it('discards unrelated candidates', () =>
|
||||
expect(
|
||||
rankCandidates(track, [{ ...map, title: 'Nothing Alike', artist: 'Different' }])
|
||||
).toEqual([]));
|
||||
});
|
||||
|
||||
it.each([`spotify:album:${id}`, `https://www.open.spotify.com/intl-nl/album/${id}/?si=abc`])(
|
||||
'parses album %s',
|
||||
(value) => expect(parseSpotifyInput(value)).toEqual({ kind: 'album', id })
|
||||
);
|
||||
44
src/lib/map-finder/input.ts
Normal file
44
src/lib/map-finder/input.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export type SpotifyInput =
|
||||
| { kind: 'track'; id: string }
|
||||
| { kind: 'playlist'; id: string }
|
||||
| { kind: 'album'; id: string }
|
||||
| { kind: 'search'; query: string }
|
||||
| { kind: 'unsupported'; resource: string };
|
||||
export function parseSpotifyInput(value: string): SpotifyInput {
|
||||
const query = value.trim();
|
||||
if (!query || query.length > 500)
|
||||
throw new Error('Enter a Spotify URL, URI, or song search (up to 500 characters).');
|
||||
let resource: string | undefined;
|
||||
let id: string | undefined;
|
||||
if (/^spotify:/i.test(query)) {
|
||||
const match = /^spotify:([^:]+):([^:]+)$/i.exec(query);
|
||||
if (!match)
|
||||
throw new Error(
|
||||
'Invalid Spotify URI. Use spotify:track:ID or spotify:playlist:ID or spotify:album:ID.'
|
||||
);
|
||||
[, resource, id] = match;
|
||||
} else {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(query);
|
||||
} catch {
|
||||
if (/^(?:https?:\/\/)?(?:www\.)?open\.spotify\.com\b/i.test(query))
|
||||
throw new Error('Invalid Spotify URL. Include https://open.spotify.com/.');
|
||||
return { kind: 'search', query };
|
||||
}
|
||||
if (!/^(www\.)?open\.spotify\.com$/i.test(url.hostname)) return { kind: 'search', query };
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.port)
|
||||
throw new Error('Invalid Spotify URL.');
|
||||
const match = /^\/(?:intl-[a-z-]+\/)?([^/]+)(?:\/([^/]+))?\/?$/i.exec(url.pathname);
|
||||
if (!match) throw new Error('Invalid Spotify URL path.');
|
||||
[, resource, id] = match;
|
||||
}
|
||||
resource = resource.toLowerCase();
|
||||
if (resource !== 'track' && resource !== 'playlist' && resource !== 'album')
|
||||
return { kind: 'unsupported', resource };
|
||||
if (!id || !/^[a-zA-Z0-9]{22}$/.test(id))
|
||||
throw new Error(
|
||||
'Invalid Spotify ID. Track, playlist, and album IDs must contain 22 letters or digits.'
|
||||
);
|
||||
return { kind: resource, id };
|
||||
}
|
||||
87
src/lib/map-finder/matching.ts
Normal file
87
src/lib/map-finder/matching.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { OsuBeatmapset, ScoredBeatmapset, SpotifyTrack } from './types';
|
||||
// Medium requires a strong title plus artist evidence. High is reserved for near-exact pairs.
|
||||
export const HIGH_CONFIDENCE = 88;
|
||||
export const MEDIUM_CONFIDENCE = 72;
|
||||
export const MIN_CANDIDATE_SCORE = 40;
|
||||
export const MAX_CANDIDATES = 6;
|
||||
export function normalize(value: string): string {
|
||||
return value
|
||||
.normalize('NFKD')
|
||||
.replace(/\p{M}/gu, '')
|
||||
.toLowerCase()
|
||||
.replace(/&/g, ' and ')
|
||||
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
export function cleanTitle(value: string): string {
|
||||
return normalize(
|
||||
value
|
||||
.replace(
|
||||
/\s*[([](?:feat\.?|ft\.?|featuring|[^)\]]*(?:remaster|radio edit|album version|soundtrack|from the))[^)\]]*[)\]]/gi,
|
||||
''
|
||||
)
|
||||
.replace(/\s+(?:feat\.?|ft\.?|featuring)\s+.*$/i, '')
|
||||
.replace(/\s*[-–—]\s*(?:\d{4}\s*)?(?:remaster\w*|radio edit|album version|from\s+).*$/i, '')
|
||||
);
|
||||
}
|
||||
function similarity(a: string, b: string): number {
|
||||
if (!a || !b) return 0;
|
||||
if (a === b) return 1;
|
||||
const left = new Set(a.split(' ')),
|
||||
right = new Set(b.split(' '));
|
||||
const overlap = [...left].filter((token) => right.has(token)).length;
|
||||
return Math.max(
|
||||
(2 * overlap) / (left.size + right.size),
|
||||
a.length >= 5 && b.length >= 5 && (a.includes(b) || b.includes(a)) ? 0.8 : 0
|
||||
);
|
||||
}
|
||||
function versions(value: string): string {
|
||||
const normalized = normalize(value);
|
||||
return ['remix', 'live', 'acoustic', 'instrumental', 'sped up', 'nightcore']
|
||||
.filter((word) => normalized.includes(word))
|
||||
.join(' ');
|
||||
}
|
||||
export function scoreBeatmapset(track: SpotifyTrack, map: OsuBeatmapset): ScoredBeatmapset {
|
||||
const title = Math.max(
|
||||
...[map.title, map.title_unicode ?? ''].map((v) =>
|
||||
similarity(cleanTitle(track.name), cleanTitle(v))
|
||||
)
|
||||
);
|
||||
const artistNames = [
|
||||
...track.artists.map((a) => a.name),
|
||||
track.artists.map((a) => a.name).join(' and ')
|
||||
];
|
||||
const artist = Math.max(
|
||||
0,
|
||||
...artistNames.flatMap((a) =>
|
||||
[map.artist, map.artist_unicode ?? ''].map((b) => similarity(normalize(a), normalize(b)))
|
||||
)
|
||||
);
|
||||
const mismatch = versions(track.name) !== versions(map.title + ' ' + (map.title_unicode ?? ''));
|
||||
let score = Math.round(title * 65 + artist * 35 - (mismatch ? 24 : 0));
|
||||
if (artist < 0.3) score = Math.min(score, MEDIUM_CONFIDENCE - 1);
|
||||
score = Math.max(0, score);
|
||||
const reasons = [
|
||||
title === 1 ? 'title exact' : 'title similar',
|
||||
artist === 1 ? 'artist exact' : artist >= 0.3 ? 'artist similar' : 'artist differs'
|
||||
];
|
||||
if (mismatch) reasons.push('version differs');
|
||||
return {
|
||||
...map,
|
||||
score,
|
||||
confidence: score >= HIGH_CONFIDENCE ? 'high' : score >= MEDIUM_CONFIDENCE ? 'medium' : 'low',
|
||||
reasons
|
||||
};
|
||||
}
|
||||
export function rankCandidates(track: SpotifyTrack, maps: OsuBeatmapset[]): ScoredBeatmapset[] {
|
||||
const merged = new Map<number, ScoredBeatmapset>();
|
||||
for (const map of maps) {
|
||||
const scored = scoreBeatmapset(track, map);
|
||||
if (scored.score > (merged.get(map.id)?.score ?? -1)) merged.set(map.id, scored);
|
||||
}
|
||||
return [...merged.values()]
|
||||
.filter((m) => m.score >= MIN_CANDIDATE_SCORE)
|
||||
.sort((a, b) => b.score - a.score || a.id - b.id)
|
||||
.slice(0, MAX_CANDIDATES);
|
||||
}
|
||||
34
src/lib/map-finder/types.ts
Normal file
34
src/lib/map-finder/types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export type SpotifyTrack = {
|
||||
id: string;
|
||||
name: string;
|
||||
artists: { name: string }[];
|
||||
album: { name: string; images: { url: string }[] };
|
||||
};
|
||||
export type SpotifyCollection = {
|
||||
kind: 'playlist' | 'album';
|
||||
id: string;
|
||||
name: string;
|
||||
owner: string;
|
||||
image?: string;
|
||||
};
|
||||
export type OsuBeatmapset = {
|
||||
id: number;
|
||||
artist: string;
|
||||
title: string;
|
||||
artist_unicode?: string;
|
||||
title_unicode?: string;
|
||||
covers?: { card?: string; 'card@2x'?: string };
|
||||
status?: string;
|
||||
beatmaps?: { id: number }[];
|
||||
};
|
||||
export type ScoredBeatmapset = OsuBeatmapset & {
|
||||
score: number;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
reasons: string[];
|
||||
};
|
||||
export type TrackMatchResult = {
|
||||
track: SpotifyTrack;
|
||||
matches: ScoredBeatmapset[];
|
||||
status: 'matched' | 'no_match' | 'error';
|
||||
error?: string;
|
||||
};
|
||||
12
src/lib/relative-time.test.ts
Normal file
12
src/lib/relative-time.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { relativeTime } from './relative-time';
|
||||
|
||||
describe('relativeTime', () => {
|
||||
it('uses readable singular and plural dates', () => {
|
||||
const now = Date.UTC(2026, 8, 23);
|
||||
expect(relativeTime(new Date(now - 1000), now)).toBe('just now');
|
||||
expect(relativeTime(new Date(now - 1000 * 30), now)).toBe('30 seconds ago');
|
||||
expect(relativeTime(new Date(now - 1000 * 60 * 60), now)).toBe('an hour ago');
|
||||
expect(relativeTime(new Date(now - 1000 * 60 * 60 * 24 * 365), now)).toBe('a year ago');
|
||||
});
|
||||
});
|
||||
26
src/lib/relative-time.ts
Normal file
26
src/lib/relative-time.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export function relativeTime(date: Date | string, now: number) {
|
||||
const elapsed = Math.max(0, Math.floor((now - new Date(date).getTime()) / 1000));
|
||||
if (elapsed < 5) return 'just now';
|
||||
const units: [number, string][] = [
|
||||
[31_536_000, 'year'],
|
||||
[2_592_000, 'month'],
|
||||
[604_800, 'week'],
|
||||
[86_400, 'day'],
|
||||
[3_600, 'hour'],
|
||||
[60, 'minute'],
|
||||
[1, 'second']
|
||||
];
|
||||
for (const [seconds, unit] of units) {
|
||||
if (elapsed >= seconds) {
|
||||
const count = Math.floor(elapsed / seconds);
|
||||
return `${count === 1 ? (unit === 'hour' ? 'an' : 'a') : count} ${unit}${count === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
}
|
||||
return 'just now';
|
||||
}
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('en', { dateStyle: 'long', timeZone: 'UTC' });
|
||||
|
||||
export function calendarDate(date: Date | string) {
|
||||
return dateFormatter.format(new Date(date));
|
||||
}
|
||||
54
src/lib/seo.test.ts
Normal file
54
src/lib/seo.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getSeo } from './seo';
|
||||
|
||||
describe('public page metadata', () => {
|
||||
it('gives the homepage a descriptive title and production canonical URL', () => {
|
||||
const seo = getSeo('/');
|
||||
expect(seo.title).toBe('Projects, lists and writing | Stan Runge');
|
||||
expect(seo.url).toBe('https://stanrunge.dev/');
|
||||
expect(seo.indexable).toBe(true);
|
||||
expect(seo.image).toBe('https://stanrunge.dev/moon.jpg');
|
||||
});
|
||||
|
||||
it('uses article content and its latest edit time', () => {
|
||||
const article = getSeo('/blog/my-post', {
|
||||
blogArticle: {
|
||||
title: 'My post',
|
||||
content: 'A **specific** article about lists.',
|
||||
updatedAt: '2026-09-02T00:00:00.000Z'
|
||||
}
|
||||
});
|
||||
expect(article.title).toBe('My post | Stan Runge');
|
||||
expect(article.description).toBe('A specific article about lists.');
|
||||
expect(article.type).toBe('article');
|
||||
expect(article.modifiedAt).toBe('2026-09-02T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('describes a list using its contents', () => {
|
||||
const seo = getSeo('/lists/vinyls-to-collect', {
|
||||
list: {
|
||||
name: 'Vinyls to collect',
|
||||
isProgress: true,
|
||||
items: [{ name: 'Album one' }, { name: 'Album two' }]
|
||||
}
|
||||
});
|
||||
expect(seo.title).toBe('Vinyls to collect | Stan Runge');
|
||||
expect(seo.description).toContain('Album one, Album two');
|
||||
expect(seo.indexable).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps private and staging pages out of search results', () => {
|
||||
expect(getSeo('/login').indexable).toBe(false);
|
||||
expect(getSeo('/projects/animaldex/register').indexable).toBe(false);
|
||||
expect(
|
||||
getSeo('/blog/my-post/edit', {
|
||||
blogArticle: {
|
||||
title: 'My post',
|
||||
content: '',
|
||||
updatedAt: ''
|
||||
}
|
||||
}).indexable
|
||||
).toBe(false);
|
||||
expect(getSeo('/projects/catch-em-all', {}, 'STAGING').indexable).toBe(false);
|
||||
});
|
||||
});
|
||||
118
src/lib/seo.ts
Normal file
118
src/lib/seo.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { formatTitle, getTitle, type SiteEnvironment } from './title';
|
||||
|
||||
export const SITE_ORIGIN = 'https://stanrunge.dev';
|
||||
|
||||
type Article = {
|
||||
title: string;
|
||||
content: string;
|
||||
updatedAt: Date | string;
|
||||
};
|
||||
type List = { name: string; isProgress: boolean; items: { name: string }[] };
|
||||
type Race = { short: string; distance: number; discipline: string };
|
||||
type PageContent = { blogArticle?: Article; list?: List; race?: Race };
|
||||
|
||||
type Seo = {
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
indexable: boolean;
|
||||
type: 'website' | 'article';
|
||||
image: string;
|
||||
imageAlt: string;
|
||||
modifiedAt?: string;
|
||||
};
|
||||
|
||||
const publicPages: Record<string, { title: string; description: string }> = {
|
||||
'/': {
|
||||
title: 'Projects, lists and writing',
|
||||
description: 'Stan Runge’s projects, lists, writing and experience.'
|
||||
},
|
||||
'/projects': {
|
||||
title: 'Projects',
|
||||
description: 'Software projects by Stan Runge, including Catchemall, Animaldex and Derby Lens.'
|
||||
},
|
||||
'/blog': { title: 'Blog', description: 'Articles and notes by Stan Runge.' },
|
||||
'/lists': { title: 'Lists', description: 'Lists collected and maintained by Stan Runge.' },
|
||||
'/projects/catch-em-all': {
|
||||
title: 'Catchemall',
|
||||
description: 'Track which Pokémon you have caught across 53 games and 1,025 Pokémon.'
|
||||
},
|
||||
'/projects/animaldex': {
|
||||
title: 'Animaldex',
|
||||
description: 'Find and add neighborhood animals on a map.'
|
||||
},
|
||||
'/projects/derby': {
|
||||
title: 'Derby Lens',
|
||||
description: 'Estimate horse racing odds and compare them with market prices.'
|
||||
},
|
||||
'/projects/spotify-beatmap-finder': {
|
||||
title: 'osu! map finder',
|
||||
description: 'Find osu! beatmaps for Spotify songs, albums and playlists.'
|
||||
}
|
||||
};
|
||||
|
||||
function excerpt(markdown: string): string {
|
||||
return markdown
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/[`*_>#~|]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 160);
|
||||
}
|
||||
|
||||
function date(value: Date | string): string | undefined {
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
|
||||
}
|
||||
|
||||
export function getSeo(
|
||||
pathname: string,
|
||||
content: PageContent = {},
|
||||
environment: SiteEnvironment = null
|
||||
): Seo {
|
||||
const path = pathname.replace(/\/$/, '') || '/';
|
||||
const url = new URL(path, SITE_ORIGIN).href;
|
||||
let page = publicPages[path];
|
||||
let type: Seo['type'] = 'website';
|
||||
let modifiedAt: string | undefined;
|
||||
|
||||
if (/^\/blog\/[^/]+$/.test(path) && content.blogArticle) {
|
||||
const article = content.blogArticle;
|
||||
page = {
|
||||
title: article.title,
|
||||
description: excerpt(article.content) || `Read ${article.title} by Stan Runge.`
|
||||
};
|
||||
type = 'article';
|
||||
modifiedAt = date(article.updatedAt);
|
||||
} else if (/^\/blog\/[^/]+\/edit$/.test(path) && content.blogArticle) {
|
||||
page = { title: `Editing ${content.blogArticle.title}`, description: 'Edit a blog article.' };
|
||||
} else if (/^\/lists\/[^/]+$/.test(path) && content.list) {
|
||||
const list = content.list;
|
||||
const names = list.items.slice(0, 3).map((item) => item.name);
|
||||
page = {
|
||||
title: list.name,
|
||||
description: names.length
|
||||
? `${list.name}: ${names.join(', ')}${list.items.length > names.length ? ', and more' : ''}.`
|
||||
: `${list.name}, a ${list.isProgress ? 'progress ' : ''}list by Stan Runge.`
|
||||
};
|
||||
} else if (/^\/projects\/derby\/[^/]+$/.test(path) && content.race) {
|
||||
const race = content.race;
|
||||
page = {
|
||||
title: `${race.short} · Derby Lens`,
|
||||
description: `Estimate odds for the ${race.short}, a ${race.distance} m ${race.discipline} race.`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: page ? formatTitle(page.title, environment) : getTitle(path, environment),
|
||||
description: page?.description ?? 'Stan Runge’s personal website.',
|
||||
url,
|
||||
indexable: !!page && !environment && !/^\/blog\/[^/]+\/edit$/.test(path),
|
||||
type,
|
||||
image: `${SITE_ORIGIN}/moon.jpg`,
|
||||
imageAlt: 'Moon icon for Stan Runge’s website',
|
||||
modifiedAt
|
||||
};
|
||||
}
|
||||
@@ -1,35 +1,88 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { building, dev } from '$app/environment';
|
||||
import { betterAuth } from 'better-auth/minimal';
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||
import { genericOAuth } from 'better-auth/plugins';
|
||||
import { APIError, createAuthMiddleware } from 'better-auth/api';
|
||||
import { sveltekitCookies } from 'better-auth/svelte-kit';
|
||||
import { getRequestEvent } from '$app/server';
|
||||
import { db } from '$lib/server/db';
|
||||
import * as schema from '$lib/server/db/schema';
|
||||
import { error, type RequestEvent } from '@sveltejs/kit';
|
||||
import { osuProfile } from './osu-profile';
|
||||
export { isAdmin, requireAdmin, requireUser } from './authorization';
|
||||
|
||||
if (!building && (!env.BETTER_AUTH_SECRET || env.BETTER_AUTH_SECRET.length < 32)) {
|
||||
throw new Error('Set BETTER_AUTH_SECRET to a random secret of at least 32 characters');
|
||||
}
|
||||
if (!building && !dev && !env.BETTER_AUTH_URL && !env.ORIGIN) {
|
||||
throw new Error('Set BETTER_AUTH_URL to the public site origin');
|
||||
}
|
||||
export const authProviders = {
|
||||
google: !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET),
|
||||
osu: !!(env.OSU_CLIENT_ID && env.OSU_CLIENT_SECRET)
|
||||
};
|
||||
export const auth = betterAuth({
|
||||
baseURL: env.ORIGIN ?? 'http://localhost:3000',
|
||||
secret: env.BETTER_AUTH_SECRET ?? 'build-time-placeholder',
|
||||
baseURL: env.BETTER_AUTH_URL || env.ORIGIN || 'http://localhost:5173',
|
||||
secret: env.BETTER_AUTH_SECRET || 'build-only-placeholder-not-a-runtime-secret',
|
||||
database: drizzleAdapter(db, { provider: 'pg', schema }),
|
||||
emailAndPassword: { enabled: true },
|
||||
socialProviders: {
|
||||
github: {
|
||||
clientId: env.GITHUB_CLIENT_ID,
|
||||
clientSecret: env.GITHUB_CLIENT_SECRET
|
||||
emailAndPassword: { enabled: true, minPasswordLength: 12, maxPasswordLength: 128 },
|
||||
user: {
|
||||
additionalFields: {
|
||||
role: { type: 'string', required: false, defaultValue: 'user', input: false }
|
||||
}
|
||||
},
|
||||
account: {
|
||||
accountLinking: {
|
||||
enabled: true,
|
||||
disableImplicitLinking: true,
|
||||
allowDifferentEmails: true,
|
||||
trustedProviders: ['osu']
|
||||
}
|
||||
},
|
||||
rateLimit: { enabled: true },
|
||||
socialProviders: authProviders.google
|
||||
? {
|
||||
google: { clientId: env.GOOGLE_CLIENT_ID!, clientSecret: env.GOOGLE_CLIENT_SECRET! }
|
||||
}
|
||||
: {},
|
||||
hooks: {
|
||||
before: createAuthMiddleware(async (ctx) => {
|
||||
// osu! has no email scope. Reserve its internal identifiers against credential signup.
|
||||
if (
|
||||
ctx.path === '/sign-up/email' &&
|
||||
typeof ctx.body?.email === 'string' &&
|
||||
ctx.body.email.toLowerCase().endsWith('.invalid')
|
||||
) {
|
||||
throw new APIError('BAD_REQUEST', { message: 'Please use a deliverable email address' });
|
||||
}
|
||||
})
|
||||
},
|
||||
plugins: [
|
||||
sveltekitCookies(getRequestEvent) // make sure this is the last plugin in the array
|
||||
genericOAuth({
|
||||
config: authProviders.osu
|
||||
? [
|
||||
{
|
||||
providerId: 'osu',
|
||||
clientId: env.OSU_CLIENT_ID!,
|
||||
clientSecret: env.OSU_CLIENT_SECRET!,
|
||||
authorizationUrl: 'https://osu.ppy.sh/oauth/authorize',
|
||||
tokenUrl: 'https://osu.ppy.sh/oauth/token',
|
||||
scopes: ['identify'],
|
||||
getUserInfo: async (tokens) => {
|
||||
const response = await fetch('https://osu.ppy.sh/api/v2/me', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.accessToken}`,
|
||||
Accept: 'application/json'
|
||||
},
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
if (!response.ok) throw new Error('Could not load osu! profile');
|
||||
return osuProfile(await response.json());
|
||||
}
|
||||
}
|
||||
]
|
||||
: []
|
||||
}),
|
||||
sveltekitCookies(getRequestEvent)
|
||||
]
|
||||
});
|
||||
|
||||
export function isAdmin(event: RequestEvent): boolean {
|
||||
const token = event.cookies.get('admin_token');
|
||||
return !!token && token === env.ADMIN_SECRET;
|
||||
}
|
||||
|
||||
export function requireAdmin(event: RequestEvent): void {
|
||||
if (!isAdmin(event)) {
|
||||
error(401, { message: 'Unauthorized' });
|
||||
}
|
||||
}
|
||||
|
||||
22
src/lib/server/authorization.test.ts
Normal file
22
src/lib/server/authorization.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isAdmin, requireAdmin, requireUser } from './authorization';
|
||||
function event(role?: string) {
|
||||
return { locals: { user: role ? { id: 'alice', role } : null, session: null } } as {
|
||||
locals: App.Locals;
|
||||
};
|
||||
}
|
||||
describe('central authorization', () => {
|
||||
it('rejects anonymous requests even with a legacy admin cookie', () => {
|
||||
const anonymous = { ...event(), cookies: { get: () => 'old-admin-secret' } };
|
||||
expect(isAdmin(anonymous)).toBe(false);
|
||||
expect(() => requireAdmin(anonymous)).toThrow(expect.objectContaining({ status: 401 }));
|
||||
});
|
||||
it('allows normal users to own data but not administer the site', () => {
|
||||
expect(requireUser(event('user')).id).toBe('alice');
|
||||
expect(() => requireAdmin(event('user'))).toThrow(expect.objectContaining({ status: 403 }));
|
||||
});
|
||||
it('allows only the explicit admin role', () => {
|
||||
expect(requireAdmin(event('admin')).id).toBe('alice');
|
||||
expect(isAdmin(event('superadmin'))).toBe(false);
|
||||
});
|
||||
});
|
||||
18
src/lib/server/authorization.ts
Normal file
18
src/lib/server/authorization.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
type AuthEvent = { locals: App.Locals };
|
||||
|
||||
export function requireUser(event: AuthEvent) {
|
||||
if (!event.locals.user) error(401, 'Log in to continue');
|
||||
return event.locals.user;
|
||||
}
|
||||
|
||||
export function isAdmin(event: AuthEvent): boolean {
|
||||
return event.locals.user?.role === 'admin';
|
||||
}
|
||||
|
||||
export function requireAdmin(event: AuthEvent) {
|
||||
const user = requireUser(event);
|
||||
if (!isAdmin(event)) error(403, 'Administrator access required');
|
||||
return user;
|
||||
}
|
||||
35
src/lib/server/catalog.test.ts
Normal file
35
src/lib/server/catalog.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
const catalog = JSON.parse(readFileSync('data/pokemon-catalog.json', 'utf8')) as {
|
||||
pokemon: { number: number; name: string; hp: number; type1: string }[];
|
||||
games: { sourceId: number; name: string }[];
|
||||
entries: [number, number, string][];
|
||||
};
|
||||
describe('Pokémon catalog snapshot', () => {
|
||||
it('contains the national species catalog and the latest source versions', () => {
|
||||
expect(catalog.pokemon).toHaveLength(1025);
|
||||
expect(catalog.games.length).toBeGreaterThanOrEqual(53);
|
||||
expect(catalog.pokemon.find((p) => p.number === 25)).toMatchObject({
|
||||
name: 'Pikachu',
|
||||
type1: 'Electric',
|
||||
hp: 35
|
||||
});
|
||||
expect(catalog.games.some((g) => g.name.includes('Z-A'))).toBe(true);
|
||||
});
|
||||
it('has unique species, games, and valid entry references', () => {
|
||||
const pokemon = new Set(catalog.pokemon.map((p) => p.number));
|
||||
const games = new Set(catalog.games.map((g) => g.sourceId));
|
||||
expect(pokemon.size).toBe(catalog.pokemon.length);
|
||||
expect(games.size).toBe(catalog.games.length);
|
||||
expect(new Set(catalog.entries.map(([p, g]) => `${p}/${g}`)).size).toBe(catalog.entries.length);
|
||||
expect(
|
||||
catalog.entries.every(
|
||||
([p, g, a]) => pokemon.has(p) && games.has(g) && ['catchable', 'unknown'].includes(a)
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
it('does not invent exclusive availability from missing encounters', () => {
|
||||
expect(catalog.entries.some(([, , a]) => a === 'catchable')).toBe(true);
|
||||
expect(catalog.entries.some(([, , a]) => a === 'unknown')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,27 @@ import { defineRelations } from 'drizzle-orm';
|
||||
import * as schema from './schema';
|
||||
|
||||
export const relations = defineRelations(schema, (r) => ({
|
||||
// ── Auth ──────────────────────────────────────
|
||||
user: {
|
||||
sessions: r.many.session(),
|
||||
accounts: r.many.account(),
|
||||
entries: r.many.userEntries(),
|
||||
games: r.many.userGames(),
|
||||
listCompletions: r.many.listItemCompletions()
|
||||
},
|
||||
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
|
||||
})
|
||||
},
|
||||
|
||||
// ── Progress ──────────────────────────────────
|
||||
tasks: {
|
||||
tasksToTopics: r.many.tasksToTopics()
|
||||
@@ -21,61 +42,41 @@ export const relations = defineRelations(schema, (r) => ({
|
||||
},
|
||||
|
||||
// ── CatchEmAll ────────────────────────────────
|
||||
game: {
|
||||
entries: r.many.pokemonGameEntry()
|
||||
games: {
|
||||
entries: r.many.pokemonGameEntries()
|
||||
},
|
||||
pokemon: {
|
||||
entry: r.many.pokemonGameEntry()
|
||||
entry: r.many.pokemonGameEntries()
|
||||
},
|
||||
pokemonGameEntry: {
|
||||
game: r.one.game({
|
||||
from: r.pokemonGameEntry.gameId,
|
||||
to: r.game.id
|
||||
pokemonGameEntries: {
|
||||
game: r.one.games({
|
||||
from: r.pokemonGameEntries.gameId,
|
||||
to: r.games.id
|
||||
}),
|
||||
pokemon: r.one.pokemon({
|
||||
from: r.pokemonGameEntry.pokemonId,
|
||||
from: r.pokemonGameEntries.pokemonId,
|
||||
to: r.pokemon.id
|
||||
}),
|
||||
userEntries: r.many.userEntry()
|
||||
userEntries: r.many.userEntries()
|
||||
},
|
||||
userEntry: {
|
||||
pokemonGameEntry: r.one.pokemonGameEntry({
|
||||
from: r.userEntry.pokemonGameEntryId,
|
||||
to: r.pokemonGameEntry.id
|
||||
userEntries: {
|
||||
pokemonGameEntry: r.one.pokemonGameEntries({
|
||||
from: r.userEntries.pokemonGameEntryId,
|
||||
to: r.pokemonGameEntries.id
|
||||
}),
|
||||
user: r.one.user({
|
||||
from: r.userEntry.userId,
|
||||
from: r.userEntries.userId,
|
||||
to: r.user.id
|
||||
})
|
||||
},
|
||||
userGame: {
|
||||
userGames: {
|
||||
user: r.one.user({
|
||||
from: r.userGame.userId,
|
||||
from: r.userGames.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
|
||||
game: r.one.games({
|
||||
from: r.userGames.gameId,
|
||||
to: r.games.id
|
||||
})
|
||||
},
|
||||
|
||||
@@ -95,5 +96,27 @@ export const relations = defineRelations(schema, (r) => ({
|
||||
from: r.photos.sightingId,
|
||||
to: r.sightings.id
|
||||
})
|
||||
},
|
||||
|
||||
// ── Lists ─────────────────────────────────────
|
||||
lists: {
|
||||
items: r.many.listItems()
|
||||
},
|
||||
listItems: {
|
||||
list: r.one.lists({
|
||||
from: r.listItems.listId,
|
||||
to: r.lists.id
|
||||
}),
|
||||
completions: r.many.listItemCompletions()
|
||||
},
|
||||
listItemCompletions: {
|
||||
item: r.one.listItems({
|
||||
from: r.listItemCompletions.itemId,
|
||||
to: r.listItems.id
|
||||
}),
|
||||
user: r.one.user({
|
||||
from: r.listItemCompletions.userId,
|
||||
to: r.user.id
|
||||
})
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
import {
|
||||
doublePrecision,
|
||||
index,
|
||||
integer,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
real,
|
||||
serial,
|
||||
text,
|
||||
timestamp,
|
||||
unique,
|
||||
uuid,
|
||||
varchar
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
import { user } from './auth.schema';
|
||||
import { availabilityValues } from '../../availability';
|
||||
|
||||
export const tasks = pgTable('tasks', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 256 }),
|
||||
currentPoints: integer('current_points').default(0),
|
||||
totalPoints: integer('total_points').default(1),
|
||||
notes: varchar('notes', { length: 8192 }),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow()
|
||||
});
|
||||
|
||||
export const topics = pgTable('topics', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 256 }),
|
||||
emoji: varchar('emoji', { length: 256 }),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow()
|
||||
});
|
||||
|
||||
export const tasksToTopics = pgTable('tasks_to_topics', {
|
||||
taskId: integer('task_id')
|
||||
.notNull()
|
||||
.references(() => tasks.id),
|
||||
topicId: integer('topic_id')
|
||||
.notNull()
|
||||
.references(() => topics.id)
|
||||
});
|
||||
|
||||
export const universities = pgTable('universities', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 256 }),
|
||||
progress: integer('progress').default(0),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow()
|
||||
});
|
||||
|
||||
export const certificates = pgTable('certificates', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 256 }),
|
||||
progress: integer('progress').default(0),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow()
|
||||
});
|
||||
|
||||
export const publications = pgTable('publications', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 256 }),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow()
|
||||
});
|
||||
|
||||
// Legacy ^
|
||||
// New
|
||||
|
||||
export const blogArticleStatus = pgEnum('blog_article_status', ['draft', 'published']);
|
||||
|
||||
export const blogArticles = pgTable(
|
||||
'blog_articles',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
title: varchar('title', { length: 256 }).notNull(),
|
||||
slug: varchar('slug', { length: 256 }).notNull(),
|
||||
content: text('content').default(''),
|
||||
status: blogArticleStatus('status').notNull().default('draft'),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow()
|
||||
},
|
||||
(table) => [unique().on(table.slug)]
|
||||
);
|
||||
|
||||
// CatchEmAll
|
||||
|
||||
export const availability = pgEnum('availability', availabilityValues);
|
||||
|
||||
export const pokemon = pgTable('pokemon', {
|
||||
id: serial('id').primaryKey(),
|
||||
number: integer('number').notNull(),
|
||||
generation: integer('generation').notNull().default(1),
|
||||
name: varchar('name', { length: 256 }).notNull(),
|
||||
type1: varchar('type1', { length: 256 }).notNull(),
|
||||
type2: varchar('type2', { length: 256 }),
|
||||
hp: integer('hp').notNull().default(0),
|
||||
attack: integer('attack').notNull().default(0),
|
||||
defense: integer('defense').notNull().default(0),
|
||||
specialAttack: integer('special_attack').notNull().default(0),
|
||||
specialDefense: integer('special_defense').notNull().default(0),
|
||||
speed: integer('speed').notNull().default(0)
|
||||
});
|
||||
|
||||
export const game = pgTable('game', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 256 }).notNull(),
|
||||
generation: integer('generation').notNull().default(1)
|
||||
});
|
||||
|
||||
export const pokemonGameEntry = pgTable(
|
||||
'pokemon_game_entry',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
pokemonId: integer('pokemon_id')
|
||||
.notNull()
|
||||
.references(() => pokemon.id, { onDelete: 'cascade' }),
|
||||
gameId: integer('game_id')
|
||||
.notNull()
|
||||
.references(() => game.id, { onDelete: 'cascade' }),
|
||||
availability: availability('availability').notNull().default('catchable'),
|
||||
// optional extra detail for the tooltip, e.g. "trade with a Blue version owner"
|
||||
note: varchar('note', { length: 256 })
|
||||
},
|
||||
(table) => [unique().on(table.pokemonId, table.gameId), index().on(table.gameId)]
|
||||
);
|
||||
|
||||
export const userEntry = pgTable(
|
||||
'user_entry',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
pokemonGameEntryId: integer('pokemon_game_entry_id')
|
||||
.notNull()
|
||||
.references(() => pokemonGameEntry.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' })
|
||||
},
|
||||
(table) => [unique().on(table.pokemonGameEntryId, table.userId), index().on(table.userId)]
|
||||
);
|
||||
|
||||
// which games a user has chosen to track ("rules")
|
||||
export const userGame = pgTable(
|
||||
'user_game',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
gameId: integer('game_id')
|
||||
.notNull()
|
||||
.references(() => game.id, { onDelete: 'cascade' })
|
||||
},
|
||||
(table) => [unique().on(table.userId, table.gameId), index().on(table.userId)]
|
||||
);
|
||||
|
||||
// Animaldex
|
||||
|
||||
export const animals = pgTable('animals', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
|
||||
species: text('species').notNull(),
|
||||
breed: text('breed'),
|
||||
animalName: text('animal_name'),
|
||||
description: text('description'),
|
||||
|
||||
// AI detection stored on the animal (from the first/primary photo)
|
||||
aiBreedSuggestion: text('ai_breed_suggestion'),
|
||||
aiBreedConfidence: real('ai_breed_confidence'),
|
||||
|
||||
// Moderation — the animal entity is moderated once.
|
||||
// Accepted = visible on map (as long as it has accepted sightings too).
|
||||
// Denied animals are hidden but not deleted.
|
||||
submittedAt: timestamp('submitted_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
acceptedAt: timestamp('accepted_at', { withTimezone: true }),
|
||||
deniedAt: timestamp('denied_at', { withTimezone: true })
|
||||
});
|
||||
|
||||
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(),
|
||||
|
||||
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';
|
||||
69
src/lib/server/db/schema/animaldex.ts
Normal file
69
src/lib/server/db/schema/animaldex.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { doublePrecision, integer, pgTable, text } from 'drizzle-orm/pg-core';
|
||||
import { id, timestamps, timestamptz } from './helpers';
|
||||
|
||||
export const animals = pgTable('animals', {
|
||||
id: id(),
|
||||
|
||||
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: doublePrecision('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: timestamptz('submitted_at').notNull().defaultNow(),
|
||||
acceptedAt: timestamptz('accepted_at'),
|
||||
deniedAt: timestamptz('denied_at'),
|
||||
...timestamps
|
||||
});
|
||||
|
||||
export const sightings = pgTable('sightings', {
|
||||
id: id(),
|
||||
animalId: integer('animal_id')
|
||||
.notNull()
|
||||
.references(() => animals.id, { onDelete: 'cascade' }),
|
||||
|
||||
reporterName: text('reporter_name'),
|
||||
|
||||
seenAt: timestamptz('seen_at').notNull(),
|
||||
lat: doublePrecision('lat').notNull(),
|
||||
lng: doublePrecision('lng').notNull(),
|
||||
|
||||
submittedAt: timestamptz('submitted_at').notNull().defaultNow(),
|
||||
acceptedAt: timestamptz('accepted_at'),
|
||||
deniedAt: timestamptz('denied_at'),
|
||||
...timestamps
|
||||
});
|
||||
|
||||
export const photos = pgTable('photos', {
|
||||
id: id(),
|
||||
sightingId: integer('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: timestamptz('uploaded_at').notNull().defaultNow(),
|
||||
...timestamps
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
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';
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { pgTable, text, timestamp, boolean, index } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, text, timestamp, boolean, index, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const user = pgTable('user', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
role: text('role').notNull().default('user'),
|
||||
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')
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull()
|
||||
@@ -17,10 +18,10 @@ export const session = pgTable(
|
||||
'session',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
expiresAt: timestamp('expires_at').notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
token: text('token').notNull().unique(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
@@ -37,6 +38,7 @@ export const account = pgTable(
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
accountId: text('account_id').notNull(),
|
||||
issuer: text('issuer').notNull(),
|
||||
providerId: text('provider_id').notNull(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
@@ -44,16 +46,19 @@ export const account = pgTable(
|
||||
accessToken: text('access_token'),
|
||||
refreshToken: text('refresh_token'),
|
||||
idToken: text('id_token'),
|
||||
accessTokenExpiresAt: timestamp('access_token_expires_at'),
|
||||
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
|
||||
accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }),
|
||||
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }),
|
||||
scope: text('scope'),
|
||||
password: text('password'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull()
|
||||
},
|
||||
(table) => [index('account_userId_idx').on(table.userId)]
|
||||
(table) => [
|
||||
index('account_userId_idx').on(table.userId),
|
||||
uniqueIndex('account_issuer_account_id_unique').on(table.issuer, table.accountId)
|
||||
]
|
||||
);
|
||||
|
||||
export const verification = pgTable(
|
||||
@@ -62,9 +67,9 @@ export const verification = pgTable(
|
||||
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')
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull()
|
||||
22
src/lib/server/db/schema/blog.ts
Normal file
22
src/lib/server/db/schema/blog.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { pgEnum, pgTable, text, unique } from 'drizzle-orm/pg-core';
|
||||
import { id, timestamps } from './helpers';
|
||||
|
||||
export const blogArticleStatus = pgEnum('blog_article_status', ['draft', 'published']);
|
||||
|
||||
export const blogArticles = pgTable(
|
||||
'blog_articles',
|
||||
{
|
||||
id: id(),
|
||||
title: text('title').notNull(),
|
||||
slug: text('slug').notNull(),
|
||||
content: text('content').notNull().default(''),
|
||||
tags: text('tags').array().notNull().default([]),
|
||||
status: blogArticleStatus('status').notNull().default('draft'),
|
||||
...timestamps
|
||||
},
|
||||
(table) => [unique().on(table.slug)]
|
||||
);
|
||||
|
||||
export type BlogArticle = typeof blogArticles.$inferSelect;
|
||||
export type NewBlogArticle = typeof blogArticles.$inferInsert;
|
||||
export type BlogArticleStatus = (typeof blogArticleStatus.enumValues)[number];
|
||||
96
src/lib/server/db/schema/catch-em-all.ts
Normal file
96
src/lib/server/db/schema/catch-em-all.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { index, integer, pgEnum, pgTable, text, unique } from 'drizzle-orm/pg-core';
|
||||
import { availabilityValues } from '../../../availability';
|
||||
import { user } from './auth';
|
||||
import { id, timestamps } from './helpers';
|
||||
|
||||
export const availability = pgEnum('availability', availabilityValues);
|
||||
|
||||
export const pokemon = pgTable('pokemon', {
|
||||
id: id(),
|
||||
number: integer('number').notNull().unique(),
|
||||
generation: integer('generation').notNull().default(1),
|
||||
name: text('name').notNull(),
|
||||
type1: text('type1').notNull(),
|
||||
type2: text('type2'),
|
||||
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),
|
||||
...timestamps
|
||||
});
|
||||
|
||||
export const games = pgTable('games', {
|
||||
id: id(),
|
||||
sourceId: integer('source_id').unique(),
|
||||
name: text('name').notNull(),
|
||||
generation: integer('generation').notNull().default(1),
|
||||
...timestamps
|
||||
});
|
||||
|
||||
export const pokemonGameEntries = pgTable(
|
||||
'pokemon_game_entries',
|
||||
{
|
||||
id: id(),
|
||||
pokemonId: integer('pokemon_id')
|
||||
.notNull()
|
||||
.references(() => pokemon.id, { onDelete: 'cascade' }),
|
||||
gameId: integer('game_id')
|
||||
.notNull()
|
||||
.references(() => games.id, { onDelete: 'cascade' }),
|
||||
availability: availability('availability').notNull().default('catchable'),
|
||||
// optional extra detail for the tooltip, e.g. "trade with a Blue version owner"
|
||||
note: text('note'),
|
||||
...timestamps
|
||||
},
|
||||
(table) => [unique().on(table.pokemonId, table.gameId), index().on(table.gameId)]
|
||||
);
|
||||
|
||||
export const userEntries = pgTable(
|
||||
'user_entries',
|
||||
{
|
||||
id: id(),
|
||||
pokemonGameEntryId: integer('pokemon_game_entry_id')
|
||||
.notNull()
|
||||
.references(() => pokemonGameEntries.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
...timestamps
|
||||
},
|
||||
(table) => [unique().on(table.pokemonGameEntryId, table.userId), index().on(table.userId)]
|
||||
);
|
||||
|
||||
// which games a user has chosen to track ("rules")
|
||||
export const userGames = pgTable(
|
||||
'user_games',
|
||||
{
|
||||
id: id(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
gameId: integer('game_id')
|
||||
.notNull()
|
||||
.references(() => games.id, { onDelete: 'cascade' }),
|
||||
...timestamps
|
||||
},
|
||||
(table) => [unique().on(table.userId, table.gameId), index().on(table.userId)]
|
||||
);
|
||||
|
||||
// A row distinguishes an explicit empty game selection from a new account.
|
||||
export const catchEmAllSettings = pgTable('catch_em_all_settings', {
|
||||
id: id(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.unique()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
...timestamps
|
||||
});
|
||||
|
||||
export type Pokemon = typeof pokemon.$inferSelect;
|
||||
export type Game = typeof games.$inferSelect;
|
||||
export type PokemonGameEntry = typeof pokemonGameEntries.$inferSelect;
|
||||
export type UserEntry = typeof userEntries.$inferSelect;
|
||||
export type UserGame = typeof userGames.$inferSelect;
|
||||
export type Availability = (typeof availability.enumValues)[number];
|
||||
17
src/lib/server/db/schema/helpers.ts
Normal file
17
src/lib/server/db/schema/helpers.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { serial, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
// Shared column patterns so every table is built the same way.
|
||||
// Timestamps are always `timestamptz` — they represent a moment in time, not a
|
||||
// wall-clock reading.
|
||||
|
||||
export const id = () => serial('id').primaryKey();
|
||||
|
||||
export const timestamptz = (name: string) => timestamp(name, { withTimezone: true });
|
||||
|
||||
export const timestamps = {
|
||||
createdAt: timestamptz('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamptz('updated_at')
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
};
|
||||
8
src/lib/server/db/schema/index.ts
Normal file
8
src/lib/server/db/schema/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// One file per domain. Everything is re-exported here so the rest of the app
|
||||
// (and drizzle-kit / relations) only ever imports `$lib/server/db/schema`.
|
||||
export * from './auth';
|
||||
export * from './blog';
|
||||
export * from './progress';
|
||||
export * from './catch-em-all';
|
||||
export * from './animaldex';
|
||||
export * from './lists';
|
||||
46
src/lib/server/db/schema/lists.ts
Normal file
46
src/lib/server/db/schema/lists.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { boolean, index, integer, pgTable, text, unique } from 'drizzle-orm/pg-core';
|
||||
import { id, timestamps } from './helpers';
|
||||
import { user } from './auth';
|
||||
|
||||
export const lists = pgTable('lists', {
|
||||
id: id(),
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull().unique(),
|
||||
isProgress: boolean('is_progress').notNull().default(false),
|
||||
...timestamps
|
||||
});
|
||||
|
||||
export const listItems = pgTable(
|
||||
'list_items',
|
||||
{
|
||||
id: id(),
|
||||
listId: integer('list_id')
|
||||
.notNull()
|
||||
.references(() => lists.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
pictureUrl: text('picture_url'),
|
||||
description: text('description'),
|
||||
...timestamps
|
||||
},
|
||||
(table) => [index().on(table.listId)]
|
||||
);
|
||||
|
||||
export const listItemCompletions = pgTable(
|
||||
'list_item_completions',
|
||||
{
|
||||
id: id(),
|
||||
itemId: integer('item_id')
|
||||
.notNull()
|
||||
.references(() => listItems.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
...timestamps
|
||||
},
|
||||
(table) => [unique().on(table.itemId, table.userId), index().on(table.userId)]
|
||||
);
|
||||
|
||||
export type List = typeof lists.$inferSelect;
|
||||
export type NewList = typeof lists.$inferInsert;
|
||||
export type ListItem = typeof listItems.$inferSelect;
|
||||
export type NewListItem = typeof listItems.$inferInsert;
|
||||
38
src/lib/server/db/schema/progress.ts
Normal file
38
src/lib/server/db/schema/progress.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { integer, pgTable, text, unique } from 'drizzle-orm/pg-core';
|
||||
import { id, timestamps } from './helpers';
|
||||
|
||||
export const tasks = pgTable('tasks', {
|
||||
id: id(),
|
||||
name: text('name').notNull(),
|
||||
currentPoints: integer('current_points').notNull().default(0),
|
||||
totalPoints: integer('total_points').notNull().default(1),
|
||||
notes: text('notes'),
|
||||
...timestamps
|
||||
});
|
||||
|
||||
export const topics = pgTable('topics', {
|
||||
id: id(),
|
||||
name: text('name').notNull(),
|
||||
emoji: text('emoji'),
|
||||
...timestamps
|
||||
});
|
||||
|
||||
export const tasksToTopics = pgTable(
|
||||
'tasks_to_topics',
|
||||
{
|
||||
id: id(),
|
||||
taskId: integer('task_id')
|
||||
.notNull()
|
||||
.references(() => tasks.id, { onDelete: 'cascade' }),
|
||||
topicId: integer('topic_id')
|
||||
.notNull()
|
||||
.references(() => topics.id, { onDelete: 'cascade' }),
|
||||
...timestamps
|
||||
},
|
||||
(table) => [unique().on(table.taskId, table.topicId)]
|
||||
);
|
||||
|
||||
export type Task = typeof tasks.$inferSelect;
|
||||
export type NewTask = typeof tasks.$inferInsert;
|
||||
export type Topic = typeof topics.$inferSelect;
|
||||
export type NewTopic = typeof topics.$inferInsert;
|
||||
119
src/lib/server/finder-http.ts
Normal file
119
src/lib/server/finder-http.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public provider: string,
|
||||
public status: number,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
export function publicError(error: unknown): string {
|
||||
return error instanceof ApiError
|
||||
? error.message
|
||||
: 'Search could not be completed. Please try again.';
|
||||
}
|
||||
export function logApiError(error: unknown) {
|
||||
console.error(
|
||||
'Map finder request failed',
|
||||
error instanceof ApiError
|
||||
? { provider: error.provider, status: error.status }
|
||||
: { type: 'unexpected' }
|
||||
);
|
||||
}
|
||||
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
const cooldowns = new Map<string, number>();
|
||||
export async function requestJson(
|
||||
fetchFn: typeof fetch,
|
||||
provider: string,
|
||||
url: string,
|
||||
init: RequestInit = {},
|
||||
wait = sleep
|
||||
): Promise<unknown> {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
if ((cooldowns.get(provider) ?? 0) > Date.now())
|
||||
throw new ApiError(provider, 429, `${provider} is rate-limited. Please try again later.`);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchFn(url, { ...init, signal: AbortSignal.timeout(12000) });
|
||||
} catch {
|
||||
if (attempt < 2) {
|
||||
await wait(300 * 2 ** attempt + Math.random() * 150);
|
||||
continue;
|
||||
}
|
||||
throw new ApiError(provider, 503, `${provider} is unavailable. Please try again later.`);
|
||||
}
|
||||
if (response.ok) {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
throw new ApiError(
|
||||
provider,
|
||||
502,
|
||||
`${provider} returned an invalid response. Please try again.`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (response.status === 429 || response.status >= 500) {
|
||||
const retry = response.headers.get('Retry-After');
|
||||
const delay = retry
|
||||
? /^\d+(\.\d+)?$/.test(retry)
|
||||
? Number(retry) * 1000
|
||||
: Math.max(0, Date.parse(retry) - Date.now())
|
||||
: 300 * 2 ** attempt;
|
||||
const boundedDelay = Number.isFinite(delay) ? delay : 1000;
|
||||
if (response.status === 429) cooldowns.set(provider, Date.now() + boundedDelay);
|
||||
if (attempt < 2 && boundedDelay <= 4000) {
|
||||
await wait(boundedDelay + Math.random() * 150);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
throw new ApiError(
|
||||
provider,
|
||||
response.status,
|
||||
response.status === 429
|
||||
? `${provider} is rate-limited. Please try again later.`
|
||||
: `${provider} request failed. Please try again later.`
|
||||
);
|
||||
}
|
||||
throw new ApiError(provider, 503, `${provider} is unavailable.`);
|
||||
}
|
||||
export function createTokenCache() {
|
||||
let token: { value: string; expires: number } | undefined;
|
||||
let pending: Promise<string> | undefined;
|
||||
return async (refresh: () => Promise<unknown>): Promise<string> => {
|
||||
if (token && token.expires > Date.now() + 30000) return token.value;
|
||||
if (!pending)
|
||||
pending = (async () => {
|
||||
const result = (await refresh()) as { access_token?: unknown; expires_in?: unknown };
|
||||
if (
|
||||
typeof result?.access_token !== 'string' ||
|
||||
typeof result.expires_in !== 'number' ||
|
||||
result.expires_in <= 0
|
||||
)
|
||||
throw new ApiError('OAuth', 502, 'API authentication is temporarily unavailable.');
|
||||
token = { value: result.access_token, expires: Date.now() + result.expires_in * 1000 };
|
||||
return token.value;
|
||||
})().finally(() => {
|
||||
pending = undefined;
|
||||
});
|
||||
return pending;
|
||||
};
|
||||
}
|
||||
export async function mapConcurrent<T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
work: (item: T) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
if (!Number.isInteger(limit) || limit < 1) throw new Error('Concurrency must be positive.');
|
||||
const results: R[] = new Array(items.length);
|
||||
let cursor = 0;
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
while (cursor < items.length) {
|
||||
const index = cursor++;
|
||||
results[index] = await work(items[index]);
|
||||
}
|
||||
})
|
||||
);
|
||||
return results;
|
||||
}
|
||||
28
src/lib/server/finder-limit.ts
Normal file
28
src/lib/server/finder-limit.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export const RATE_WINDOW_MS = 10 * 60_000;
|
||||
export const RATE_BUDGET = 12;
|
||||
export function createRateLimiter() {
|
||||
let activeSearches = 0;
|
||||
const records = new Map<string, { expires: number; used: number; active: boolean }>();
|
||||
return (key: string, cost: number, now = Date.now()) => {
|
||||
// Opportunistic cleanup on every action; no timers keep the server alive.
|
||||
for (const [id, entry] of records)
|
||||
if (entry.expires <= now && !entry.active) records.delete(id);
|
||||
let entry = records.get(key);
|
||||
if (!entry) {
|
||||
if (records.size >= 10000) return null;
|
||||
entry = { expires: now + RATE_WINDOW_MS, used: 0, active: false };
|
||||
records.set(key, entry);
|
||||
}
|
||||
if (activeSearches >= 6 || entry.active || entry.used + cost > RATE_BUDGET) return null;
|
||||
entry.used += cost;
|
||||
entry.active = true;
|
||||
activeSearches++;
|
||||
return () => {
|
||||
if (entry.active) {
|
||||
entry.active = false;
|
||||
activeSearches--;
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
export const acquireSearch = createRateLimiter();
|
||||
167
src/lib/server/finder.test.ts
Normal file
167
src/lib/server/finder.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: { SPOTIFY_CLIENT_ID: 'test', SPOTIFY_CLIENT_SECRET: 'test' }
|
||||
}));
|
||||
import { createTokenCache, mapConcurrent, requestJson } from './finder-http';
|
||||
import { createRateLimiter, RATE_WINDOW_MS } from './finder-limit';
|
||||
import { getSpotifyMetadata, playlistTracks, MAX_PLAYLIST_TRACKS } from './spotify';
|
||||
const id = (n: number) => String(n).padStart(22, '0');
|
||||
const track = (n: number) => ({
|
||||
type: 'track',
|
||||
id: id(n),
|
||||
name: `Song ${n}`,
|
||||
artists: [{ name: 'Artist' }],
|
||||
album: { name: 'Album', images: [] }
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
describe('request controls', () => {
|
||||
it('reuses tokens, coalesces refreshes and refreshes near expiry', async () => {
|
||||
vi.useFakeTimers();
|
||||
const cache = createTokenCache();
|
||||
const refresh = vi.fn(async () => ({ access_token: 'token', expires_in: 60 }));
|
||||
expect(await Promise.all([cache(refresh), cache(refresh)])).toEqual(['token', 'token']);
|
||||
await cache(refresh);
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
vi.advanceTimersByTime(31000);
|
||||
await cache(refresh);
|
||||
expect(refresh).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it('bounds concurrency and preserves order', async () => {
|
||||
let active = 0,
|
||||
peak = 0;
|
||||
const result = await mapConcurrent([3, 2, 1, 0], 2, async (n) => {
|
||||
active++;
|
||||
peak = Math.max(peak, active);
|
||||
await new Promise((r) => setTimeout(r, n));
|
||||
active--;
|
||||
return n * 2;
|
||||
});
|
||||
expect(peak).toBe(2);
|
||||
expect(result).toEqual([6, 4, 2, 0]);
|
||||
});
|
||||
it('retries 429 after Retry-After', async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchFn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response('', { status: 429, headers: { 'Retry-After': '1' } }))
|
||||
.mockResolvedValueOnce(Response.json({ ok: true }));
|
||||
const wait = vi.fn(async (ms: number) => {
|
||||
vi.advanceTimersByTime(ms);
|
||||
});
|
||||
expect(await requestJson(fetchFn, 'retry-test', 'https://example.test', {}, wait)).toEqual({
|
||||
ok: true
|
||||
});
|
||||
expect(wait.mock.calls[0][0]).toBeGreaterThanOrEqual(1000);
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it('does not retry 404 or malformed JSON', async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValue(new Response('', { status: 404 }));
|
||||
await expect(requestJson(fetchFn, 'test', 'https://example.test')).rejects.toMatchObject({
|
||||
status: 404
|
||||
});
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
await expect(
|
||||
requestJson(
|
||||
vi.fn().mockResolvedValue(new Response('invalid')),
|
||||
'test',
|
||||
'https://example.test'
|
||||
)
|
||||
).rejects.toMatchObject({ status: 502 });
|
||||
});
|
||||
it('limits server retries and respects long cooldowns without sleeping', async () => {
|
||||
const fetchFn = vi.fn().mockImplementation(async () => new Response('', { status: 503 }));
|
||||
await expect(
|
||||
requestJson(fetchFn, 'server-test', 'https://example.test', {}, async () => {})
|
||||
).rejects.toMatchObject({ status: 503 });
|
||||
expect(fetchFn).toHaveBeenCalledTimes(3);
|
||||
const throttled = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response('', { status: 429, headers: { 'Retry-After': '60' } }));
|
||||
await expect(
|
||||
requestJson(throttled, 'long-retry', 'https://example.test')
|
||||
).rejects.toMatchObject({ status: 429 });
|
||||
expect(throttled).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('limits weighted searches, concurrent requests and expires records', () => {
|
||||
const acquire = createRateLimiter();
|
||||
const release = acquire('ip', 6, 0)!;
|
||||
expect(acquire('ip', 1, 1)).toBeNull();
|
||||
release();
|
||||
acquire('ip', 6, 2)!();
|
||||
expect(acquire('ip', 1, 3)).toBeNull();
|
||||
expect(acquire('ip', 6, RATE_WINDOW_MS + 1)).toBeTypeOf('function');
|
||||
});
|
||||
});
|
||||
describe('playlists', () => {
|
||||
it('skips unusable entries and deduplicates in order', () =>
|
||||
expect(
|
||||
playlistTracks([
|
||||
{ track: track(2) },
|
||||
{ track: null },
|
||||
{ track: { ...track(3), is_local: true } },
|
||||
{ track: { ...track(4), type: 'episode' } },
|
||||
{ track: { ...track(5), is_playable: false } },
|
||||
{ track: track(2) },
|
||||
{ item: track(1) },
|
||||
{ track: { id: id(7) } }
|
||||
]).map((t) => t.id)
|
||||
).toEqual([id(2), id(1)]));
|
||||
it('paginates, deduplicates across pages and truncates', async () => {
|
||||
const fetchFn = vi.fn(async (url: string | URL | Request) => {
|
||||
const value = String(url);
|
||||
if (value.includes('/api/token'))
|
||||
return Response.json({ access_token: 'token', expires_in: 3600 });
|
||||
if (value.includes('offset=2'))
|
||||
return Response.json({
|
||||
items: Array.from({ length: 55 }, (_, n) => ({ track: track(n + 1) })),
|
||||
next: null,
|
||||
total: 57
|
||||
});
|
||||
return Response.json({
|
||||
name: 'Playlist',
|
||||
owner: { display_name: 'Owner' },
|
||||
tracks: {
|
||||
items: [{ track: track(1) }, { track: null }],
|
||||
next: 'https://evil.test',
|
||||
total: 57
|
||||
}
|
||||
});
|
||||
});
|
||||
const result = await getSpotifyMetadata(fetchFn as typeof fetch, {
|
||||
kind: 'playlist',
|
||||
id: id(1)
|
||||
});
|
||||
expect(result.tracks).toHaveLength(MAX_PLAYLIST_TRACKS);
|
||||
expect(result.tracks[0].id).toBe(id(1));
|
||||
expect(result.tracks[1].id).toBe(id(2));
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(fetchFn.mock.calls.every(([url]) => !String(url).includes('evil.test'))).toBe(true);
|
||||
});
|
||||
it('supports the newer items/item response', async () => {
|
||||
const fetchFn = vi.fn(async () =>
|
||||
Response.json({ name: 'New playlist', items: { items: [{ item: track(1) }], next: null } })
|
||||
);
|
||||
const result = await getSpotifyMetadata(fetchFn, { kind: 'playlist', id: id(1) });
|
||||
expect(result.tracks).toHaveLength(1);
|
||||
expect(result.truncated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('paginates album tracks and attaches shared album metadata', async () => {
|
||||
const fetchFn = vi.fn(async (url: string | URL | Request) =>
|
||||
String(url).includes('offset=1')
|
||||
? Response.json({ items: [track(2)], next: null, total: 2 })
|
||||
: Response.json({
|
||||
name: 'Album name',
|
||||
artists: [{ name: 'Artist' }],
|
||||
images: [{ url: 'https://example.test/cover.jpg' }],
|
||||
tracks: { items: [track(1)], next: 'next', total: 2 }
|
||||
})
|
||||
);
|
||||
const result = await getSpotifyMetadata(fetchFn as typeof fetch, { kind: 'album', id: id(1) });
|
||||
expect(result.collection).toMatchObject({ kind: 'album', name: 'Album name', owner: 'Artist' });
|
||||
expect(result.tracks.map((t) => t.id)).toEqual([id(1), id(2)]);
|
||||
expect(result.tracks[1].album.name).toBe('Album name');
|
||||
expect(result.truncated).toBe(false);
|
||||
expect(String(fetchFn.mock.calls[1][0])).toContain('/albums/');
|
||||
});
|
||||
20
src/lib/server/osu-profile.test.ts
Normal file
20
src/lib/server/osu-profile.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { expect, it } from 'vitest';
|
||||
import { osuProfile } from './osu-profile';
|
||||
it('binds osu accounts to numeric provider IDs, never a claimed email or username', () => {
|
||||
const profile = osuProfile({
|
||||
id: 123,
|
||||
username: 'Alice',
|
||||
avatar_url: 'https://a.ppy.sh/123',
|
||||
email: 'admin@example.com',
|
||||
email_verified: true
|
||||
});
|
||||
expect(profile).toMatchObject({ id: '123', email: '123@osu.invalid', emailVerified: false });
|
||||
expect(
|
||||
osuProfile({ id: 123, username: 'Renamed', avatar_url: 'https://a.ppy.sh/123' }).email
|
||||
).toBe(profile.email);
|
||||
});
|
||||
it('rejects malformed provider identities', () => {
|
||||
expect(() =>
|
||||
osuProfile({ id: 'admin', username: 'Alice', avatar_url: 'https://a.ppy.sh/123' })
|
||||
).toThrow();
|
||||
});
|
||||
16
src/lib/server/osu-profile.ts
Normal file
16
src/lib/server/osu-profile.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod';
|
||||
const profileSchema = z.object({
|
||||
id: z.number().int().positive(),
|
||||
username: z.string().min(1),
|
||||
avatar_url: z.string().url()
|
||||
});
|
||||
export function osuProfile(value: unknown) {
|
||||
const profile = profileSchema.parse(value);
|
||||
return {
|
||||
id: String(profile.id),
|
||||
name: profile.username,
|
||||
image: profile.avatar_url,
|
||||
email: `${profile.id}@osu.invalid`,
|
||||
emailVerified: false
|
||||
};
|
||||
}
|
||||
37
src/lib/server/osu.test.ts
Normal file
37
src/lib/server/osu.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { expect, it, vi } from 'vitest';
|
||||
vi.mock('$env/dynamic/private', () => ({ env: { OSU_CLIENT_ID: '1', OSU_CLIENT_SECRET: 'test' } }));
|
||||
import { getOsuMaps } from './osu';
|
||||
it('falls back deliberately, merges IDs, reuses OAuth and caches matching results', async () => {
|
||||
const fetchFn = vi.fn(async (url: string | URL | Request) => {
|
||||
if (String(url).includes('/oauth/token'))
|
||||
return Response.json({ access_token: 'token', expires_in: 3600 });
|
||||
const query = new URL(String(url)).searchParams.get('q');
|
||||
return Response.json({
|
||||
beatmapsets: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Unique Song',
|
||||
artist: query === 'unique song' ? 'Artist' : 'Wrong Performer'
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
const track = {
|
||||
id: '6PUzxtIHkv346yP89NzP9X',
|
||||
name: 'Unique Song',
|
||||
artists: [{ name: 'Artist' }],
|
||||
album: { name: '', images: [] }
|
||||
};
|
||||
const results = await Promise.all([
|
||||
getOsuMaps(fetchFn as typeof fetch, track),
|
||||
getOsuMaps(fetchFn as typeof fetch, track)
|
||||
]);
|
||||
expect(results[0]).toHaveLength(1);
|
||||
expect(results[0][0].score).toBe(100);
|
||||
expect(results[1]).toEqual(results[0]);
|
||||
await getOsuMaps(fetchFn as typeof fetch, track);
|
||||
expect(fetchFn).toHaveBeenCalledTimes(3);
|
||||
expect(fetchFn.mock.calls.filter(([url]) => String(url).includes('/oauth/token'))).toHaveLength(
|
||||
1
|
||||
);
|
||||
});
|
||||
90
src/lib/server/osu.ts
Normal file
90
src/lib/server/osu.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { OsuBeatmapset, ScoredBeatmapset, SpotifyTrack } from '$lib/map-finder/types';
|
||||
import { cleanTitle, normalize, rankCandidates, MEDIUM_CONFIDENCE } from '$lib/map-finder/matching';
|
||||
import { ApiError, createTokenCache, requestJson } from './finder-http';
|
||||
const token = createTokenCache();
|
||||
const cache = new Map<string, { expires: number; result: ScoredBeatmapset[] }>();
|
||||
const pending = new Map<string, Promise<ScoredBeatmapset[]>>();
|
||||
export const MATCH_CONCURRENCY = 3;
|
||||
// Global permits also bound outbound work across simultaneous visitors.
|
||||
let active = 0;
|
||||
const queue: (() => void)[] = [];
|
||||
async function limited<T>(work: () => Promise<T>): Promise<T> {
|
||||
if (active >= MATCH_CONCURRENCY) await new Promise<void>((resolve) => queue.push(resolve));
|
||||
else active++;
|
||||
try {
|
||||
return await work();
|
||||
} finally {
|
||||
const next = queue.shift();
|
||||
if (next) next();
|
||||
else active--;
|
||||
}
|
||||
}
|
||||
async function search(fetchFn: typeof fetch, query: string): Promise<OsuBeatmapset[]> {
|
||||
const access = await token(() => {
|
||||
if (!env.OSU_CLIENT_ID || !env.OSU_CLIENT_SECRET)
|
||||
throw new ApiError('osu!', 503, 'osu! is not configured on this server.');
|
||||
return requestJson(fetchFn, 'osu!', 'https://osu.ppy.sh/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({
|
||||
client_id: Number(env.OSU_CLIENT_ID),
|
||||
client_secret: env.OSU_CLIENT_SECRET,
|
||||
grant_type: 'client_credentials',
|
||||
scope: 'public'
|
||||
})
|
||||
});
|
||||
});
|
||||
const response = (await requestJson(
|
||||
fetchFn,
|
||||
'osu!',
|
||||
`https://osu.ppy.sh/api/v2/beatmapsets/search?${new URLSearchParams({ q: query, s: 'any' })}`,
|
||||
{ headers: { Authorization: `Bearer ${access}`, Accept: 'application/json' } }
|
||||
)) as { beatmapsets?: unknown[] };
|
||||
if (!Array.isArray(response?.beatmapsets))
|
||||
throw new ApiError('osu!', 502, 'osu! returned an invalid search response.');
|
||||
return response.beatmapsets
|
||||
.filter(
|
||||
(item): item is OsuBeatmapset =>
|
||||
!!item &&
|
||||
typeof item === 'object' &&
|
||||
Number.isInteger((item as OsuBeatmapset).id) &&
|
||||
typeof (item as OsuBeatmapset).title === 'string' &&
|
||||
typeof (item as OsuBeatmapset).artist === 'string'
|
||||
)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
artist: item.artist,
|
||||
title_unicode: typeof item.title_unicode === 'string' ? item.title_unicode : undefined,
|
||||
artist_unicode: typeof item.artist_unicode === 'string' ? item.artist_unicode : undefined,
|
||||
status: typeof item.status === 'string' ? item.status : undefined,
|
||||
covers: { card: typeof item.covers?.card === 'string' ? item.covers.card : undefined },
|
||||
beatmaps: Array.isArray(item.beatmaps) ? item.beatmaps.map(() => ({ id: 0 })) : undefined
|
||||
}));
|
||||
}
|
||||
export async function getOsuMaps(
|
||||
fetchFn: typeof fetch,
|
||||
track: SpotifyTrack
|
||||
): Promise<ScoredBeatmapset[]> {
|
||||
const key = JSON.stringify([normalize(track.name), track.artists.map((a) => normalize(a.name))]);
|
||||
for (const [key, entry] of cache) if (entry.expires <= Date.now()) cache.delete(key);
|
||||
const hit = cache.get(key);
|
||||
if (hit) return hit.result;
|
||||
const existing = pending.get(key);
|
||||
if (existing) return existing;
|
||||
const work = limited(async () => {
|
||||
const maps = await search(fetchFn, `${track.artists[0].name} ${track.name}`);
|
||||
let ranked = rankCandidates(track, maps);
|
||||
if (!ranked.some((m) => m.score >= MEDIUM_CONFIDENCE))
|
||||
ranked = rankCandidates(track, [
|
||||
...maps,
|
||||
...(await search(fetchFn, cleanTitle(track.name) || track.name))
|
||||
]);
|
||||
if (cache.size >= 500) cache.delete(cache.keys().next().value!);
|
||||
cache.set(key, { expires: Date.now() + 5 * 60_000, result: ranked });
|
||||
return ranked;
|
||||
}).finally(() => pending.delete(key));
|
||||
pending.set(key, work);
|
||||
return work;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
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';
|
||||
|
||||
@@ -44,38 +43,41 @@ const EXT: Record<AllowedMimeType, string> = {
|
||||
'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(),
|
||||
/** Upload through the site server so browsers do not need R2 CORS access. */
|
||||
export async function uploadImage(
|
||||
file: File,
|
||||
prefix: 'sightings' | 'lists' = 'sightings'
|
||||
): Promise<{ key: string; publicUrl: string }> {
|
||||
if (!isAllowedMimeType(file.type)) throw new Error('Unsupported image type');
|
||||
const mimeType = file.type;
|
||||
const key = `${prefix}/${randomUUID()}.${EXT[mimeType]}`;
|
||||
await getClient().send(
|
||||
new PutObjectCommand({
|
||||
Bucket: env.R2_BUCKET,
|
||||
Key: key,
|
||||
ContentType: mimeType
|
||||
}),
|
||||
{ expiresIn: 3600 }
|
||||
ContentType: mimeType,
|
||||
Body: new Uint8Array(await file.arrayBuffer())
|
||||
})
|
||||
);
|
||||
return { key, publicUrl: `${env.R2_PUBLIC_URL!.replace(/\/$/, '')}/${key}` };
|
||||
}
|
||||
|
||||
return { key, uploadUrl, publicUrl: `${env.R2_PUBLIC_URL}/${key}` };
|
||||
export function listImageUrl(key: string): string | null {
|
||||
if (
|
||||
!/^lists\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(jpg|png|webp)$/.test(
|
||||
key
|
||||
)
|
||||
)
|
||||
return null;
|
||||
if (!env.R2_PUBLIC_URL) return null;
|
||||
return `${env.R2_PUBLIC_URL.replace(/\/$/, '')}/${key}`;
|
||||
}
|
||||
|
||||
export function listImageKey(url: string): string | null {
|
||||
const base = env.R2_PUBLIC_URL?.replace(/\/$/, '');
|
||||
if (!base || !url.startsWith(`${base}/`)) return null;
|
||||
const key = url.slice(base.length + 1);
|
||||
return listImageUrl(key) === url ? key : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
17
src/lib/server/slugify.test.ts
Normal file
17
src/lib/server/slugify.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { slugify, validSlug } from './slugify';
|
||||
|
||||
describe('content slugs', () => {
|
||||
it('turns titles into readable URL segments', () => {
|
||||
expect(slugify('Hello, World!')).toBe('hello-world');
|
||||
expect(slugify(' A / B ')).toBe('a-b');
|
||||
expect(slugify('!!!', 'list')).toBe('list');
|
||||
});
|
||||
|
||||
it('accepts only canonical editable slugs', () => {
|
||||
expect(validSlug('my-list-2026')).toBe(true);
|
||||
for (const value of ['My-List', 'two words', '-leading', 'trailing-', 'double--dash', '']) {
|
||||
expect(validSlug(value)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,14 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { blogArticles } from '$lib/server/db/schema';
|
||||
import { blogArticles, lists } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export function slugify(title: string): string {
|
||||
export function slugify(title: string, fallback = 'article'): string {
|
||||
return (
|
||||
title
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'article'
|
||||
.replace(/^-+|-+$/g, '') || fallback
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,3 +27,17 @@ export async function uniqueSlug(title: string): Promise<string> {
|
||||
|
||||
return slug;
|
||||
}
|
||||
|
||||
export async function uniqueListSlug(name: string): Promise<string> {
|
||||
const base = slugify(name, 'list');
|
||||
let slug = base;
|
||||
let suffix = 2;
|
||||
while ((await db.select({ id: lists.id }).from(lists).where(eq(lists.slug, slug))).length > 0) {
|
||||
slug = `${base}-${suffix++}`;
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
export function validSlug(slug: string): boolean {
|
||||
return slug.length <= 120 && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug);
|
||||
}
|
||||
|
||||
188
src/lib/server/spotify.ts
Normal file
188
src/lib/server/spotify.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { SpotifyInput } from '$lib/map-finder/input';
|
||||
import type { SpotifyTrack, SpotifyCollection } from '$lib/map-finder/types';
|
||||
import { ApiError, createTokenCache, requestJson } from './finder-http';
|
||||
export const MAX_PLAYLIST_TRACKS = 50;
|
||||
export const MAX_PLAYLIST_PAGES = 10;
|
||||
const token = createTokenCache();
|
||||
type RecordValue = Record<string, unknown>;
|
||||
function record(value: unknown): RecordValue {
|
||||
return value && typeof value === 'object' ? (value as RecordValue) : {};
|
||||
}
|
||||
export function usableTrack(value: unknown): SpotifyTrack | null {
|
||||
const track = record(value),
|
||||
album = record(track.album);
|
||||
if (
|
||||
track.type !== 'track' ||
|
||||
track.is_local ||
|
||||
track.is_playable === false ||
|
||||
typeof track.id !== 'string' ||
|
||||
!/^[a-zA-Z0-9]{22}$/.test(track.id) ||
|
||||
typeof track.name !== 'string' ||
|
||||
!track.name.trim()
|
||||
)
|
||||
return null;
|
||||
const artists = Array.isArray(track.artists)
|
||||
? track.artists
|
||||
.map(record)
|
||||
.filter((a) => typeof a.name === 'string' && a.name.trim())
|
||||
.map((a) => ({ name: a.name as string }))
|
||||
: [];
|
||||
if (!artists.length) return null;
|
||||
const images = Array.isArray(album.images)
|
||||
? album.images
|
||||
.map(record)
|
||||
.filter((i) => typeof i.url === 'string' && i.url.startsWith('https://'))
|
||||
.map((i) => ({ url: i.url as string }))
|
||||
: [];
|
||||
return {
|
||||
id: track.id,
|
||||
name: track.name,
|
||||
artists,
|
||||
album: { name: typeof album.name === 'string' ? album.name : '', images }
|
||||
};
|
||||
}
|
||||
export function playlistTracks(items: unknown[], seen = new Set<string>()): SpotifyTrack[] {
|
||||
const result: SpotifyTrack[] = [];
|
||||
for (const entry of items) {
|
||||
const item = record(entry);
|
||||
if (item.is_local) continue;
|
||||
const track = usableTrack(item.track ?? item.item);
|
||||
if (track && !seen.has(track.id)) {
|
||||
seen.add(track.id);
|
||||
result.push(track);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
async function spotify(fetchFn: typeof fetch, path: string) {
|
||||
const access = await token(() => {
|
||||
if (!env.SPOTIFY_CLIENT_ID || !env.SPOTIFY_CLIENT_SECRET)
|
||||
throw new ApiError('Spotify', 503, 'Spotify is not configured on this server.');
|
||||
return requestJson(fetchFn, 'Spotify', 'https://accounts.spotify.com/api/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${btoa(`${env.SPOTIFY_CLIENT_ID}:${env.SPOTIFY_CLIENT_SECRET}`)}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({ grant_type: 'client_credentials' })
|
||||
});
|
||||
});
|
||||
return record(
|
||||
await requestJson(fetchFn, 'Spotify', `https://api.spotify.com/v1/${path}`, {
|
||||
headers: { Authorization: `Bearer ${access}` }
|
||||
})
|
||||
);
|
||||
}
|
||||
export async function getSpotifyMetadata(
|
||||
fetchFn: typeof fetch,
|
||||
input:
|
||||
| Extract<SpotifyInput, { kind: 'track' | 'playlist' | 'album' }>
|
||||
| Extract<SpotifyInput, { kind: 'search' }>
|
||||
): Promise<{ tracks: SpotifyTrack[]; collection?: SpotifyCollection; truncated: boolean }> {
|
||||
if (input.kind === 'track' || input.kind === 'search') {
|
||||
let response: RecordValue;
|
||||
try {
|
||||
response = await spotify(
|
||||
fetchFn,
|
||||
input.kind === 'track'
|
||||
? `tracks/${input.id}`
|
||||
: `search?${new URLSearchParams({ q: input.query, type: 'track', limit: '1' })}`
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404)
|
||||
throw new ApiError('Spotify', 404, 'Spotify track not found.');
|
||||
throw error;
|
||||
}
|
||||
const track = usableTrack(
|
||||
input.kind === 'track'
|
||||
? response
|
||||
: (record(response.tracks).items as unknown[] | undefined)?.[0]
|
||||
);
|
||||
if (!track)
|
||||
throw new ApiError(
|
||||
'Spotify',
|
||||
404,
|
||||
input.kind === 'track'
|
||||
? 'Spotify track is unavailable.'
|
||||
: 'No Spotify text-search result. Try an artist and song title.'
|
||||
);
|
||||
return { tracks: [track], truncated: false };
|
||||
}
|
||||
try {
|
||||
const isAlbum = input.kind === 'album';
|
||||
const resource = isAlbum ? 'albums' : 'playlists';
|
||||
const metadata = await spotify(fetchFn, `${resource}/${input.id}`);
|
||||
if (typeof metadata.name !== 'string')
|
||||
throw new ApiError('Spotify', 502, 'Spotify returned incomplete playlist metadata.');
|
||||
const owner = record(metadata.owner),
|
||||
images = metadata.images;
|
||||
const collection: SpotifyCollection = {
|
||||
id: input.id,
|
||||
kind: input.kind,
|
||||
name: metadata.name,
|
||||
owner:
|
||||
isAlbum && Array.isArray(metadata.artists)
|
||||
? metadata.artists
|
||||
.map(record)
|
||||
.map((a) => (typeof a.name === 'string' ? a.name : ''))
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
: typeof owner.display_name === 'string'
|
||||
? owner.display_name
|
||||
: '',
|
||||
image:
|
||||
Array.isArray(images) && typeof record(images[0]).url === 'string'
|
||||
? (record(images[0]).url as string)
|
||||
: undefined
|
||||
};
|
||||
// Extended-quota responses use tracks/track; newer development-mode responses use items/item.
|
||||
const modern = metadata.items !== undefined;
|
||||
let page = record(modern ? metadata.items : metadata.tracks);
|
||||
let offset = 0;
|
||||
const tracks: SpotifyTrack[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (let pages = 0; pages < MAX_PLAYLIST_PAGES; pages++) {
|
||||
if (!Array.isArray(page.items))
|
||||
page = await spotify(
|
||||
fetchFn,
|
||||
`${resource}/${input.id}/${modern ? 'items' : 'tracks'}?limit=50&offset=${offset}`
|
||||
);
|
||||
if (!Array.isArray(page.items))
|
||||
throw new ApiError(
|
||||
'Spotify',
|
||||
502,
|
||||
'Spotify playlist tracks are unavailable for this application.'
|
||||
);
|
||||
const usable = playlistTracks(
|
||||
isAlbum
|
||||
? page.items.map((item) => ({
|
||||
track: { ...record(item), album: { name: metadata.name, images: metadata.images } }
|
||||
}))
|
||||
: page.items,
|
||||
seen
|
||||
);
|
||||
const room = MAX_PLAYLIST_TRACKS - tracks.length;
|
||||
tracks.push(...usable.slice(0, room));
|
||||
offset += page.items.length;
|
||||
const more = Boolean(page.next) || (typeof page.total === 'number' && offset < page.total);
|
||||
if (tracks.length >= MAX_PLAYLIST_TRACKS || !more || pages === MAX_PLAYLIST_PAGES - 1)
|
||||
return { tracks, collection, truncated: usable.length > room || more };
|
||||
if (!page.items.length)
|
||||
throw new ApiError('Spotify', 502, 'Spotify returned incomplete playlist tracks.');
|
||||
// Construct pagination locally: never forward credentials to an upstream-supplied URL.
|
||||
page = {};
|
||||
}
|
||||
return { tracks, collection, truncated: true };
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && [401, 403, 404].includes(error.status))
|
||||
throw new ApiError(
|
||||
'Spotify',
|
||||
error.status,
|
||||
input.kind === 'album'
|
||||
? 'Spotify album unavailable or not found. Try an individual track.'
|
||||
: 'Spotify playlist unavailable or private, or this application does not have playlist access. Try a public playlist or an individual track.'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
20
src/lib/title.test.ts
Normal file
20
src/lib/title.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatTitle, getTitle, siteEnvironment } from './title';
|
||||
|
||||
describe('site titles', () => {
|
||||
it('uses one suffix and an environment prefix', () => {
|
||||
expect(getTitle('/', 'DEV')).toBe('(DEV) Home | Stan Runge');
|
||||
expect(getTitle('/projects/catch-em-all', 'STAGING')).toBe('(STAGING) Catchemall | Stan Runge');
|
||||
expect(formatTitle('A blog post')).toBe('A blog post | Stan Runge');
|
||||
});
|
||||
|
||||
it('gives unlisted routes a readable title', () => {
|
||||
expect(getTitle('/projects/typing-practice')).toBe('typing practice | Stan Runge');
|
||||
});
|
||||
|
||||
it('labels development and staging without labelling production', () => {
|
||||
expect(siteEnvironment(true, 'https://staging.stanrunge.dev')).toBe('DEV');
|
||||
expect(siteEnvironment(false, 'https://staging.stanrunge.dev')).toBe('STAGING');
|
||||
expect(siteEnvironment(false, 'https://stanrunge.dev')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,40 @@
|
||||
import { dev } from "$app/env";
|
||||
export type SiteEnvironment = 'DEV' | 'STAGING' | null;
|
||||
|
||||
export function getTitle(path: string): string {
|
||||
let builtTitle = "Stan Runge";
|
||||
|
||||
if (dev) builtTitle = "(DEV) " + builtTitle;
|
||||
|
||||
return builtTitle;
|
||||
export function siteEnvironment(isDev: boolean, origin?: string): SiteEnvironment {
|
||||
if (isDev) return 'DEV';
|
||||
try {
|
||||
return new URL(origin ?? '').hostname === 'staging.stanrunge.dev' ? 'STAGING' : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatTitle(title: string, environment: SiteEnvironment = null): string {
|
||||
return `${environment ? `(${environment}) ` : ''}${title} | Stan Runge`;
|
||||
}
|
||||
|
||||
export function getTitle(path: string, environment: SiteEnvironment = null): string {
|
||||
const titles: Record<string, string> = {
|
||||
'/': 'Home',
|
||||
'/projects': 'Projects',
|
||||
'/blog': 'Blog',
|
||||
'/lists': 'Lists',
|
||||
'/login': 'Sign in',
|
||||
'/register': 'Register',
|
||||
'/account': 'Account',
|
||||
'/projects/catch-em-all': 'Catchemall',
|
||||
'/projects/animaldex': 'Animaldex',
|
||||
'/projects/animaldex/register': 'Log a sighting',
|
||||
'/projects/animaldex/admin': 'Animaldex admin',
|
||||
'/projects/derby': 'Derby Lens',
|
||||
'/projects/cs-prices': 'CS Prices',
|
||||
'/projects/game-tracker': 'Game Tracker',
|
||||
'/projects/money-tracker': 'Money Tracker',
|
||||
'/projects/spotify-beatmap-finder': 'osu! map finder',
|
||||
'/projects/progress': 'Progress'
|
||||
};
|
||||
const cleaned = path.replace(/\/$/, '') || '/';
|
||||
const title =
|
||||
titles[cleaned] ?? cleaned.split('/').filter(Boolean).at(-1)?.replace(/-/g, ' ') ?? 'Home';
|
||||
return formatTitle(title, environment);
|
||||
}
|
||||
|
||||
@@ -6,5 +6,18 @@ export const createArticleSchema = z.object({
|
||||
|
||||
export const updateArticleSchema = z.object({
|
||||
title: z.string().min(1).max(256),
|
||||
content: z.string().max(50000)
|
||||
slug: z.string().min(1).max(120),
|
||||
content: z.string().max(50000),
|
||||
tags: z.array(z.string().min(1).max(32)).max(8)
|
||||
});
|
||||
|
||||
export function parseArticleTags(value: string) {
|
||||
return [
|
||||
...new Set(
|
||||
value
|
||||
.split(',')
|
||||
.map((tag) => tag.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
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({
|
||||
@@ -29,7 +23,7 @@ export const registerAnimalSchema = z.object({
|
||||
deviceLat: z.number().min(-90).max(90),
|
||||
deviceLng: z.number().min(-180).max(180),
|
||||
|
||||
// Already-uploaded R2 keys (1–10 photos, browser uploaded directly)
|
||||
// Already-uploaded R2 keys (1–10 photos)
|
||||
photoKeys: z.array(z.string().min(1)).min(1).max(10)
|
||||
});
|
||||
|
||||
@@ -38,7 +32,7 @@ export type RegisterAnimalInput = z.infer<typeof registerAnimalSchema>;
|
||||
// ── Add sighting to existing animal ──────────────────────────────────────────
|
||||
|
||||
export const addSightingSchema = z.object({
|
||||
animalId: z.string().uuid(),
|
||||
animalId: z.coerce.number().int().positive(),
|
||||
reporterName: z.string().max(100).optional(),
|
||||
seenAt: z.iso.datetime(),
|
||||
lat: z.number().min(-90).max(90),
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
export const load = async ({ cookies }) => {
|
||||
return {
|
||||
editable: cookies.get('token')
|
||||
};
|
||||
};
|
||||
import { isAdmin } from '$lib/server/authorization';
|
||||
import { dev } from '$app/environment';
|
||||
import { siteEnvironment } from '$lib/title';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
const environment = siteEnvironment(dev, process.env.ORIGIN);
|
||||
export const load: LayoutServerLoad = (event) => ({
|
||||
user: event.locals.user
|
||||
? { id: event.locals.user.id, name: event.locals.user.name, image: event.locals.user.image }
|
||||
: null,
|
||||
isAdmin: isAdmin(event),
|
||||
editable: isAdmin(event),
|
||||
version: process.env.APP_VERSION || 'dev',
|
||||
environment
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import { assets, resolve } from '$app/paths';
|
||||
import { dev } from '$app/environment';
|
||||
import { page } from '$app/state';
|
||||
import { getSeo } from '$lib/seo';
|
||||
import CopyButton from '$lib/components/CopyButton.svelte';
|
||||
import SiOsu from '~icons/simple-icons/osu';
|
||||
import SiSteam from '~icons/simple-icons/steam';
|
||||
import SiTwitch from '~icons/simple-icons/twitch';
|
||||
@@ -10,47 +15,105 @@
|
||||
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';
|
||||
import { dev } from '$app/environment';
|
||||
import { getTitle } from '$lib/title';
|
||||
import { page } from '$app/state';
|
||||
|
||||
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_' },
|
||||
let { data, children } = $props();
|
||||
const seo = $derived(getSeo(page.url.pathname, page.data, data.environment));
|
||||
let menuOpen = $state(false);
|
||||
type ThemePreference = 'dark' | 'system' | 'light';
|
||||
let theme = $state<ThemePreference>('dark');
|
||||
const navigation = [
|
||||
{ label: 'home', href: '/' },
|
||||
{ label: 'projects', href: '/projects' },
|
||||
{ label: 'blog', href: '/blog' },
|
||||
{ label: 'lists', href: '/lists' }
|
||||
] as const;
|
||||
const socialLinks = [
|
||||
{ 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 / twitter', 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: SiTwitch, label: 'twitch', href: 'https://twitch.tv/stanrunge_' },
|
||||
{
|
||||
icon: SiSpotify,
|
||||
label: 'Spotify',
|
||||
label: 'spotify',
|
||||
href: 'https://open.spotify.com/user/e3o3qr9z7fpsu4gur9zz4y1ld'
|
||||
},
|
||||
{ icon: SiSteam, label: 'Steam', href: 'https://steamcommunity.com/id/stanrunge' },
|
||||
{ icon: SiSteam, label: 'steam', href: 'https://steamcommunity.com/id/stanrunge' },
|
||||
{ icon: SiOsu, label: 'osu!', href: 'https://osu.ppy.sh/users/11212255' }
|
||||
];
|
||||
|
||||
const copies = [
|
||||
const copyLinks = [
|
||||
{ icon: SiWhatsapp, label: 'WhatsApp', value: 'stanrunge' },
|
||||
{ icon: SiDiscord, label: 'Discord', value: 'stanrunge' },
|
||||
{ icon: SiSignal, label: 'Signal', value: 'stan.443' }
|
||||
];
|
||||
|
||||
onMount(() => {
|
||||
const preference = document.documentElement.dataset.themePreference;
|
||||
theme = preference === 'light' || preference === 'system' ? preference : 'dark';
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: light)');
|
||||
const onSystemThemeChange = () => {
|
||||
if (theme === 'system') applyTheme('system');
|
||||
};
|
||||
systemTheme.addEventListener('change', onSystemThemeChange);
|
||||
return () => systemTheme.removeEventListener('change', onSystemThemeChange);
|
||||
});
|
||||
|
||||
function applyTheme(preference: ThemePreference) {
|
||||
const resolvedTheme =
|
||||
preference === 'system'
|
||||
? window.matchMedia('(prefers-color-scheme: light)').matches
|
||||
? 'light'
|
||||
: 'dark'
|
||||
: preference;
|
||||
document.documentElement.dataset.themePreference = preference;
|
||||
document.documentElement.dataset.theme = resolvedTheme;
|
||||
document
|
||||
.querySelector('meta[name="theme-color"]')
|
||||
?.setAttribute('content', resolvedTheme === 'light' ? '#ffffff' : '#000000');
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
theme = theme === 'dark' ? 'system' : theme === 'system' ? 'light' : 'dark';
|
||||
applyTheme(theme);
|
||||
try {
|
||||
localStorage.setItem('theme', theme);
|
||||
} catch {
|
||||
// The selected theme still applies for this visit.
|
||||
}
|
||||
}
|
||||
|
||||
function current(href: string) {
|
||||
return page.url.pathname === href || (href !== '/' && page.url.pathname.startsWith(href + '/'));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet themeIcon()}
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="22"
|
||||
height="22"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.7"
|
||||
stroke-linecap="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M12 2v2M12 20v2M4.93 4.93l1.42 1.42m11.3 11.3 1.42 1.42M2 12h2m16 0h2M4.93 19.07l1.42-1.42m11.3-11.3 1.42-1.42"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="6" fill={theme === 'light' ? 'currentColor' : 'none'} />
|
||||
{#if theme === 'system'}
|
||||
<path d="M12 6a6 6 0 0 1 0 12Z" fill="currentColor" stroke="none" />
|
||||
{/if}
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
<svelte:head>
|
||||
{#if !dev}
|
||||
<script
|
||||
@@ -59,45 +122,157 @@
|
||||
data-website-id="47f2b385-188a-4776-a287-4889008ebac5"
|
||||
></script>
|
||||
{/if}
|
||||
|
||||
<title>{getTitle(page.url.pathname)}</title>
|
||||
<title>{seo.title}</title>
|
||||
<meta name="description" content={seo.description} />
|
||||
<meta name="robots" content={seo.indexable ? 'index, follow' : 'noindex, nofollow'} />
|
||||
<link rel="canonical" href={seo.url} />
|
||||
<meta property="og:site_name" content="Stan Runge" />
|
||||
<meta property="og:type" content={seo.type} />
|
||||
<meta property="og:title" content={seo.title} />
|
||||
<meta property="og:description" content={seo.description} />
|
||||
<meta property="og:url" content={seo.url} />
|
||||
<meta property="og:image" content={seo.image} />
|
||||
<meta property="og:image:alt" content={seo.imageAlt} />
|
||||
<meta property="og:image:width" content="793" />
|
||||
<meta property="og:image:height" content="793" />
|
||||
{#if seo.modifiedAt}<meta property="article:modified_time" content={seo.modifiedAt} />{/if}
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:creator" content="@stanrunge" />
|
||||
<meta name="twitter:title" content={seo.title} />
|
||||
<meta name="twitter:description" content={seo.description} />
|
||||
<meta name="twitter:image" content={seo.image} />
|
||||
<meta name="twitter:image:alt" content={seo.imageAlt} />
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col min-h-screen">
|
||||
<header class="flex justify-between items-center gap-4 py-4 px-4 border-b-4 border-gray-300">
|
||||
<a href={resolve('/')} class="text-2xl font-bold underline">Stan Runge</a>
|
||||
<div class="flex gap-8">
|
||||
<div class="text-center py-2 font-bold text-xl ">
|
||||
<a href={resolve('/blog')}>Blog</a>
|
||||
</div>
|
||||
|
||||
<div class="text-center py-2 font-bold text-xl ">
|
||||
<a href={resolve('/projects')}>Projects</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="flex flex-wrap justify-center gap-4" aria-label="Social links">
|
||||
{#each copies as c (c.label)}
|
||||
<CopyButton {...c} />
|
||||
{/each}
|
||||
{#each links as { icon: Icon, label, href } (label)}
|
||||
<a {href} aria-label={label} class="text-2xl transition hover:opacity-60">
|
||||
<Icon />
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="flex-1">
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<footer class="flex justify-center py-8 bg-gray-200">
|
||||
<p>
|
||||
Hosted on <a
|
||||
class="underline text-blue-500"
|
||||
href="https://git.stanrunge.dev/stan/personal-website">my git server :)</a
|
||||
<div class="flex min-h-screen flex-col bg-[var(--site-bg)] text-[var(--site-text)]">
|
||||
<a
|
||||
href="#main"
|
||||
class="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:bg-[var(--site-bg)] focus:px-4 focus:py-2"
|
||||
>skip to content</a
|
||||
>
|
||||
<header class="border-b border-[var(--site-border)]">
|
||||
<div class="mx-auto flex max-w-6xl items-center gap-5 px-4 py-4 sm:px-6">
|
||||
<a
|
||||
href={resolve('/')}
|
||||
class="flex shrink-0 items-center gap-3 text-base font-semibold tracking-tight"
|
||||
onclick={() => (menuOpen = false)}
|
||||
>
|
||||
</p>
|
||||
<span class="h-7 w-7 overflow-hidden rounded-full bg-black">
|
||||
<img
|
||||
src="{assets}/favicon-32x32.png"
|
||||
alt=""
|
||||
width="32"
|
||||
height="32"
|
||||
class="h-full w-full scale-[1.7]"
|
||||
/>
|
||||
</span>
|
||||
<span>stan runge</span>
|
||||
</a>
|
||||
<nav class="ml-auto hidden items-center gap-6 text-base md:flex" aria-label="Main navigation">
|
||||
{#each navigation as item (item.href)}
|
||||
<a
|
||||
href={resolve(item.href)}
|
||||
aria-current={current(item.href) ? 'page' : undefined}
|
||||
class={current(item.href)
|
||||
? 'text-[var(--site-text)]'
|
||||
: 'text-[var(--site-muted)] hover:text-[var(--site-text)]'}>{item.label}</a
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="ml-auto hidden items-center gap-5 text-base md:ml-4 md:flex">
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleTheme}
|
||||
aria-label={`Theme: ${theme}. Switch to ${theme === 'dark' ? 'system' : theme === 'system' ? 'light' : 'dark'} mode`}
|
||||
title={`Theme: ${theme}`}
|
||||
class="flex h-10 w-10 items-center justify-center text-[var(--site-muted)] hover:text-[var(--site-text)]"
|
||||
>{@render themeIcon()}</button
|
||||
>
|
||||
{#if data.user}
|
||||
<a href={resolve('/account')} class="max-w-28 truncate hover:underline"
|
||||
>{data.user.name}</a
|
||||
>
|
||||
{:else}
|
||||
<a
|
||||
href={resolve(
|
||||
`/login?returnTo=${encodeURIComponent(page.url.pathname + page.url.search)}`
|
||||
)}
|
||||
class="hover:underline">sign in</a
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-auto text-base text-[var(--site-muted)] md:hidden"
|
||||
aria-label={menuOpen ? 'Close menu' : 'Open menu'}
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls="mobile-menu"
|
||||
onclick={() => (menuOpen = !menuOpen)}>{menuOpen ? 'close' : 'menu'}</button
|
||||
>
|
||||
</div>
|
||||
{#if menuOpen}
|
||||
<nav
|
||||
id="mobile-menu"
|
||||
class="border-t border-[var(--site-border)] px-4 py-4 md:hidden"
|
||||
aria-label="Mobile navigation"
|
||||
>
|
||||
<div class="mx-auto flex max-w-6xl flex-col items-center gap-1 text-center text-base">
|
||||
{#each navigation as item (item.href)}
|
||||
<a href={resolve(item.href)} onclick={() => (menuOpen = false)} class="w-full py-2"
|
||||
>{item.label}</a
|
||||
>
|
||||
{/each}
|
||||
<div
|
||||
class="mt-3 flex w-full items-center justify-center gap-5 border-t border-[var(--site-border)] pt-4"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleTheme}
|
||||
aria-label={`Theme: ${theme}. Switch to ${theme === 'dark' ? 'system' : theme === 'system' ? 'light' : 'dark'} mode`}
|
||||
title={`Theme: ${theme}`}
|
||||
class="flex h-10 w-10 items-center justify-center text-[var(--site-muted)]"
|
||||
>{@render themeIcon()}</button
|
||||
>
|
||||
{#if data.user}
|
||||
<a href={resolve('/account')} onclick={() => (menuOpen = false)}>account</a>
|
||||
<form method="POST" action="/logout"><button>sign out</button></form>
|
||||
{:else}
|
||||
<a href={resolve('/login')} onclick={() => (menuOpen = false)}>sign in</a>
|
||||
<a href={resolve('/register')} onclick={() => (menuOpen = false)}>register</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{/if}
|
||||
</header>
|
||||
<main id="main" class="min-w-0 flex-1">{@render children()}</main>
|
||||
<footer class="mt-16 border-t border-[var(--site-border)]">
|
||||
<div class="mx-auto max-w-6xl space-y-5 px-4 py-8 text-[var(--site-muted)] sm:px-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 text-base">
|
||||
<span
|
||||
>stan runge <span class="ml-2 font-mono text-xs" title="Deployed commit"
|
||||
>{data.version.slice(0, 7)}</span
|
||||
></span
|
||||
>
|
||||
<a
|
||||
href="https://git.stanrunge.dev/stan/personal-website"
|
||||
rel="external"
|
||||
class="hover:text-[var(--site-text)]">source ↗</a
|
||||
>
|
||||
</div>
|
||||
<nav class="flex flex-wrap items-center justify-center gap-1" aria-label="Social links">
|
||||
{#each socialLinks as { icon: Icon, label, href } (label)}
|
||||
<a
|
||||
{href}
|
||||
rel="external"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
class="flex h-10 w-10 items-center justify-center text-xl hover:text-[var(--site-text)]"
|
||||
><Icon /></a
|
||||
>
|
||||
{/each}
|
||||
{#each copyLinks as item (item.label)}<CopyButton {...item} />{/each}
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -1,31 +1,36 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { listItemCompletions } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const actions = {
|
||||
authenticate: async ({ request, cookies }) => {
|
||||
const data = await request.formData();
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const lists = await db.query.lists.findMany({
|
||||
columns: { id: true, name: true, slug: true, isProgress: true },
|
||||
with: { items: { columns: { id: true } } },
|
||||
orderBy: { id: 'desc' }
|
||||
});
|
||||
|
||||
const token = data.get('token');
|
||||
if (!token) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Missing token' }
|
||||
};
|
||||
}
|
||||
|
||||
if (token !== env.AUTH_TOKEN) {
|
||||
return {
|
||||
status: 401,
|
||||
body: { error: 'Invalid token' }
|
||||
};
|
||||
}
|
||||
|
||||
cookies.set('token', token, {
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 7
|
||||
});
|
||||
|
||||
return {
|
||||
success: true
|
||||
};
|
||||
}
|
||||
const completed = new Set(
|
||||
locals.user
|
||||
? (
|
||||
await db
|
||||
.select({ itemId: listItemCompletions.itemId })
|
||||
.from(listItemCompletions)
|
||||
.where(eq(listItemCompletions.userId, locals.user.id))
|
||||
).map((row) => row.itemId)
|
||||
: []
|
||||
);
|
||||
return {
|
||||
lists: lists.map(({ items, ...list }) => ({
|
||||
...list,
|
||||
total: items.length,
|
||||
checked: list.isProgress ? items.filter((item) => completed.has(item.id)).length : 0
|
||||
}))
|
||||
};
|
||||
};
|
||||
|
||||
// Retire the old shared-token login; legacy cookies grant no permissions.
|
||||
export const actions = {
|
||||
authenticate: () => fail(410, { message: 'Use the central account login at /login' })
|
||||
};
|
||||
|
||||
@@ -1,107 +1,181 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import portrait from '$lib/assets/stan.jpg?url';
|
||||
import ProgressBar from '$lib/components/ProgressBar.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
const projects = [
|
||||
{
|
||||
name: 'vash esports',
|
||||
subtitle: 'the best way to play osu!',
|
||||
href: 'https://esports.vash.software',
|
||||
external: true
|
||||
},
|
||||
{
|
||||
name: 'catchemall',
|
||||
subtitle: 'catch 1025 pokemon across 53 games',
|
||||
href: '/projects/catch-em-all',
|
||||
external: false
|
||||
},
|
||||
{
|
||||
name: 'animaldex',
|
||||
subtitle: 'add neighborhood pets to the world map 😼',
|
||||
href: '/projects/animaldex',
|
||||
external: false
|
||||
},
|
||||
{
|
||||
name: 'osu! map finder',
|
||||
subtitle: 'find osu! maps for Spotify songs/albums/playlists',
|
||||
href: '/projects/spotify-beatmap-finder',
|
||||
external: false
|
||||
},
|
||||
{
|
||||
name: 'derby lens',
|
||||
subtitle: 'estimate odds for horse betting',
|
||||
href: '/projects/derby',
|
||||
external: false
|
||||
}
|
||||
] as const;
|
||||
const experience = [
|
||||
{ when: '2026–now', role: 'associate software engineer', place: 'Cargill' },
|
||||
{
|
||||
when: '2021–2026',
|
||||
role: 'BSc computer science · software engineering',
|
||||
place: 'The Hague University of Applied Sciences'
|
||||
},
|
||||
{ when: '2026', role: 'software engineering intern', place: '3webapps' },
|
||||
{ when: '2023', role: 'software engineering intern', place: 'h5mag' },
|
||||
{ when: '2021–2023', role: 'junior IT specialist', place: 'Finance Plus' }
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="preload" as="image" href={portrait} fetchpriority="high" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex justify-around py-8">
|
||||
<div class="max-w-150">
|
||||
<h2 class="font-bold text-2xl underline text-center">experience</h2>
|
||||
<div class="flex gap-2">
|
||||
<p class="min-w-28 text-right">2026 - <b>current</b></p>
|
||||
<div>
|
||||
<p class="font-bold">Associate Software Engineer</p>
|
||||
|
||||
<p>Cargill</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<p class="min-w-28 text-right">2021 - 2026</p>
|
||||
<div>
|
||||
<p class="font-bold">BSc. Computer Science (Software Engineering)</p>
|
||||
<p>The Hague University of Applied Sciences</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<p class="min-w-28 text-right">2026</p>
|
||||
<p class="font-bold">internship at 3webapps</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<p class="min-w-28 text-right">2023</p>
|
||||
<p class="font-bold">internship at h5mag</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<p class="min-w-28 text-right">2021 - 2023</p>
|
||||
<p class="font-bold">junior IT specialist at Finance Plus</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<p class="min-w-28 text-right">2020 - <b>current</b></p>
|
||||
<p class="font-bold">300+ IT problems solved in Netherlands</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-8">
|
||||
<h1 class="font-black text-center text-4xl pb-8">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
|
||||
<div class="mx-auto max-w-6xl px-4 sm:px-6">
|
||||
<section
|
||||
class="grid items-center gap-8 pt-7 pb-12 sm:py-20 md:grid-cols-[minmax(0,1fr)_minmax(0,320px)] md:gap-16"
|
||||
>
|
||||
<div class="min-w-0 text-center md:text-left">
|
||||
<h1 class="text-5xl font-semibold tracking-[-0.055em] sm:text-6xl lg:text-7xl">stan runge</h1>
|
||||
<nav
|
||||
class="mt-7 grid grid-cols-2 justify-items-center gap-x-4 gap-y-3 text-base text-[var(--site-muted)] sm:flex sm:flex-wrap sm:justify-center sm:gap-x-6 md:justify-start"
|
||||
aria-label="Find me online"
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="max-w-100">
|
||||
<div>
|
||||
<h2 class="font-bold text-2xl underline text-center">projects</h2>
|
||||
<a
|
||||
href="https://git.stanrunge.dev/stan"
|
||||
rel="external"
|
||||
class="hover:text-[var(--site-text)]">git ↗</a
|
||||
>
|
||||
<a href="https://x.com/stanrunge" rel="external" class="hover:text-[var(--site-text)]"
|
||||
>twitter ↗</a
|
||||
>
|
||||
<a href="https://esports.vash.software" rel="external" class="hover:text-[var(--site-text)]"
|
||||
>vash esports ↗</a
|
||||
>
|
||||
<a href="mailto:stan@stanrunge.dev" class="hover:text-[var(--site-text)]">email ↗</a>
|
||||
</nav>
|
||||
</div>
|
||||
<img
|
||||
src={portrait}
|
||||
alt="Stan at his desk"
|
||||
width="760"
|
||||
height="746"
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
fetchpriority="high"
|
||||
class="aspect-square w-full max-w-80 justify-self-center rounded-md bg-[var(--site-surface)] object-cover md:justify-self-end"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<a href="https://esports.vash.software">
|
||||
<p class="font-bold text-xl">vash esports</p>
|
||||
<p>automated osu! matchmaking/tournament platform</p>
|
||||
</a>
|
||||
<div>
|
||||
<p class="font-bold text-xl">eudi-auth for 3webapps</p>
|
||||
<p>
|
||||
plugin for WooCommerce/Magento2 that adds authentication with the European Digital
|
||||
Identity (EUDI) wallet
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold text-xl">realtime collaboration for h5mag</p>
|
||||
<p>
|
||||
socket.io server for h5mag (magazine web editor) that broadcasts user actions to other
|
||||
editors, similar to writing a Google Doc together
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold text-xl">infra</p>
|
||||
<p>server, project deployments and dev config as code with setup script</p>
|
||||
</div>
|
||||
<a href={resolve('/projects/animaldex')}>
|
||||
<p class="font-bold text-xl">animaldex</p>
|
||||
<p>world map with pets & other animals around the neighborhood 😼</p>
|
||||
</a>
|
||||
<a href={resolve('/projects/catch-em-all')}>
|
||||
<p class="font-bold text-xl">catch em all</p>
|
||||
<p>table of all 1025 pokemon across 38 games with checkboxes for catching them all</p>
|
||||
</a>
|
||||
<a href={resolve('/projects/spotify-beatmap-finder')}>
|
||||
<p class="font-bold text-xl">spotify osu! map finder</p>
|
||||
<p>small tool for finding osu! maps using a spotify url of a song or playlist</p>
|
||||
</a>
|
||||
<a href="https://beeriokart.stanrunge.dev">
|
||||
<p class="font-bold text-xl">beerio kart</p>
|
||||
<div>Go</div>
|
||||
<p>webserver in go with a double-elim bracket for a one-time mario kart tournament</p>
|
||||
</a>
|
||||
<section aria-labelledby="projects-heading" class="border-t border-[var(--site-border)] pt-8">
|
||||
<h2 id="projects-heading" class="mb-4 text-2xl font-semibold tracking-tight">
|
||||
<a href={resolve('/projects')} class="hover:text-[var(--site-muted)]">projects ↗</a>
|
||||
</h2>
|
||||
<ul>
|
||||
{#each projects as project (project.name)}
|
||||
<li class="border-b border-[var(--site-border)] text-lg">
|
||||
{#if project.external}
|
||||
<a
|
||||
href={project.href}
|
||||
rel="external"
|
||||
class="flex items-center justify-between gap-4 py-4 hover:text-[var(--site-muted)]"
|
||||
>
|
||||
<span class="min-w-0">
|
||||
<span class="block">{project.name}</span>
|
||||
<span class="mt-1 block text-base text-[var(--site-muted)]">{project.subtitle}</span
|
||||
>
|
||||
</span><span aria-hidden="true" class="shrink-0 text-[var(--site-muted)]">↗</span>
|
||||
</a>
|
||||
{:else}
|
||||
<a
|
||||
href={resolve(project.href)}
|
||||
class="flex items-center justify-between gap-4 py-4 hover:text-[var(--site-muted)]"
|
||||
>
|
||||
<span class="min-w-0">
|
||||
<span class="block">{project.name}</span>
|
||||
<span class="mt-1 block text-base text-[var(--site-muted)]">{project.subtitle}</span
|
||||
>
|
||||
</span><span aria-hidden="true" class="shrink-0 text-[var(--site-muted)]">↗</span>
|
||||
</a>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p>+ a lot more planned (soon™)</p>
|
||||
</div>
|
||||
</div>
|
||||
<section aria-labelledby="lists-heading" class="pt-14 sm:pt-20">
|
||||
<h2 id="lists-heading" class="mb-4 text-2xl font-semibold tracking-tight">
|
||||
<a href={resolve('/lists')} class="hover:text-[var(--site-muted)]">lists ↗</a>
|
||||
</h2>
|
||||
<ul>
|
||||
{#each data.lists as list (list.id)}
|
||||
<li class="border-b border-[var(--site-border)] text-lg">
|
||||
<a
|
||||
href={resolve('/lists/[slug]', { slug: list.slug })}
|
||||
class="block py-4 hover:text-[var(--site-muted)]"
|
||||
>
|
||||
<span class="flex flex-wrap items-center justify-between gap-x-6 gap-y-1">
|
||||
<span>{list.name}</span>
|
||||
<span class="text-base text-[var(--site-muted)]">
|
||||
{#if list.isProgress && data.user}
|
||||
{list.total ? Math.round((list.checked / list.total) * 100) : 0}% · {list.checked}/{list.total}
|
||||
{:else}
|
||||
{list.total} {list.total === 1 ? 'item' : 'items'}
|
||||
{/if} ↗
|
||||
</span>
|
||||
</span>
|
||||
{#if list.isProgress}<span class="mt-3 block"
|
||||
><ProgressBar
|
||||
value={list.checked}
|
||||
max={list.total}
|
||||
label={`Progress on ${list.name}`}
|
||||
small
|
||||
/></span
|
||||
>{/if}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="experience-heading" class="pt-14 sm:pt-20">
|
||||
<h2 id="experience-heading" class="mb-4 text-2xl font-semibold tracking-tight">experience</h2>
|
||||
<ol>
|
||||
{#each experience as item (item.when + item.place)}
|
||||
<li
|
||||
class="grid gap-1 border-t border-[var(--site-border)] py-4 sm:grid-cols-[minmax(0,1fr)_130px] sm:gap-6"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<h3 class="font-medium">{item.role}</h3>
|
||||
<p class="mt-1 text-base text-[var(--site-muted)]">{item.place}</p>
|
||||
</div>
|
||||
<span class="text-base text-[var(--site-muted)] sm:text-right">{item.when}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
13
src/routes/account/+page.server.ts
Normal file
13
src/routes/account/+page.server.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { auth, authProviders } from '$lib/server/auth';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
export const load: PageServerLoad = async ({ locals, request, url }) => {
|
||||
if (!locals.user) redirect(303, '/login?returnTo=/account');
|
||||
const accounts = await auth.api.listUserAccounts({ headers: request.headers });
|
||||
return {
|
||||
email: locals.user.email.endsWith('@osu.invalid') ? null : locals.user.email,
|
||||
providers: authProviders,
|
||||
linkedProviders: accounts.map((account) => account.providerId),
|
||||
oauthError: url.searchParams.has('error')
|
||||
};
|
||||
};
|
||||
72
src/routes/account/+page.svelte
Normal file
72
src/routes/account/+page.svelte
Normal file
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { authClient } from '$lib/auth-client';
|
||||
import type { PageProps } from './$types';
|
||||
let { data }: PageProps = $props();
|
||||
let busy = $state(false);
|
||||
let message = $state('');
|
||||
async function link(provider: 'google' | 'osu') {
|
||||
busy = true;
|
||||
message = '';
|
||||
try {
|
||||
const result = await authClient.linkSocial({
|
||||
provider,
|
||||
callbackURL: '/account',
|
||||
errorCallbackURL: '/account'
|
||||
});
|
||||
if (result.error) {
|
||||
message = result.error.message || 'Could not link this account.';
|
||||
busy = false;
|
||||
}
|
||||
} catch {
|
||||
message = 'Could not connect. Please try again.';
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="mx-auto max-w-xl p-6 space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Your account</h1>
|
||||
<p class="mt-2">Signed in as {data.user?.name}{data.email ? ` (${data.email})` : ''}.</p>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">Account ID: <code>{data.user?.id}</code></p>
|
||||
|
||||
<div class="rounded border p-4 space-y-3">
|
||||
<h2 class="font-bold">Sign-in methods</h2>
|
||||
<p class="text-sm text-gray-600">
|
||||
Link a provider here to use it with this account and keep your saved progress together.
|
||||
</p>
|
||||
{#if message || data.oauthError}<p role="alert" class="text-red-700">
|
||||
{message ||
|
||||
'Linking failed or was cancelled. The provider may already belong to another account.'}
|
||||
</p>{/if}
|
||||
{#if data.linkedProviders.includes('credential')}<p>Email and password connected</p>{/if}
|
||||
{#each ['google', 'osu'] as provider (provider)}
|
||||
{@const label = provider === 'google' ? 'Google' : 'osu!'}
|
||||
{#if data.linkedProviders.includes(provider)}<p>{label} connected</p>
|
||||
{:else if data.providers[provider as 'google' | 'osu']}<button
|
||||
class="block rounded border px-4 py-2 disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={() => link(provider as 'google' | 'osu')}>Connect {label}</button
|
||||
>{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{#if data.isAdmin}
|
||||
<div class="rounded border p-4 space-y-3">
|
||||
<h2 class="font-bold">Administration</h2>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<a class="underline" href={resolve('/blog')}>Blog articles</a><a
|
||||
class="underline"
|
||||
href={resolve('/lists')}>Lists</a
|
||||
><a class="underline" href={resolve('/projects/animaldex/admin')}>Animaldex moderation</a><a
|
||||
class="underline"
|
||||
href={resolve('/projects/progress')}>Progress</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<form method="POST" action="/logout">
|
||||
<button class="rounded border px-4 py-2">Log out</button>
|
||||
</form>
|
||||
</section>
|
||||
@@ -1,25 +1,3 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { z } from 'zod';
|
||||
|
||||
const loginSchema = z.object({ password: z.string().min(1) });
|
||||
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = loginSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success || parsed.data.password !== env.ADMIN_SECRET) {
|
||||
throw error(401, { message: 'Invalid password' });
|
||||
}
|
||||
|
||||
cookies.set('admin_token', env.ADMIN_SECRET, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 60 * 60 * 24 * 7
|
||||
});
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
export const GET = () => redirect(303, '/login');
|
||||
export const POST = () => error(410, 'Use the central account login at /login');
|
||||
|
||||
@@ -20,7 +20,8 @@ export const load: PageServerLoad = async (event) => {
|
||||
|
||||
return {
|
||||
blogArticles: articles,
|
||||
isAdmin: admin
|
||||
isAdmin: admin,
|
||||
now: Date.now()
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,125 +1,111 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let createNewArticlePopup = $state(false);
|
||||
let loginPopup = $state(false);
|
||||
let password = $state('');
|
||||
let loginError = $state('');
|
||||
let loginLoading = $state(false);
|
||||
|
||||
async function login() {
|
||||
loginLoading = true;
|
||||
loginError = '';
|
||||
const res = await fetch('/admin/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
if (res.ok) {
|
||||
loginPopup = false;
|
||||
password = '';
|
||||
await invalidateAll();
|
||||
} else {
|
||||
loginError = 'Wrong password';
|
||||
}
|
||||
loginLoading = false;
|
||||
}
|
||||
import { calendarDate, relativeTime } from '$lib/relative-time';
|
||||
import type { PageProps } from './$types';
|
||||
let { data, form }: PageProps = $props();
|
||||
let searchQuery = $state('');
|
||||
let selectedTag = $state('');
|
||||
let tags = $derived([...new Set(data.blogArticles.flatMap((article) => article.tags))].sort());
|
||||
let visibleArticles = $derived(
|
||||
data.blogArticles.filter((article) => {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
return (
|
||||
(!selectedTag || article.tags.includes(selectedTag)) &&
|
||||
(!query ||
|
||||
`${article.title} ${article.content} ${article.tags.join(' ')}`
|
||||
.toLowerCase()
|
||||
.includes(query))
|
||||
);
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="text-center py-8 gap-2 flex align-center justify-center">
|
||||
<h1 class="text-2xl">Blog (Stan's yapping corner)</h1>
|
||||
{#if data.isAdmin}
|
||||
<button
|
||||
onclick={() => (createNewArticlePopup = true)}
|
||||
class="bg-gray-800 rounded px-4 py-2 text-white font-bold cursor-pointer"
|
||||
>Write new article</button
|
||||
>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (loginPopup = true)}
|
||||
class="text-xs text-gray-400 underline cursor-pointer">Admin login</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
{#each data.blogArticles as article (article.slug)}
|
||||
<a href={resolve('/blog/[slug]', { slug: article.slug })}>
|
||||
<div class="flex gap-2 py-2 px-4 border rounded min-w-100">
|
||||
<p class="font-bold text-md">{article.title}</p>
|
||||
{#if data.isAdmin && article.status === 'draft'}
|
||||
<span class="text-xs uppercase text-orange-500">draft</span>
|
||||
<a href={resolve('/blog/[slug]/edit', { slug: article.slug })} class="text-xs underline"
|
||||
>edit</a
|
||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||
<div class="mb-10 flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">blog</h1>
|
||||
{#if data.isAdmin}<details class="relative">
|
||||
<summary class="cursor-pointer text-base underline underline-offset-4">new article</summary>
|
||||
<form
|
||||
method="POST"
|
||||
use:enhance
|
||||
class="absolute top-full right-0 z-10 mt-3 w-72 max-w-[calc(100vw-2rem)] space-y-3 rounded-lg border border-[var(--site-border)] bg-[var(--site-surface)] p-4 shadow-lg"
|
||||
>
|
||||
<label class="block"
|
||||
>title<input
|
||||
name="title"
|
||||
required
|
||||
maxlength="256"
|
||||
class="mt-2 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
|
||||
/></label
|
||||
>
|
||||
{/if}
|
||||
<p>{article.createdAt?.toDateString()}</p>
|
||||
<button
|
||||
class="rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)]"
|
||||
>create draft</button
|
||||
>
|
||||
</form>
|
||||
</details>{/if}
|
||||
</div>
|
||||
{#if form?.message}<p role="alert" class="mb-5 text-red-500">{form.message}</p>{/if}
|
||||
<div class="mb-8 space-y-4">
|
||||
<label class="block max-w-lg">
|
||||
<span class="sr-only">Search articles</span>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="search articles"
|
||||
bind:value={searchQuery}
|
||||
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-4 py-3 text-base"
|
||||
/>
|
||||
</label>
|
||||
{#if tags.length}
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-2" aria-label="Filter by tag">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selectedTag = '')}
|
||||
aria-pressed={!selectedTag}
|
||||
class={selectedTag ? 'text-[var(--site-muted)]' : 'underline underline-offset-4'}
|
||||
>all</button
|
||||
>
|
||||
{#each tags as tag (tag)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selectedTag = selectedTag === tag ? '' : tag)}
|
||||
aria-pressed={selectedTag === tag}
|
||||
class={selectedTag === tag
|
||||
? 'underline underline-offset-4'
|
||||
: 'text-[var(--site-muted)]'}>{tag}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<ul>
|
||||
{#each visibleArticles as article (article.id)}
|
||||
<li class="border-t border-[var(--site-border)] py-5">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1">
|
||||
<a
|
||||
href={resolve('/blog/[slug]', { slug: article.slug })}
|
||||
class="text-lg font-medium hover:text-[var(--site-muted)]">{article.title}</a
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-x-4 gap-y-1 text-base text-[var(--site-muted)]"
|
||||
>
|
||||
{#if data.isAdmin && article.status === 'draft'}<span>draft</span>{/if}
|
||||
{#each article.tags as tag (tag)}<span>{tag}</span>{/each}
|
||||
<time datetime={article.createdAt.toISOString()} title={calendarDate(article.createdAt)}
|
||||
>{relativeTime(article.createdAt, data.now)}</time
|
||||
>
|
||||
{#if data.isAdmin}<a
|
||||
href={resolve('/blog/[slug]/edit', { slug: article.slug })}
|
||||
class="hover:text-[var(--site-text)]">edit</a
|
||||
>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="border-t border-[var(--site-border)] py-5 text-[var(--site-muted)]">
|
||||
{data.blogArticles.length ? 'no matching articles' : 'no articles yet'}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{#if createNewArticlePopup}
|
||||
<div class="fixed inset-0 flex items-center justify-center bg-black/40">
|
||||
<form method="POST" use:enhance class="w-80 rounded bg-white p-6 shadow-xl">
|
||||
<h2 class="mb-4 text-lg font-bold">Create New Article</h2>
|
||||
<input
|
||||
name="title"
|
||||
placeholder="Article title"
|
||||
required
|
||||
class="mb-4 w-full rounded border border-gray-300 px-3 py-2 text-sm outline-none"
|
||||
/>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (createNewArticlePopup = false)}
|
||||
class="rounded px-3 py-2 text-sm cursor-pointer">Cancel</button
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-gray-800 px-3 py-2 text-sm font-bold text-white cursor-pointer"
|
||||
>Create draft</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loginPopup}
|
||||
<div class="fixed inset-0 flex items-center justify-center bg-black/40">
|
||||
<div class="w-80 rounded bg-white p-6 shadow-xl">
|
||||
<h2 class="mb-4 text-lg font-bold">Admin login</h2>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={password}
|
||||
placeholder="Password"
|
||||
onkeydown={(e) => e.key === 'Enter' && login()}
|
||||
class="mb-3 w-full rounded border border-gray-300 px-3 py-2 text-sm outline-none"
|
||||
/>
|
||||
{#if loginError}
|
||||
<p class="mb-3 text-xs text-red-500">{loginError}</p>
|
||||
{/if}
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
loginPopup = false;
|
||||
loginError = '';
|
||||
}}
|
||||
class="rounded px-3 py-2 text-sm cursor-pointer">Cancel</button
|
||||
>
|
||||
<button
|
||||
onclick={login}
|
||||
disabled={loginLoading}
|
||||
class="rounded bg-gray-800 px-3 py-2 text-sm font-bold text-white disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
|
||||
>
|
||||
{loginLoading ? 'Logging in…' : 'Log in'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { calendarDate } from '$lib/relative-time';
|
||||
import 'highlight.js/styles/github-dark.css';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{data.blogArticle.title} - Blog</title></svelte:head>
|
||||
|
||||
<article class="mx-auto max-w-2xl px-4 py-8">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold">{data.blogArticle.title}</h1>
|
||||
<div class="mb-6 flex items-center justify-between gap-4">
|
||||
<h1 class="text-3xl font-semibold tracking-tight sm:text-4xl">{data.blogArticle.title}</h1>
|
||||
{#if data.isAdmin}
|
||||
<a
|
||||
href={resolve('/blog/[slug]/edit', { slug: data.blogArticle.slug })}
|
||||
class="text-sm underline">Edit</a
|
||||
class="text-base underline">edit</a
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mb-6 text-sm text-gray-500">{data.blogArticle.createdAt?.toDateString()}</p>
|
||||
<div class="prose prose-neutral max-w-none">
|
||||
<div class="mb-8 flex flex-wrap gap-x-4 gap-y-1 text-base text-[var(--site-muted)]">
|
||||
<time datetime={data.blogArticle.createdAt.toISOString()}
|
||||
>{calendarDate(data.blogArticle.createdAt)}</time
|
||||
>
|
||||
{#each data.blogArticle.tags as tag (tag)}<span>{tag}</span>{/each}
|
||||
</div>
|
||||
<div class="article-content prose max-w-none">
|
||||
<!-- Only administrators can author blog Markdown. -->
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html data.contentHtml}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { blogArticles } from '$lib/server/db/schema';
|
||||
import { isAdmin, requireAdmin } from '$lib/server/auth';
|
||||
import { updateArticleSchema } from '$lib/validation/blog';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { parseArticleTags, updateArticleSchema } from '$lib/validation/blog';
|
||||
import { validSlug } from '$lib/server/slugify';
|
||||
import { and, eq, ne } from 'drizzle-orm';
|
||||
import { error, fail, redirect, type RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
|
||||
@@ -26,59 +27,81 @@ export const load: PageServerLoad = async (event) => {
|
||||
};
|
||||
};
|
||||
|
||||
async function readArticleForm(event: RequestEvent) {
|
||||
const formData = await event.request.formData();
|
||||
const parsed = updateArticleSchema.safeParse({
|
||||
title: formData.get('title')?.toString().trim(),
|
||||
slug: formData.get('slug')?.toString().trim().toLowerCase(),
|
||||
content: formData.get('content')?.toString(),
|
||||
tags: parseArticleTags(formData.get('tags')?.toString() ?? '')
|
||||
});
|
||||
if (!parsed.success || !validSlug(parsed.data.slug)) return null;
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
save: async (event) => {
|
||||
requireAdmin(event);
|
||||
|
||||
const formData = await event.request.formData();
|
||||
const parsed = updateArticleSchema.safeParse({
|
||||
title: formData.get('title')?.toString(),
|
||||
content: formData.get('content')?.toString()
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return fail(400, { message: 'Title and content are required' });
|
||||
}
|
||||
const values = await readArticleForm(event);
|
||||
if (!values)
|
||||
return fail(400, {
|
||||
message: 'Enter a title, a lowercase slug, and up to 8 tags of 32 characters each.'
|
||||
});
|
||||
const current = await db.query.blogArticles.findFirst({ where: { slug: event.params.slug } });
|
||||
if (!current) error(404, 'Article not found');
|
||||
const duplicate = await db
|
||||
.select({ id: blogArticles.id })
|
||||
.from(blogArticles)
|
||||
.where(and(eq(blogArticles.slug, values.slug), ne(blogArticles.id, current.id)));
|
||||
if (duplicate.length) return fail(409, { message: 'That article slug is already in use.' });
|
||||
|
||||
const [updated] = await db
|
||||
.update(blogArticles)
|
||||
.set({
|
||||
title: parsed.data.title,
|
||||
content: parsed.data.content,
|
||||
title: values.title,
|
||||
slug: values.slug,
|
||||
content: values.content,
|
||||
tags: values.tags,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(blogArticles.slug, event.params.slug))
|
||||
.where(eq(blogArticles.id, current.id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return error(404, { message: 'Article not found' });
|
||||
}
|
||||
|
||||
if (values.slug !== event.params.slug) redirect(303, `/blog/${values.slug}/edit`);
|
||||
return { saved: true };
|
||||
},
|
||||
|
||||
publish: async (event) => {
|
||||
requireAdmin(event);
|
||||
|
||||
const formData = await event.request.formData();
|
||||
const parsed = updateArticleSchema.safeParse({
|
||||
title: formData.get('title')?.toString(),
|
||||
content: formData.get('content')?.toString()
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return fail(400, { message: 'Title and content are required' });
|
||||
}
|
||||
const values = await readArticleForm(event);
|
||||
if (!values)
|
||||
return fail(400, {
|
||||
message: 'Enter a title, a lowercase slug, and up to 8 tags of 32 characters each.'
|
||||
});
|
||||
const current = await db.query.blogArticles.findFirst({ where: { slug: event.params.slug } });
|
||||
if (!current) error(404, 'Article not found');
|
||||
const duplicate = await db
|
||||
.select({ id: blogArticles.id })
|
||||
.from(blogArticles)
|
||||
.where(and(eq(blogArticles.slug, values.slug), ne(blogArticles.id, current.id)));
|
||||
if (duplicate.length) return fail(409, { message: 'That article slug is already in use.' });
|
||||
|
||||
const [updated] = await db
|
||||
.update(blogArticles)
|
||||
.set({
|
||||
title: parsed.data.title,
|
||||
content: parsed.data.content,
|
||||
title: values.title,
|
||||
slug: values.slug,
|
||||
content: values.content,
|
||||
tags: values.tags,
|
||||
status: 'published',
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(blogArticles.slug, event.params.slug))
|
||||
.where(eq(blogArticles.id, current.id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
@@ -86,5 +109,15 @@ export const actions = {
|
||||
}
|
||||
|
||||
return redirect(303, `/blog/${updated.slug}`);
|
||||
},
|
||||
delete: async (event) => {
|
||||
requireAdmin(event);
|
||||
const current = await db.query.blogArticles.findFirst({ where: { slug: event.params.slug } });
|
||||
if (!current) error(404, 'Article not found');
|
||||
const confirmation = String((await event.request.formData()).get('confirmTitle') ?? '');
|
||||
if (confirmation !== current.title)
|
||||
return fail(400, { message: 'Type the exact article title to confirm deletion.' });
|
||||
await db.delete(blogArticles).where(eq(blogArticles.id, current.id));
|
||||
redirect(303, '/blog');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,30 +4,43 @@
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
let title = $state(data.blogArticle.title);
|
||||
let content = $state(data.blogArticle.content ?? '');
|
||||
let title = $state('');
|
||||
let slug = $state('');
|
||||
let tags = $state('');
|
||||
let content = $state('');
|
||||
let saving = $state(false);
|
||||
let loadedSlug = '';
|
||||
$effect(() => {
|
||||
const article = data.blogArticle;
|
||||
if (article.slug === loadedSlug) return;
|
||||
loadedSlug = article.slug;
|
||||
title = article.title;
|
||||
slug = article.slug;
|
||||
tags = article.tags.join(', ');
|
||||
content = article.content ?? '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Editing {data.blogArticle.title}</title></svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl px-4 py-8">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold">
|
||||
Editing article
|
||||
<span
|
||||
class="ml-2 rounded px-2 py-0.5 text-xs uppercase {data.blogArticle.status === 'published'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-orange-100 text-orange-700'}">{data.blogArticle.status}</span
|
||||
<div class="mx-auto max-w-5xl px-4 py-10 sm:px-6 sm:py-16">
|
||||
<a
|
||||
href={resolve('/blog')}
|
||||
class="text-base text-[var(--site-muted)] hover:text-[var(--site-text)]">← blog</a
|
||||
>
|
||||
<div class="mt-5 mb-8 flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-semibold tracking-tight sm:text-4xl">edit article</h1>
|
||||
<p class="mt-1 text-base text-[var(--site-muted)]">{data.blogArticle.status}</p>
|
||||
</div>
|
||||
{#if data.blogArticle.status === 'published'}
|
||||
<a
|
||||
href={resolve('/blog/[slug]', { slug: data.blogArticle.slug })}
|
||||
class="text-base underline underline-offset-4">view article ↗</a
|
||||
>
|
||||
</h1>
|
||||
<a href={resolve('/blog/[slug]', { slug: data.blogArticle.slug })} class="text-sm underline"
|
||||
>View</a
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if form?.message}
|
||||
<p class="mb-3 text-sm text-red-500">{form.message}</p>
|
||||
<p role="alert" class="mb-5 text-base text-red-500">{form.message}</p>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
@@ -40,36 +53,80 @@
|
||||
saving = false;
|
||||
};
|
||||
}}
|
||||
class="flex flex-col gap-3"
|
||||
class="space-y-6"
|
||||
>
|
||||
<input
|
||||
name="title"
|
||||
bind:value={title}
|
||||
required
|
||||
class="rounded border border-gray-300 px-3 py-2 text-lg font-bold outline-none"
|
||||
/>
|
||||
<textarea
|
||||
name="content"
|
||||
bind:value={content}
|
||||
rows="20"
|
||||
class="rounded border border-gray-300 px-3 py-2 font-body text-base leading-relaxed text-gray-900 outline-none"
|
||||
></textarea>
|
||||
<p class="-mt-2 text-xs text-gray-400">Supports Markdown formatting.</p>
|
||||
<label class="block text-base font-medium"
|
||||
>title<input
|
||||
name="title"
|
||||
bind:value={title}
|
||||
required
|
||||
maxlength="256"
|
||||
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-4 py-3 text-xl font-medium"
|
||||
/></label
|
||||
>
|
||||
<div class="grid gap-6 sm:grid-cols-2">
|
||||
<label class="block text-base font-medium"
|
||||
>slug<input
|
||||
name="slug"
|
||||
bind:value={slug}
|
||||
required
|
||||
maxlength="120"
|
||||
pattern="[a-z0-9]+(-[a-z0-9]+)*"
|
||||
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-4 py-3 font-mono text-base"
|
||||
/></label
|
||||
>
|
||||
<label class="block text-base font-medium"
|
||||
>tags<input
|
||||
name="tags"
|
||||
bind:value={tags}
|
||||
placeholder="comma-separated tags"
|
||||
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-4 py-3 text-base"
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
<label class="block text-base font-medium"
|
||||
>content <span class="font-normal text-[var(--site-muted)]">· markdown</span>
|
||||
<textarea
|
||||
name="content"
|
||||
bind:value={content}
|
||||
rows="24"
|
||||
class="mt-2 block min-h-96 w-full resize-y rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-4 py-3 font-mono text-base leading-relaxed text-[var(--site-text)]"
|
||||
></textarea></label
|
||||
>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<div class="flex flex-wrap justify-end gap-3 border-t border-[var(--site-border)] pt-5">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
class="rounded bg-gray-800 px-4 py-2 text-sm font-bold text-white disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
|
||||
>Save draft</button
|
||||
class="rounded border border-[var(--site-border)] px-5 py-2.5 text-base disabled:opacity-50"
|
||||
>save</button
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
formaction="?/publish"
|
||||
disabled={saving}
|
||||
class="rounded bg-green-700 px-4 py-2 text-sm font-bold text-white disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
|
||||
>Publish</button
|
||||
class="rounded bg-[var(--site-text)] px-5 py-2.5 text-base font-medium text-[var(--site-bg)] disabled:opacity-50"
|
||||
>{data.blogArticle.status === 'published' ? 'update published article' : 'publish'}</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
<details class="mt-12 border-t border-[var(--site-border)] pt-5">
|
||||
<summary class="cursor-pointer text-base text-[var(--site-muted)]">delete article</summary>
|
||||
<form method="POST" action="?/delete" class="mt-3 space-y-3">
|
||||
<p class="text-base">
|
||||
This permanently deletes the article. Type <strong>{data.blogArticle.title}</strong> to confirm.
|
||||
</p>
|
||||
<label class="block text-base"
|
||||
>article title<input
|
||||
name="confirmTitle"
|
||||
required
|
||||
autocomplete="off"
|
||||
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
|
||||
/></label
|
||||
>
|
||||
<button class="rounded border border-red-500 px-4 py-2 text-base text-red-500"
|
||||
>delete article</button
|
||||
>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
47
src/routes/lists/+page.server.ts
Normal file
47
src/routes/lists/+page.server.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { listItemCompletions, lists } from '$lib/server/db/schema';
|
||||
import { requireAdmin } from '$lib/server/authorization';
|
||||
import { uniqueListSlug } from '$lib/server/slugify';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const rows = await db.query.lists.findMany({
|
||||
with: { items: { columns: { id: true } } },
|
||||
orderBy: { id: 'desc' }
|
||||
});
|
||||
const completed = new Set(
|
||||
locals.user
|
||||
? (
|
||||
await db
|
||||
.select({ itemId: listItemCompletions.itemId })
|
||||
.from(listItemCompletions)
|
||||
.where(eq(listItemCompletions.userId, locals.user.id))
|
||||
).map((row) => row.itemId)
|
||||
: []
|
||||
);
|
||||
return {
|
||||
lists: rows.map(({ items, ...list }) => ({
|
||||
...list,
|
||||
total: items.length,
|
||||
checked: list.isProgress ? items.filter((item) => completed.has(item.id)).length : 0
|
||||
}))
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
newList: async (event) => {
|
||||
requireAdmin(event);
|
||||
const form = await event.request.formData();
|
||||
const name = String(form.get('name') ?? '').trim();
|
||||
const type = String(form.get('type') ?? '');
|
||||
if (!name || name.length > 200)
|
||||
return fail(400, { message: 'Enter a list name of 1–200 characters.' });
|
||||
if (type !== 'normal' && type !== 'progress')
|
||||
return fail(400, { message: 'Choose a list type.' });
|
||||
const slug = await uniqueListSlug(name);
|
||||
await db.insert(lists).values({ name, slug, isProgress: type === 'progress' });
|
||||
redirect(303, `/lists/${slug}`);
|
||||
}
|
||||
};
|
||||
78
src/routes/lists/+page.svelte
Normal file
78
src/routes/lists/+page.svelte
Normal file
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
import ProgressBar from '$lib/components/ProgressBar.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
let { data, form }: PageProps = $props();
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||
<div class="mb-10 flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">lists</h1>
|
||||
{#if data.isAdmin}<details class="relative">
|
||||
<summary class="cursor-pointer text-base underline underline-offset-4">new list</summary>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/newList"
|
||||
use:enhance
|
||||
class="absolute top-full right-0 z-10 mt-3 w-80 max-w-[calc(100vw-2rem)] space-y-3 rounded-lg border border-[var(--site-border)] bg-[var(--site-surface)] p-4 shadow-lg"
|
||||
>
|
||||
<label class="block"
|
||||
>name<input
|
||||
name="name"
|
||||
required
|
||||
maxlength="200"
|
||||
class="mt-2 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
|
||||
/></label
|
||||
>
|
||||
<label class="block"
|
||||
>type<select
|
||||
name="type"
|
||||
class="mt-2 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
|
||||
><option value="normal">normal</option><option value="progress">progress</option
|
||||
></select
|
||||
></label
|
||||
>
|
||||
<button
|
||||
class="rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)]"
|
||||
>create list</button
|
||||
>
|
||||
</form>
|
||||
</details>{/if}
|
||||
</div>
|
||||
{#if form?.message}<p role="alert" class="mb-5 text-red-500">{form.message}</p>{/if}
|
||||
<ul>
|
||||
{#each data.lists as list (list.id)}
|
||||
<li class="border-t border-[var(--site-border)]">
|
||||
<a
|
||||
href={resolve('/lists/[slug]', { slug: list.slug })}
|
||||
class="block py-5 hover:text-[var(--site-muted)]"
|
||||
>
|
||||
<span class="flex flex-wrap items-center justify-between gap-x-6 gap-y-1">
|
||||
<span class="text-lg font-medium">{list.name}</span>
|
||||
<span class="text-base text-[var(--site-muted)]">
|
||||
{#if list.isProgress && data.user}
|
||||
{list.total ? Math.round((list.checked / list.total) * 100) : 0}% · {list.checked}/{list.total}
|
||||
{:else}
|
||||
{list.total}
|
||||
{list.total === 1 ? 'item' : 'items'}{list.isProgress ? ' · progress' : ''}
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
{#if list.isProgress}<span class="mt-3 block"
|
||||
><ProgressBar
|
||||
value={list.checked}
|
||||
max={list.total}
|
||||
label={`Progress on ${list.name}`}
|
||||
small
|
||||
/></span
|
||||
>{/if}
|
||||
</a>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="border-t border-[var(--site-border)] py-5 text-[var(--site-muted)]">
|
||||
no lists yet
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
178
src/routes/lists/[slug]/+page.server.ts
Normal file
178
src/routes/lists/[slug]/+page.server.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { lists, listItems, listItemCompletions } from '$lib/server/db/schema';
|
||||
import { requireAdmin, requireUser } from '$lib/server/authorization';
|
||||
import { deleteObject, listImageKey, listImageUrl } from '$lib/server/r2';
|
||||
import { validSlug } from '$lib/server/slugify';
|
||||
import { and, eq, ne } from 'drizzle-orm';
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
async function currentList(slug: string) {
|
||||
const list = await db.query.lists.findFirst({ where: { slug } });
|
||||
if (!list) error(404, 'List not found');
|
||||
return list;
|
||||
}
|
||||
async function removeStoredPicture(url: string | null) {
|
||||
const key = url ? listImageKey(url) : null;
|
||||
if (!key) return;
|
||||
try {
|
||||
await deleteObject(key);
|
||||
} catch (cause) {
|
||||
console.error('Could not remove list picture from R2', cause);
|
||||
}
|
||||
}
|
||||
function itemFields(form: FormData) {
|
||||
const name = String(form.get('name') ?? '').trim();
|
||||
const description = String(form.get('description') ?? '').trim();
|
||||
const pictureKey = String(form.get('pictureKey') ?? '').trim();
|
||||
const removePicture = form.get('removePicture') === 'on';
|
||||
const file = form.get('picture');
|
||||
if (!name || name.length > 500) return { error: 'Enter an item name of 1–500 characters.' };
|
||||
if (description.length > 5000) return { error: 'Description must be 5,000 characters or less.' };
|
||||
if (file instanceof File && file.size > 0)
|
||||
return { error: 'Enable JavaScript to upload an image.' };
|
||||
if (pictureKey && removePicture) return { error: 'Choose a new picture or remove the old one.' };
|
||||
const pictureUrl = pictureKey ? listImageUrl(pictureKey) : null;
|
||||
if (pictureKey && !pictureUrl) return { error: 'Invalid uploaded picture.' };
|
||||
return {
|
||||
values: {
|
||||
name,
|
||||
description: description || null,
|
||||
...(pictureKey ? { pictureUrl } : removePicture ? { pictureUrl: null } : {})
|
||||
}
|
||||
};
|
||||
}
|
||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
if (/^[1-9]\d*$/.test(params.slug)) {
|
||||
const oldId = Number(params.slug);
|
||||
if (Number.isSafeInteger(oldId)) {
|
||||
const oldList = await db.query.lists.findFirst({ where: { id: oldId } });
|
||||
if (oldList) redirect(308, `/lists/${oldList.slug}`);
|
||||
}
|
||||
}
|
||||
const list = await db.query.lists.findFirst({
|
||||
where: { slug: params.slug },
|
||||
with: { items: { orderBy: { id: 'asc' } } }
|
||||
});
|
||||
if (!list) error(404, 'List not found');
|
||||
const completedIds =
|
||||
locals.user && list.isProgress
|
||||
? (
|
||||
await db
|
||||
.select({ itemId: listItemCompletions.itemId })
|
||||
.from(listItemCompletions)
|
||||
.where(eq(listItemCompletions.userId, locals.user.id))
|
||||
).map((row) => row.itemId)
|
||||
: [];
|
||||
const inList = completedIds.filter((id) => list.items.some((item) => item.id === id));
|
||||
return { list, completedIds: inList, checked: inList.length, total: list.items.length };
|
||||
};
|
||||
export const actions: Actions = {
|
||||
deleteList: async (event) => {
|
||||
requireAdmin(event);
|
||||
const list = await currentList(event.params.slug);
|
||||
const confirmation = String((await event.request.formData()).get('confirmName') ?? '');
|
||||
if (confirmation !== list.name)
|
||||
return fail(400, { message: 'Type the exact list name to confirm deletion.' });
|
||||
const pictures = await db
|
||||
.select({ pictureUrl: listItems.pictureUrl })
|
||||
.from(listItems)
|
||||
.where(eq(listItems.listId, list.id));
|
||||
await db.delete(lists).where(eq(lists.id, list.id));
|
||||
await Promise.all(pictures.map((item) => removeStoredPicture(item.pictureUrl)));
|
||||
redirect(303, '/lists');
|
||||
},
|
||||
updateList: async (event) => {
|
||||
requireAdmin(event);
|
||||
const form = await event.request.formData();
|
||||
const name = String(form.get('name') ?? '').trim();
|
||||
const slug = String(form.get('slug') ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const type = String(form.get('type') ?? '');
|
||||
if (!name || name.length > 200)
|
||||
return fail(400, { message: 'Enter a name of 1–200 characters' });
|
||||
if (type !== 'normal' && type !== 'progress')
|
||||
return fail(400, { message: 'Choose a list type.' });
|
||||
if (!validSlug(slug))
|
||||
return fail(400, { message: 'Use a lowercase slug with letters, numbers, and hyphens.' });
|
||||
const current = await currentList(event.params.slug);
|
||||
const duplicate = await db
|
||||
.select({ id: lists.id })
|
||||
.from(lists)
|
||||
.where(and(eq(lists.slug, slug), ne(lists.id, current.id)));
|
||||
if (duplicate.length) return fail(409, { message: 'That list slug is already in use.' });
|
||||
const [list] = await db
|
||||
.update(lists)
|
||||
.set({ name, slug, isProgress: type === 'progress' })
|
||||
.where(eq(lists.id, current.id))
|
||||
.returning();
|
||||
if (!list) error(404, 'List not found');
|
||||
if (slug !== event.params.slug) redirect(303, `/lists/${slug}`);
|
||||
return { saved: true };
|
||||
},
|
||||
addItem: async (event) => {
|
||||
requireAdmin(event);
|
||||
const fields = itemFields(await event.request.formData());
|
||||
if (fields.error) return fail(400, { message: fields.error });
|
||||
const list = await currentList(event.params.slug);
|
||||
await db.insert(listItems).values({ ...fields.values!, listId: list.id });
|
||||
return { saved: true };
|
||||
},
|
||||
updateItem: async (event) => {
|
||||
requireAdmin(event);
|
||||
const form = await event.request.formData();
|
||||
const id = Number(form.get('itemId'));
|
||||
if (!Number.isSafeInteger(id) || id <= 0) return fail(400, { message: 'Invalid item.' });
|
||||
const fields = itemFields(form);
|
||||
if (fields.error) return fail(400, { message: fields.error });
|
||||
const list = await currentList(event.params.slug);
|
||||
const existing = await db.query.listItems.findFirst({ where: { id, listId: list.id } });
|
||||
if (!existing) error(404, 'Item not found');
|
||||
const [item] = await db
|
||||
.update(listItems)
|
||||
.set(fields.values!)
|
||||
.where(and(eq(listItems.id, id), eq(listItems.listId, list.id)))
|
||||
.returning({ id: listItems.id });
|
||||
if (!item) error(404, 'Item not found');
|
||||
if ('pictureUrl' in fields.values! && fields.values!.pictureUrl !== existing.pictureUrl)
|
||||
await removeStoredPicture(existing.pictureUrl);
|
||||
return { saved: true };
|
||||
},
|
||||
removeItem: async (event) => {
|
||||
requireAdmin(event);
|
||||
const id = Number((await event.request.formData()).get('itemId'));
|
||||
if (!Number.isSafeInteger(id) || id <= 0) return fail(400, { message: 'Invalid item' });
|
||||
const list = await currentList(event.params.slug);
|
||||
const [item] = await db
|
||||
.delete(listItems)
|
||||
.where(and(eq(listItems.id, id), eq(listItems.listId, list.id)))
|
||||
.returning({ pictureUrl: listItems.pictureUrl });
|
||||
if (!item) error(404, 'Item not found');
|
||||
await removeStoredPicture(item.pictureUrl);
|
||||
return { saved: true };
|
||||
},
|
||||
setCompletion: async (event) => {
|
||||
const user = requireUser(event);
|
||||
const list = await currentList(event.params.slug);
|
||||
if (!list.isProgress) return fail(400, { message: 'This is not a progress list.' });
|
||||
const form = await event.request.formData();
|
||||
const id = Number(form.get('itemId'));
|
||||
const completed = form.get('completed');
|
||||
if (!Number.isSafeInteger(id) || id <= 0 || (completed !== 'yes' && completed !== 'no'))
|
||||
return fail(400, { message: 'Invalid progress change.' });
|
||||
const item = await db.query.listItems.findFirst({ where: { id, listId: list.id } });
|
||||
if (!item) error(404, 'Item not found');
|
||||
if (completed === 'yes') {
|
||||
await db
|
||||
.insert(listItemCompletions)
|
||||
.values({ itemId: id, userId: user.id })
|
||||
.onConflictDoNothing();
|
||||
} else {
|
||||
await db
|
||||
.delete(listItemCompletions)
|
||||
.where(and(eq(listItemCompletions.itemId, id), eq(listItemCompletions.userId, user.id)));
|
||||
}
|
||||
return { saved: true };
|
||||
}
|
||||
};
|
||||
265
src/routes/lists/[slug]/+page.svelte
Normal file
265
src/routes/lists/[slug]/+page.svelte
Normal file
@@ -0,0 +1,265 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { enhance } from '$app/forms';
|
||||
import ListPictureDropzone from '$lib/components/ListPictureDropzone.svelte';
|
||||
import ProgressBar from '$lib/components/ProgressBar.svelte';
|
||||
import type { SubmitFunction } from '@sveltejs/kit';
|
||||
import type { PageProps } from './$types';
|
||||
let { data, form }: PageProps = $props();
|
||||
let completed = $derived(new Set(data.completedIds));
|
||||
let uploadError = $state('');
|
||||
let uploading = $state(false);
|
||||
let managing = $state(false);
|
||||
const uploadPicture: SubmitFunction = async ({ formData, cancel }) => {
|
||||
uploadError = '';
|
||||
const picture = formData.get('picture');
|
||||
formData.delete('picture');
|
||||
if (!(picture instanceof File) || picture.size === 0) return;
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(picture.type)) {
|
||||
uploadError = 'Choose a JPEG, PNG, or WebP image.';
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
if (picture.size > 5 * 1024 * 1024) {
|
||||
uploadError = 'Images must be 5 MB or smaller.';
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
uploading = true;
|
||||
try {
|
||||
const uploadForm = new FormData();
|
||||
uploadForm.set('picture', picture);
|
||||
const uploaded = await fetch(resolve('/lists/api/upload'), {
|
||||
method: 'POST',
|
||||
body: uploadForm
|
||||
});
|
||||
if (!uploaded.ok) throw new Error('Could not upload the image. Please try again.');
|
||||
const { key } = await uploaded.json();
|
||||
formData.set('pictureKey', key);
|
||||
} catch (cause) {
|
||||
uploadError = cause instanceof Error ? cause.message : 'Image upload failed.';
|
||||
cancel();
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="mx-auto max-w-4xl space-y-6 px-4 py-10 sm:px-6 sm:py-14">
|
||||
<a
|
||||
href={resolve('/lists')}
|
||||
class="text-base text-[var(--site-muted)] hover:text-[var(--site-text)]">← lists</a
|
||||
>
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">{data.list.name}</h1>
|
||||
<p class="mt-2 text-base text-[var(--site-muted)]">
|
||||
{data.total}
|
||||
{data.total === 1 ? 'item' : 'items'}
|
||||
</p>
|
||||
</div>
|
||||
{#if data.isAdmin}<button
|
||||
type="button"
|
||||
onclick={() => (managing = !managing)}
|
||||
aria-pressed={managing}
|
||||
class="text-base underline underline-offset-4">{managing ? 'done' : 'manage list'}</button
|
||||
>{/if}
|
||||
</div>
|
||||
{#if form?.message}<p role="alert" class="text-red-700">{form.message}</p>{/if}
|
||||
{#if uploadError}<p role="alert" class="text-red-700">{uploadError}</p>{/if}
|
||||
{#if data.list.isProgress}
|
||||
{#if data.user}
|
||||
<div class="border-y border-[var(--site-border)] py-4">
|
||||
<p class="font-medium">
|
||||
{data.checked} of {data.total} checked · {data.total
|
||||
? Math.round((data.checked / data.total) * 100)
|
||||
: 0}%
|
||||
</p>
|
||||
<div class="mt-2">
|
||||
<ProgressBar value={data.checked} max={data.total} label="List progress" small />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p>
|
||||
<a class="underline" href={resolve(`/login?returnTo=/lists/${data.list.slug}`)}>Log in</a> to
|
||||
save your progress.
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if data.isAdmin && managing}
|
||||
<details class="border-b border-[var(--site-border)] pb-5">
|
||||
<summary class="cursor-pointer text-base text-[var(--site-muted)]">list settings</summary>
|
||||
<div class="mt-5 space-y-5">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateList"
|
||||
use:enhance
|
||||
class="grid gap-3 sm:grid-cols-2 sm:items-end"
|
||||
>
|
||||
<label
|
||||
>List name<input
|
||||
name="name"
|
||||
value={data.list.name}
|
||||
required
|
||||
maxlength="200"
|
||||
class="block w-full rounded border p-2"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Slug<input
|
||||
name="slug"
|
||||
value={data.list.slug}
|
||||
required
|
||||
maxlength="120"
|
||||
pattern="[a-z0-9]+(-[a-z0-9]+)*"
|
||||
class="block w-full rounded border p-2"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Type<select name="type" class="block w-full rounded border p-2">
|
||||
<option value="normal" selected={!data.list.isProgress}>Normal</option>
|
||||
<option value="progress" selected={data.list.isProgress}>Progress</option>
|
||||
</select></label
|
||||
>
|
||||
<button class="rounded border p-2">Save list</button>
|
||||
</form>
|
||||
<details class="border-t border-[var(--site-border)] pt-4">
|
||||
<summary class="cursor-pointer font-medium text-red-700">Delete this list</summary>
|
||||
<form method="POST" action="?/deleteList" class="mt-3 space-y-3">
|
||||
<p class="text-sm">
|
||||
This deletes the list, its items, and everyone’s saved progress. Type <strong
|
||||
>{data.list.name}</strong
|
||||
> to confirm.
|
||||
</p>
|
||||
<label class="block"
|
||||
>List name<input
|
||||
name="confirmName"
|
||||
required
|
||||
autocomplete="off"
|
||||
class="mt-1 block w-full rounded border p-2"
|
||||
/></label
|
||||
>
|
||||
<button class="rounded bg-red-700 px-4 py-2 text-white">Delete list</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
<ul class="divide-y divide-[var(--site-border)] border-t border-[var(--site-border)]">
|
||||
{#each data.list.items as item (item.id)}
|
||||
<li class="py-4">
|
||||
<div class="flex items-start gap-4">
|
||||
{#if item.pictureUrl}<img
|
||||
src={item.pictureUrl}
|
||||
alt={item.name}
|
||||
loading="lazy"
|
||||
width="64"
|
||||
height="64"
|
||||
class="h-16 w-16 shrink-0 rounded bg-[var(--site-surface)] object-cover"
|
||||
/>{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class:line-through={completed.has(item.id)} class="font-medium">{item.name}</p>
|
||||
{#if item.description}<p
|
||||
class="mt-1 whitespace-pre-wrap text-base text-[var(--site-muted)]"
|
||||
>
|
||||
{item.description}
|
||||
</p>{/if}
|
||||
</div>
|
||||
{#if data.list.isProgress && data.user}
|
||||
<form method="POST" action="?/setCompletion" use:enhance class="shrink-0">
|
||||
<input type="hidden" name="itemId" value={item.id} />
|
||||
<input type="hidden" name="completed" value={completed.has(item.id) ? 'no' : 'yes'} />
|
||||
<button
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full border border-[var(--site-border)] text-base"
|
||||
aria-label={`${completed.has(item.id) ? 'Uncheck' : 'Check'} ${item.name}`}
|
||||
>
|
||||
{completed.has(item.id) ? '✓' : ''}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
{#if data.isAdmin && managing}
|
||||
<details class="mt-3 border-t border-[var(--site-border)] pt-3">
|
||||
<summary class="cursor-pointer text-base underline">edit item</summary>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateItem"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={uploadPicture}
|
||||
class="mt-3 grid gap-3"
|
||||
>
|
||||
<input type="hidden" name="itemId" value={item.id} />
|
||||
<label
|
||||
>Name<input
|
||||
name="name"
|
||||
value={item.name}
|
||||
required
|
||||
maxlength="500"
|
||||
class="block w-full rounded border p-2"
|
||||
/></label
|
||||
>
|
||||
<ListPictureDropzone label="Replace picture (optional)" />
|
||||
{#if item.pictureUrl}<label class="text-sm"
|
||||
><input type="checkbox" name="removePicture" /> Remove current picture</label
|
||||
>{/if}
|
||||
<label
|
||||
>Description (optional)<textarea
|
||||
name="description"
|
||||
maxlength="5000"
|
||||
rows="3"
|
||||
class="block w-full rounded border p-2">{item.description ?? ''}</textarea
|
||||
></label
|
||||
>
|
||||
<button
|
||||
disabled={uploading}
|
||||
class="justify-self-start rounded bg-gray-900 px-4 py-2 text-white disabled:opacity-50"
|
||||
>Save item</button
|
||||
>
|
||||
</form>
|
||||
<form method="POST" action="?/removeItem" use:enhance class="mt-3">
|
||||
<input type="hidden" name="itemId" value={item.id} />
|
||||
<button class="text-sm text-red-700 underline" aria-label={`Remove ${item.name}`}
|
||||
>Remove item</button
|
||||
>
|
||||
</form>
|
||||
</details>
|
||||
{/if}
|
||||
</li>
|
||||
{:else}<li class="py-5 text-[var(--site-muted)]">no items yet</li>{/each}
|
||||
</ul>
|
||||
{#if data.isAdmin && managing}
|
||||
<details class="border-t border-[var(--site-border)] pt-5">
|
||||
<summary class="cursor-pointer font-medium">add item</summary>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/addItem"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={uploadPicture}
|
||||
class="mt-5 grid gap-3"
|
||||
>
|
||||
<label
|
||||
>Name<input
|
||||
name="name"
|
||||
required
|
||||
maxlength="500"
|
||||
class="block w-full rounded border p-2"
|
||||
/></label
|
||||
>
|
||||
<ListPictureDropzone />
|
||||
<label
|
||||
>Description (optional)<textarea
|
||||
name="description"
|
||||
maxlength="5000"
|
||||
rows="3"
|
||||
class="block w-full rounded border p-2"></textarea></label
|
||||
>
|
||||
<button
|
||||
disabled={uploading}
|
||||
class="justify-self-start rounded bg-gray-900 px-4 py-2 text-white disabled:opacity-50"
|
||||
>Add item</button
|
||||
>
|
||||
</form>
|
||||
</details>
|
||||
<noscript><p>Enable JavaScript to upload item pictures.</p></noscript>
|
||||
{/if}
|
||||
</section>
|
||||
24
src/routes/lists/api/upload/+server.ts
Normal file
24
src/routes/lists/api/upload/+server.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { requireAdmin } from '$lib/server/authorization';
|
||||
import { uploadImage } from '$lib/server/r2';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
requireAdmin(event);
|
||||
const form = await event.request.formData().catch(() => null);
|
||||
const picture = form?.get('picture');
|
||||
if (
|
||||
!(picture instanceof File) ||
|
||||
!['image/jpeg', 'image/png', 'image/webp'].includes(picture.type)
|
||||
) {
|
||||
error(400, 'Choose a JPEG, PNG, or WebP image.');
|
||||
}
|
||||
if (!picture.size || picture.size > 5 * 1024 * 1024)
|
||||
error(400, 'Images must be 5 MB or smaller.');
|
||||
try {
|
||||
return json(await uploadImage(picture, 'lists'));
|
||||
} catch (cause) {
|
||||
console.error('List image upload failed', cause);
|
||||
error(503, 'Image storage is unavailable.');
|
||||
}
|
||||
};
|
||||
9
src/routes/login/+page.server.ts
Normal file
9
src/routes/login/+page.server.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { authProviders } from '$lib/server/auth';
|
||||
import { safeReturnTo } from '$lib/auth-redirect';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
export const load: PageServerLoad = ({ url, locals }) => {
|
||||
const returnTo = safeReturnTo(url.searchParams.get('returnTo'));
|
||||
if (locals.user) redirect(303, returnTo);
|
||||
return { providers: authProviders, returnTo, oauthError: url.searchParams.has('error') };
|
||||
};
|
||||
7
src/routes/login/+page.svelte
Normal file
7
src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import AuthForm from '$lib/components/AuthForm.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
let { data }: PageProps = $props();
|
||||
</script>
|
||||
|
||||
<AuthForm mode="login" {...data} />
|
||||
15
src/routes/logout/+server.ts
Normal file
15
src/routes/logout/+server.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { auth } from '$lib/server/auth';
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
if (
|
||||
![url.origin, new URL(String(auth.options.baseURL)).origin].includes(
|
||||
request.headers.get('origin') ?? ''
|
||||
)
|
||||
)
|
||||
error(403, 'Invalid origin');
|
||||
await auth.api.signOut({ headers: request.headers });
|
||||
cookies.delete('admin_token', { path: '/' });
|
||||
cookies.delete('token', { path: '/' });
|
||||
redirect(303, '/login');
|
||||
};
|
||||
@@ -2,45 +2,57 @@
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
const projects = [
|
||||
{
|
||||
name: 'Animaldex',
|
||||
href: '/projects/animaldex',
|
||||
description: 'World map with pets & other animals around the neighborhood'
|
||||
},
|
||||
{
|
||||
name: 'CatchEmAll',
|
||||
href: '/projects/catch-em-all',
|
||||
description: 'Table of all 1025 pokemon across 38 games with checkboxes for catching them all'
|
||||
},
|
||||
{
|
||||
name: 'Derby Lens',
|
||||
href: '/projects/derby',
|
||||
description: 'Experimental win-probability model for the Duindigt Derby R 2026 with scenario sliders'
|
||||
},
|
||||
{
|
||||
name: 'CS Prices',
|
||||
href: '/projects/cs-prices',
|
||||
description: 'Dashboard for CS2 skin price histories, trends and predictions'
|
||||
},
|
||||
{
|
||||
name: 'Spotify Beatmap Finder',
|
||||
href: '/projects/spotify-beatmap-finder',
|
||||
description: 'Search for osu! beatmaps with Spotify songs/albums/playlists'
|
||||
}
|
||||
{ name: 'catchemall', href: '/projects/catch-em-all', external: false },
|
||||
{ name: 'animaldex', href: '/projects/animaldex', external: false },
|
||||
{ name: 'derby lens', href: '/projects/derby', external: false },
|
||||
{ name: 'cs prices', href: '/projects/cs-prices', external: false },
|
||||
{ name: 'osu! map finder', href: '/projects/spotify-beatmap-finder', external: false },
|
||||
{ name: 'game tracker', href: '/projects/game-tracker', external: false },
|
||||
{ name: 'money tracker', href: '/projects/money-tracker', external: false },
|
||||
{ name: 'vash esports', href: 'https://esports.vash.software', external: true },
|
||||
{ name: 'beerio kart', href: 'https://beeriokart.stanrunge.dev', external: true }
|
||||
] as const;
|
||||
const earlier = [
|
||||
'distributor',
|
||||
'realtime editing',
|
||||
'eIDAS auth module for WooCommerce + Magento 2'
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold underline py-8">Projects ({projects.length})</h1>
|
||||
|
||||
<ul class="flex flex-col gap-4 items-center">
|
||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||
<h1 class="mb-10 text-4xl font-semibold tracking-tight sm:text-5xl">projects</h1>
|
||||
<ul>
|
||||
{#each projects as project (project.name)}
|
||||
<a class="border min-w-160 py-2 text-center rounded-lg" href={resolve(project.href)}>
|
||||
<h1 class="font-bold text-xl">
|
||||
{project.name}
|
||||
</h1>
|
||||
<p>{project.description}</p>
|
||||
</a>
|
||||
<li class="border-t border-[var(--site-border)] text-lg">
|
||||
{#if project.external}
|
||||
<a
|
||||
href={project.href}
|
||||
rel="external"
|
||||
class="flex items-center justify-between gap-4 py-5 hover:text-[var(--site-muted)]"
|
||||
>
|
||||
<span>{project.name}</span><span aria-hidden="true" class="text-[var(--site-muted)]"
|
||||
>↗</span
|
||||
>
|
||||
</a>
|
||||
{:else}
|
||||
<a
|
||||
href={resolve(project.href)}
|
||||
class="flex items-center justify-between gap-4 py-5 hover:text-[var(--site-muted)]"
|
||||
>
|
||||
<span>{project.name}</span><span aria-hidden="true" class="text-[var(--site-muted)]"
|
||||
>↗</span
|
||||
>
|
||||
</a>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<h2 class="mt-14 mb-3 text-2xl font-semibold">other</h2>
|
||||
<ul>
|
||||
{#each earlier as name (name)}
|
||||
<li class="border-t border-[var(--site-border)] py-5 text-lg text-[var(--site-muted)]">
|
||||
{name}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
<script lang="ts">
|
||||
import './layout.css';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="Animaldex" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
</svelte:head>
|
||||
{@render children()}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { onMount, onDestroy, tick } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import type { Map as MaptilerMap, Marker as MaptilerMarker } from '@maptiler/sdk';
|
||||
|
||||
interface TrailPoint {
|
||||
id: string;
|
||||
id: number;
|
||||
lat: number;
|
||||
lng: number;
|
||||
seenAt: string;
|
||||
@@ -13,7 +14,7 @@
|
||||
}
|
||||
|
||||
interface AnimalFeatureProps {
|
||||
animalId: string;
|
||||
animalId: number;
|
||||
species: string;
|
||||
breed: string | null;
|
||||
animalName: string | null;
|
||||
@@ -28,9 +29,17 @@
|
||||
}
|
||||
|
||||
let mapContainer: HTMLDivElement;
|
||||
let detailsSection = $state<HTMLDivElement | null>(null);
|
||||
let map: MaptilerMap | null = null;
|
||||
let destroyed = false;
|
||||
let htmlMarkers: MaptilerMarker[] = [];
|
||||
let MapMarker: typeof MaptilerMarker | null = null;
|
||||
let mapError = $state('');
|
||||
let animalCount = $state<number | null>(null);
|
||||
let visibleAnimals = $state<GeoJSONFeature[]>([]);
|
||||
let locating = $state(false);
|
||||
let request: AbortController | null = null;
|
||||
const submitted = $derived(page.url.searchParams.has('submitted'));
|
||||
|
||||
let filterSpecies = $state('');
|
||||
let filterBreed = $state('');
|
||||
@@ -41,7 +50,7 @@
|
||||
let showFilters = $state(false);
|
||||
|
||||
let selected = $state<{
|
||||
animalId: string;
|
||||
animalId: number;
|
||||
species: string;
|
||||
breed: string | null;
|
||||
animalName: string | null;
|
||||
@@ -53,54 +62,85 @@
|
||||
return typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const { Map, Marker, config } = await import('@maptiler/sdk');
|
||||
const { env } = await import('$env/dynamic/public');
|
||||
async function selectAnimal(feature: GeoJSONFeature) {
|
||||
selected = { ...feature.properties, trail: parseTrail(feature.properties.trail) };
|
||||
await tick();
|
||||
detailsSection?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
config.apiKey = env.PUBLIC_MAPTILER_API_KEY;
|
||||
MapMarker = Marker;
|
||||
onMount(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const { Map, Marker, config } = await import('@maptiler/sdk');
|
||||
const { env } = await import('$env/dynamic/public');
|
||||
if (destroyed) return;
|
||||
if (!env.PUBLIC_MAPTILER_API_KEY) {
|
||||
mapError = 'The map is not configured yet.';
|
||||
return;
|
||||
}
|
||||
|
||||
const initMap = (center: [number, number], zoom: number) => {
|
||||
map = new Map({
|
||||
container: mapContainer,
|
||||
style:
|
||||
'https://api.maptiler.com/maps/satellite/style.json?key=' + env.PUBLIC_MAPTILER_API_KEY,
|
||||
center,
|
||||
zoom
|
||||
});
|
||||
config.apiKey = env.PUBLIC_MAPTILER_API_KEY;
|
||||
MapMarker = Marker;
|
||||
|
||||
map.on('load', () => {
|
||||
map!.addSource('trails', {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] }
|
||||
});
|
||||
map!.addLayer({
|
||||
id: 'trails',
|
||||
type: 'line',
|
||||
source: 'trails',
|
||||
paint: {
|
||||
'line-color': '#f97316',
|
||||
'line-width': 2,
|
||||
'line-dasharray': [2, 2],
|
||||
'line-opacity': 0.7
|
||||
}
|
||||
});
|
||||
loadMarkers(Marker);
|
||||
});
|
||||
const initMap = (center: [number, number], zoom: number) => {
|
||||
map = new Map({
|
||||
container: mapContainer,
|
||||
style:
|
||||
'https://api.maptiler.com/maps/satellite/style.json?key=' +
|
||||
env.PUBLIC_MAPTILER_API_KEY,
|
||||
center,
|
||||
zoom,
|
||||
scrollZoom: false,
|
||||
cooperativeGestures: true
|
||||
});
|
||||
|
||||
map.on('moveend', () => {
|
||||
if (MapMarker) loadMarkers(MapMarker);
|
||||
});
|
||||
};
|
||||
map.on('load', () => {
|
||||
map!.addSource('trails', {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] }
|
||||
});
|
||||
map!.addLayer({
|
||||
id: 'trails',
|
||||
type: 'line',
|
||||
source: 'trails',
|
||||
paint: {
|
||||
'line-color': '#ffffff',
|
||||
'line-width': 2,
|
||||
'line-dasharray': [2, 2],
|
||||
'line-opacity': 0.7
|
||||
}
|
||||
});
|
||||
loadMarkers(Marker);
|
||||
});
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => initMap([pos.coords.longitude, pos.coords.latitude], 13),
|
||||
() => initMap([4.708, 52.009], 15.5),
|
||||
{ enableHighAccuracy: true, timeout: 5000 }
|
||||
);
|
||||
map.on('moveend', () => {
|
||||
if (MapMarker) loadMarkers(MapMarker);
|
||||
});
|
||||
};
|
||||
|
||||
const fallback = () => {
|
||||
if (!destroyed) initMap([4.708, 52.009], 15.5);
|
||||
};
|
||||
if (!navigator.geolocation) {
|
||||
fallback();
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
if (!destroyed) initMap([position.coords.longitude, position.coords.latitude], 14);
|
||||
},
|
||||
fallback,
|
||||
{ enableHighAccuracy: true, timeout: 10000 }
|
||||
);
|
||||
} catch {
|
||||
mapError = 'The map could not load. Please try again later.';
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
destroyed = true;
|
||||
request?.abort();
|
||||
htmlMarkers.forEach((m) => m.remove());
|
||||
map?.remove();
|
||||
});
|
||||
@@ -120,11 +160,25 @@
|
||||
if (filterName) params.set('name', filterName);
|
||||
if (filterReporter) params.set('reporter', filterReporter);
|
||||
if (filterFrom) params.set('fromDate', new Date(filterFrom).toISOString());
|
||||
if (filterTo) params.set('toDate', new Date(filterTo).toISOString());
|
||||
if (filterTo) params.set('toDate', new Date(`${filterTo}T23:59:59`).toISOString());
|
||||
|
||||
const res = await fetch(`/projects/animaldex/api/map?${params}`);
|
||||
if (!res.ok) return;
|
||||
const geojson: { features: GeoJSONFeature[] } = await res.json();
|
||||
request?.abort();
|
||||
request = new AbortController();
|
||||
let geojson: { features: GeoJSONFeature[] };
|
||||
try {
|
||||
const res = await fetch(`${resolve('/projects/animaldex/api/map')}?${params}`, {
|
||||
signal: request.signal
|
||||
});
|
||||
if (!res.ok) throw new Error('Map request failed');
|
||||
geojson = await res.json();
|
||||
mapError = '';
|
||||
animalCount = geojson.features.length;
|
||||
visibleAnimals = geojson.features;
|
||||
} catch (cause) {
|
||||
if (cause instanceof DOMException && cause.name === 'AbortError') return;
|
||||
mapError = 'Could not load animals in this area. Try moving the map again.';
|
||||
return;
|
||||
}
|
||||
|
||||
htmlMarkers.forEach((m) => m.remove());
|
||||
htmlMarkers = [];
|
||||
@@ -152,34 +206,27 @@
|
||||
for (const feature of geojson.features) {
|
||||
const props = feature.properties;
|
||||
const trail = parseTrail(props.trail);
|
||||
const photoUrl = trail[0]?.photoUrl ?? null;
|
||||
const photoUrl = [...trail].reverse().find((point) => point.photoUrl)?.photoUrl ?? null;
|
||||
|
||||
const el = document.createElement('div');
|
||||
const el = document.createElement('button');
|
||||
el.type = 'button';
|
||||
el.setAttribute('aria-label', `View ${props.animalName || props.species}`);
|
||||
el.style.cssText = `
|
||||
width: 44px; height: 44px; border-radius: 50%;
|
||||
border: 3px solid white;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||
cursor: pointer; overflow: hidden;
|
||||
background: #f97316;
|
||||
background: #171717; color: white;
|
||||
`;
|
||||
if (photoUrl) {
|
||||
el.style.backgroundImage = `url(${photoUrl})`;
|
||||
el.style.backgroundSize = 'cover';
|
||||
el.style.backgroundPosition = 'center';
|
||||
} else {
|
||||
el.innerHTML = `<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:18px">🐾</div>`;
|
||||
el.textContent = '🐾';
|
||||
}
|
||||
|
||||
el.onclick = () => {
|
||||
selected = {
|
||||
animalId: props.animalId,
|
||||
species: props.species,
|
||||
breed: props.breed,
|
||||
animalName: props.animalName,
|
||||
sightingCount: props.sightingCount,
|
||||
trail
|
||||
};
|
||||
};
|
||||
el.onclick = () => selectAnimal(feature);
|
||||
|
||||
const marker = new Marker({ element: el })
|
||||
.setLngLat(feature.geometry.coordinates)
|
||||
@@ -190,6 +237,7 @@
|
||||
|
||||
function applyFilters() {
|
||||
showFilters = false;
|
||||
selected = null;
|
||||
if (MapMarker) loadMarkers(MapMarker);
|
||||
}
|
||||
function clearFilters() {
|
||||
@@ -199,9 +247,29 @@
|
||||
filterReporter = '';
|
||||
filterFrom = '';
|
||||
filterTo = '';
|
||||
selected = null;
|
||||
if (MapMarker) loadMarkers(MapMarker);
|
||||
}
|
||||
|
||||
function locate() {
|
||||
if (!navigator.geolocation || !map) {
|
||||
mapError = 'Location is unavailable in this browser.';
|
||||
return;
|
||||
}
|
||||
locating = true;
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
locating = false;
|
||||
map?.flyTo({ center: [position.coords.longitude, position.coords.latitude], zoom: 14 });
|
||||
},
|
||||
() => {
|
||||
locating = false;
|
||||
mapError = 'Location access was denied. You can still browse the map.';
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 10000 }
|
||||
);
|
||||
}
|
||||
|
||||
const activeFilterCount = $derived(
|
||||
[filterSpecies, filterBreed, filterName, filterReporter, filterFrom, filterTo].filter(Boolean)
|
||||
.length
|
||||
@@ -209,149 +277,223 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Animaldex</title>
|
||||
<link rel="stylesheet" href="https://cdn.maptiler.com/maptiler-sdk-js/latest/maptiler-sdk.css" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="relative h-screen w-full overflow-hidden">
|
||||
<div bind:this={mapContainer} class="h-full w-full"></div>
|
||||
|
||||
<div class="absolute top-4 left-1/2 z-10 flex -translate-x-1/2 gap-2">
|
||||
<section class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
|
||||
<div class="mb-7 flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">animaldex</h1>
|
||||
<p class="mt-2 text-base text-[var(--site-muted)]">neighborhood animals on the map</p>
|
||||
</div>
|
||||
<a
|
||||
href={resolve('/projects/animaldex/register')}
|
||||
class="flex items-center gap-2 rounded-full bg-orange-500 px-4 py-2 text-sm font-semibold text-white shadow-lg hover:bg-orange-600"
|
||||
class="rounded bg-[var(--site-text)] px-4 py-2.5 text-base font-medium text-[var(--site-bg)]"
|
||||
>
|
||||
+ Log a sighting
|
||||
add an animal
|
||||
</a>
|
||||
<button
|
||||
onclick={() => (showFilters = !showFilters)}
|
||||
class="relative rounded-full bg-white px-4 py-2 text-sm font-semibold text-gray-700 shadow-lg hover:bg-gray-50"
|
||||
</div>
|
||||
{#if submitted}<p role="status" class="mb-5 border-y border-[var(--site-border)] py-3">
|
||||
sighting submitted. it will appear after review.
|
||||
</p>{/if}
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<span class="text-base text-[var(--site-muted)]"
|
||||
>{animalCount === null
|
||||
? mapError
|
||||
? 'map unavailable'
|
||||
: 'loading animals…'
|
||||
: `${animalCount} ${animalCount === 1 ? 'animal' : 'animals'} in this area`}</span
|
||||
>
|
||||
Filters
|
||||
{#if activeFilterCount > 0}
|
||||
<span
|
||||
class="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-orange-500 text-[10px] text-white"
|
||||
>{activeFilterCount}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
<div class="flex items-center gap-5">
|
||||
<button
|
||||
type="button"
|
||||
onclick={locate}
|
||||
disabled={locating}
|
||||
class="underline underline-offset-4">{locating ? 'locating…' : 'my location'}</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showFilters = !showFilters)}
|
||||
aria-expanded={showFilters}
|
||||
class="underline underline-offset-4"
|
||||
>
|
||||
filters{activeFilterCount ? ` (${activeFilterCount})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showFilters}
|
||||
<div
|
||||
class="absolute top-16 left-1/2 z-20 w-80 -translate-x-1/2 rounded-2xl bg-white p-4 shadow-xl"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="font-semibold text-gray-800">Filter sightings</h2>
|
||||
<button onclick={() => (showFilters = false)} class="text-gray-400 hover:text-gray-600"
|
||||
>✕</button
|
||||
>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="mb-5 border-y border-[var(--site-border)] py-5">
|
||||
<h2 class="mb-3 text-lg font-medium">filter sightings</h2>
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<input
|
||||
bind:value={filterSpecies}
|
||||
placeholder="Species"
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||
aria-label="Species"
|
||||
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
|
||||
/>
|
||||
<input
|
||||
bind:value={filterBreed}
|
||||
placeholder="Breed"
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||
aria-label="Breed"
|
||||
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
|
||||
/>
|
||||
<input
|
||||
bind:value={filterName}
|
||||
placeholder="Animal name"
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||
aria-label="Animal name"
|
||||
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
|
||||
/>
|
||||
<input
|
||||
bind:value={filterReporter}
|
||||
placeholder="Reporter name"
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||
aria-label="Reporter name"
|
||||
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="grid grid-cols-2 gap-3 sm:col-span-2">
|
||||
<div>
|
||||
<label for="filter-from" class="mb-1 block text-xs text-gray-500">From</label>
|
||||
<label for="filter-from" class="mb-1 block text-base">from</label>
|
||||
<input
|
||||
id="filter-from"
|
||||
type="date"
|
||||
bind:value={filterFrom}
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="filter-to" class="mb-1 block text-xs text-gray-500">To</label>
|
||||
<label for="filter-to" class="mb-1 block text-base">to</label>
|
||||
<input
|
||||
id="filter-to"
|
||||
type="date"
|
||||
bind:value={filterTo}
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<div class="mt-4 flex gap-4">
|
||||
<button
|
||||
onclick={applyFilters}
|
||||
class="flex-1 rounded-lg bg-orange-500 py-2 text-sm font-semibold text-white hover:bg-orange-600"
|
||||
>Apply</button
|
||||
>
|
||||
<button
|
||||
onclick={clearFilters}
|
||||
class="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50"
|
||||
>Clear</button
|
||||
class="rounded bg-[var(--site-text)] px-4 py-2 text-base text-[var(--site-bg)]"
|
||||
>apply</button
|
||||
>
|
||||
<button onclick={clearFilters} class="text-base underline underline-offset-4">clear</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
bind:this={mapContainer}
|
||||
role="region"
|
||||
aria-label="Animaldex map"
|
||||
class="h-[min(56vh,560px)] min-h-80 w-full overflow-hidden rounded border border-[var(--site-border)] bg-[var(--site-surface)] sm:h-[min(65vh,640px)]"
|
||||
></div>
|
||||
{#if mapError}<p role="alert" class="mt-3 text-base">{mapError}</p>{/if}
|
||||
{#if animalCount === 0}<p class="mt-4 text-base text-[var(--site-muted)]">
|
||||
no animals here yet. try another area.
|
||||
</p>{/if}
|
||||
|
||||
{#if selected}
|
||||
<div class="absolute right-4 bottom-8 z-10 w-80 overflow-hidden rounded-2xl bg-white shadow-xl">
|
||||
{#if selected.trail[0]?.photoUrl}
|
||||
<div class="relative h-40 bg-gray-100">
|
||||
<img src={selected.trail[0].photoUrl} alt="" class="h-full w-full object-cover" />
|
||||
<div
|
||||
bind:this={detailsSection}
|
||||
class="mt-8 max-w-2xl border-t border-[var(--site-border)] pt-6"
|
||||
>
|
||||
{#if [...selected.trail].reverse().find((point) => point.photoUrl)?.photoUrl}
|
||||
<div
|
||||
class="relative mb-5 aspect-video max-h-80 overflow-hidden rounded bg-[var(--site-surface)]"
|
||||
>
|
||||
<img
|
||||
src={[...selected.trail].reverse().find((point) => point.photoUrl)?.photoUrl}
|
||||
alt={selected.animalName || selected.species}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<button
|
||||
onclick={() => (selected = null)}
|
||||
class="absolute top-2 right-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/50 text-white"
|
||||
>✕</button
|
||||
aria-label="Close animal details"
|
||||
class="absolute top-3 right-3 rounded bg-black px-3 py-1 text-white">✕</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="p-4">
|
||||
<div class="mb-1 flex items-start justify-between">
|
||||
<div>
|
||||
<div class="mb-1 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-lg font-semibold text-gray-900">
|
||||
<h2 class="text-2xl font-semibold">
|
||||
{selected.animalName ?? selected.species}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
</h2>
|
||||
<p class="mt-1 text-base text-[var(--site-muted)]">
|
||||
{selected.species}{selected.breed ? ` · ${selected.breed}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<span class="rounded-full bg-orange-100 px-2 py-0.5 text-xs font-medium text-orange-700"
|
||||
<span class="text-base text-[var(--site-muted)]"
|
||||
>{selected.sightingCount}
|
||||
{selected.sightingCount === 1 ? 'sighting' : 'sightings'}</span
|
||||
>
|
||||
</div>
|
||||
<div class="mt-3 max-h-36 space-y-1.5 overflow-y-auto">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selected = null)}
|
||||
class="text-base underline underline-offset-4">close details</button
|
||||
>
|
||||
<div class="mt-5 divide-y divide-[var(--site-border)] border-t border-[var(--site-border)]">
|
||||
{#each [...selected.trail].sort((a, b) => new Date(b.seenAt).getTime() - new Date(a.seenAt).getTime()) as s (s.id)}
|
||||
<div class="flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2 text-xs">
|
||||
<span class="text-gray-600"
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 py-3 text-base">
|
||||
<span
|
||||
>{new Date(s.seenAt).toLocaleDateString('nl-NL', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})}</span
|
||||
>
|
||||
{#if s.reporterName}<span class="text-gray-400">by {s.reporterName}</span>{/if}
|
||||
{#if s.reporterName}<span class="text-[var(--site-muted)]">by {s.reporterName}</span
|
||||
>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<a
|
||||
href={resolve(`/projects/animaldex/register?animalId=${selected.animalId}`)}
|
||||
class="mt-3 block w-full rounded-lg bg-orange-500 py-2 text-center text-sm font-semibold text-white hover:bg-orange-600"
|
||||
class="mt-5 inline-block text-base underline underline-offset-4"
|
||||
>
|
||||
I saw this animal too!
|
||||
i saw this animal too ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if visibleAnimals.length}
|
||||
<div class="mt-10">
|
||||
<h2 class="mb-3 text-xl font-semibold">animals in this area</h2>
|
||||
<ul class="grid gap-x-8 sm:grid-cols-2">
|
||||
{#each visibleAnimals.slice(0, 24) as feature (feature.properties.animalId)}
|
||||
{@const animal = feature.properties}
|
||||
{@const photo = [...parseTrail(animal.trail)]
|
||||
.reverse()
|
||||
.find((point) => point.photoUrl)?.photoUrl}
|
||||
<li class="border-t border-[var(--site-border)]">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectAnimal(feature)}
|
||||
class="flex w-full items-center gap-3 py-3 text-left hover:text-[var(--site-muted)]"
|
||||
>
|
||||
{#if photo}<img
|
||||
src={photo}
|
||||
alt=""
|
||||
class="h-12 w-12 rounded object-cover"
|
||||
/>{:else}<span
|
||||
class="flex h-12 w-12 items-center justify-center rounded bg-[var(--site-surface)]"
|
||||
>🐾</span
|
||||
>{/if}
|
||||
<span class="min-w-0">
|
||||
<span class="block font-medium">{animal.animalName || animal.species}</span>
|
||||
<span class="block text-base text-[var(--site-muted)]"
|
||||
>{animal.species} · {animal.sightingCount}
|
||||
{animal.sightingCount === 1 ? 'sighting' : 'sightings'}</span
|
||||
>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if visibleAnimals.length > 24}<p class="mt-3 text-base text-[var(--site-muted)]">
|
||||
showing 24 animals. zoom in to see the others.
|
||||
</p>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const ssr = false;
|
||||
@@ -5,97 +5,63 @@
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let password = $state('');
|
||||
let loginError = $state('');
|
||||
let loginLoading = $state(false);
|
||||
|
||||
async function login() {
|
||||
loginLoading = true;
|
||||
loginError = '';
|
||||
const res = await fetch('/admin/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
if (res.ok) {
|
||||
await invalidateAll();
|
||||
} else {
|
||||
loginError = 'Wrong password';
|
||||
}
|
||||
loginLoading = false;
|
||||
}
|
||||
|
||||
let actionLoading = $state<string | null>(null);
|
||||
let actionError = $state('');
|
||||
|
||||
async function moderate(type: 'animals' | 'sightings', id: string, action: 'accept' | 'deny') {
|
||||
actionLoading = `${type}-${id}-${action}`;
|
||||
await fetch(`/api/${type}/${id}/moderate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action })
|
||||
});
|
||||
actionLoading = null;
|
||||
await invalidateAll();
|
||||
async function moderate(id: number, action: 'accept' | 'deny') {
|
||||
actionLoading = `animals-${id}-${action}`;
|
||||
actionError = '';
|
||||
try {
|
||||
const response = await fetch(resolve(`/projects/animaldex/api/animals/${id}/moderate`), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action })
|
||||
});
|
||||
if (!response.ok) throw new Error();
|
||||
await invalidateAll();
|
||||
} catch {
|
||||
actionError = 'Could not update this animal. Please try again.';
|
||||
} finally {
|
||||
actionLoading = null;
|
||||
}
|
||||
}
|
||||
|
||||
const queue = $derived(data.queue ?? []);
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Animaldex - Admin</title></svelte:head>
|
||||
|
||||
{#if !data.admin}
|
||||
<div class="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div class="w-80 rounded-2xl bg-white p-8 shadow-xl">
|
||||
<h1 class="mb-6 text-xl font-semibold text-gray-900">Admin login</h1>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={password}
|
||||
placeholder="Password"
|
||||
onkeydown={(e) => e.key === 'Enter' && login()}
|
||||
class="mb-3 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||
/>
|
||||
{#if loginError}
|
||||
<p class="mb-3 text-xs text-red-500">{loginError}</p>
|
||||
{/if}
|
||||
<button
|
||||
onclick={login}
|
||||
disabled={loginLoading}
|
||||
class="w-full rounded-lg bg-orange-500 py-2 text-sm font-semibold text-white hover:bg-orange-600 disabled:opacity-50"
|
||||
<div class="mx-auto max-w-md p-8 text-center">
|
||||
<h1 class="text-xl font-bold">Administrator access required</h1>
|
||||
{#if !data.user}<a
|
||||
class="mt-4 inline-block underline"
|
||||
href={resolve('/login?returnTo=/projects/animaldex/admin')}>Log in with your account</a
|
||||
>
|
||||
{loginLoading ? 'Logging in…' : 'Log in'}
|
||||
</button>
|
||||
</div>
|
||||
{:else}<p class="mt-4">Your account does not have administrator access.</p>{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b border-gray-100 bg-white px-6 py-3"
|
||||
<section class="mx-auto max-w-4xl px-4 py-10 sm:px-6 sm:py-14">
|
||||
<a href={resolve('/projects/animaldex')} class="text-base text-[var(--site-muted)]"
|
||||
>← animaldex</a
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href={resolve('/')} class="text-gray-400 hover:text-gray-600">←</a>
|
||||
<h1 class="font-semibold text-gray-900">Animaldex admin</h1>
|
||||
</div>
|
||||
<span class="rounded-full bg-orange-100 px-2 py-0.5 text-xs font-medium text-orange-700">
|
||||
<div class="mt-5 mb-8 flex flex-wrap items-end justify-between gap-4">
|
||||
<h1 class="text-4xl font-semibold tracking-tight">animaldex admin</h1>
|
||||
<span class="text-base text-[var(--site-muted)]">
|
||||
{queue.filter((r) => !r.acceptedAt && !r.deniedAt).length} pending
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-3xl space-y-4 px-4 py-6">
|
||||
{#if actionError}<p role="alert" class="mb-5 text-base">{actionError}</p>{/if}
|
||||
<div class="space-y-4">
|
||||
{#if queue.length === 0}
|
||||
<div class="rounded-2xl bg-white p-8 text-center text-gray-400 shadow-sm">
|
||||
No submissions yet
|
||||
</div>
|
||||
<p class="border-t border-[var(--site-border)] py-5 text-[var(--site-muted)]">
|
||||
no submissions yet
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#each queue as item (item.animalId)}
|
||||
{@const status = item.acceptedAt ? 'accepted' : item.deniedAt ? 'denied' : 'pending'}
|
||||
<div
|
||||
class="overflow-hidden rounded-2xl bg-white shadow-sm {status === 'denied'
|
||||
? 'opacity-60'
|
||||
: ''}"
|
||||
>
|
||||
<div class="border-t border-[var(--site-border)] pt-4">
|
||||
<div class="flex gap-4 p-4">
|
||||
<div class="h-24 w-24 shrink-0 overflow-hidden rounded-xl bg-gray-100">
|
||||
<div class="h-24 w-24 shrink-0 overflow-hidden rounded bg-[var(--site-surface)]">
|
||||
{#if item.photoUrl}
|
||||
<img src={item.photoUrl} alt="" class="h-full w-full object-cover" />
|
||||
{:else}
|
||||
@@ -105,30 +71,23 @@
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p class="font-semibold text-gray-900">{item.animalName ?? item.species}</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
<p class="font-semibold">{item.animalName ?? item.species}</p>
|
||||
<p class="text-base text-[var(--site-muted)]">
|
||||
{item.species}{item.breed ? ` · ${item.breed}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium
|
||||
{status === 'accepted'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: status === 'denied'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-yellow-100 text-yellow-700'}"
|
||||
>
|
||||
<span class="shrink-0 text-base text-[var(--site-muted)]">
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
{#if item.aiBreedSuggestion}
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
<p class="mt-1 text-base text-[var(--site-muted)]">
|
||||
🤖 AI: {item.aiBreedSuggestion} ({Math.round(
|
||||
(item.aiBreedConfidence ?? 0) * 100
|
||||
)}%)
|
||||
</p>
|
||||
{/if}
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
<p class="mt-1 text-base text-[var(--site-muted)]">
|
||||
{new Date(item.submittedAt).toLocaleDateString('nl-NL', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
@@ -138,27 +97,23 @@
|
||||
})}
|
||||
{#if item.reporterName}· by {item.reporterName}{/if}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-gray-400">
|
||||
<p class="mt-0.5 text-base text-[var(--site-muted)]">
|
||||
📍 {item.sightingLat.toFixed(5)}, {item.sightingLng.toFixed(5)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 border-t border-gray-100 px-4 py-3">
|
||||
<div class="flex gap-3 border-t border-[var(--site-border)] px-4 py-3">
|
||||
<button
|
||||
onclick={() => moderate('animals', item.animalId, 'accept')}
|
||||
onclick={() => moderate(item.animalId, 'accept')}
|
||||
disabled={actionLoading !== null || status === 'accepted'}
|
||||
class="flex-1 rounded-lg py-1.5 text-sm font-medium {status === 'accepted'
|
||||
? 'cursor-default bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-green-100 hover:text-green-700'}"
|
||||
class="flex-1 rounded border border-[var(--site-border)] py-2 text-base disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === `animals-${item.animalId}-accept` ? '…' : '✓ Accept'}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => moderate('animals', item.animalId, 'deny')}
|
||||
onclick={() => moderate(item.animalId, 'deny')}
|
||||
disabled={actionLoading !== null || status === 'denied'}
|
||||
class="flex-1 rounded-lg py-1.5 text-sm font-medium {status === 'denied'
|
||||
? 'cursor-default bg-red-100 text-red-700'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-red-100 hover:text-red-700'}"
|
||||
class="flex-1 rounded border border-[var(--site-border)] py-2 text-base disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === `animals-${item.animalId}-deny` ? '…' : '✕ Deny'}
|
||||
</button>
|
||||
@@ -166,5 +121,5 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -1,25 +1,3 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { z } from 'zod';
|
||||
|
||||
const loginSchema = z.object({ password: z.string().min(1) });
|
||||
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = loginSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success || parsed.data.password !== env.ADMIN_SECRET) {
|
||||
throw error(401, { message: 'Invalid password' });
|
||||
}
|
||||
|
||||
cookies.set('admin_token', env.ADMIN_SECRET, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 60 * 60 * 24 * 7
|
||||
});
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
export const GET = () => redirect(303, '/login');
|
||||
export const POST = () => error(410, 'Use the central account login at /login');
|
||||
|
||||
@@ -3,8 +3,8 @@ import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { animals, sightings, photos } from '$lib/server/db/schema';
|
||||
import { registerAnimalSchema } from '$lib/validation/sighting';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { distanceMeters } from '$lib/geo';
|
||||
|
||||
const MAX_DISTANCE_METERS = 30_000;
|
||||
|
||||
@@ -18,14 +18,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
|
||||
const data = parsed.data;
|
||||
|
||||
const [distRow] = await db.execute(sql`
|
||||
SELECT ST_Distance(
|
||||
ST_MakePoint(${data.deviceLng}, ${data.deviceLat})::geography,
|
||||
ST_MakePoint(${data.lng}, ${data.lat})::geography
|
||||
) AS dist
|
||||
`);
|
||||
|
||||
const dist = Number((distRow as Record<string, unknown>).dist);
|
||||
const dist = distanceMeters(data.deviceLat, data.deviceLng, data.lat, data.lng);
|
||||
if (dist > MAX_DISTANCE_METERS) {
|
||||
throw error(422, {
|
||||
message: `Location is ${Math.round(dist / 1000)}km from your device. Maximum is 30km.`
|
||||
|
||||
@@ -9,6 +9,9 @@ import { requireAdmin } from '$lib/server/auth';
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
requireAdmin(event);
|
||||
|
||||
const id = Number(event.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) throw error(404, { message: 'Not found' });
|
||||
|
||||
const body = await event.request.json().catch(() => null);
|
||||
const parsed = moderationActionSchema.safeParse(body);
|
||||
|
||||
@@ -26,14 +29,14 @@ export const POST: RequestHandler = async (event) => {
|
||||
acceptedAt: isAccept ? now : null,
|
||||
deniedAt: isAccept ? null : now
|
||||
})
|
||||
.where(eq(animals.id, event.params.id))
|
||||
.where(eq(animals.id, id))
|
||||
.returning({ id: animals.id });
|
||||
|
||||
if (result.length && isAccept) {
|
||||
await tx
|
||||
.update(sightings)
|
||||
.set({ acceptedAt: now, deniedAt: null })
|
||||
.where(eq(sightings.animalId, event.params.id));
|
||||
.where(eq(sightings.animalId, id));
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -5,20 +5,9 @@ import { animals, sightings, photos } from '$lib/server/db/schema';
|
||||
import { addSightingSchema } from '$lib/validation/sighting';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { distanceMeters } from '$lib/geo';
|
||||
|
||||
const MAX_DISTANCE_METERS = 30_000;
|
||||
const EARTH_RADIUS_METERS = 6_371_000;
|
||||
|
||||
// Haversine distance between two lat/lng points, in meters.
|
||||
function distanceMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||
const toRad = (deg: number) => (deg * Math.PI) / 180;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLng = toRad(lng2 - lng1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_METERS * Math.asin(Math.sqrt(a));
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ request, params }) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
@@ -33,7 +22,7 @@ export const POST: RequestHandler = async ({ request, params }) => {
|
||||
const [animal] = await db
|
||||
.select({ id: animals.id, acceptedAt: animals.acceptedAt })
|
||||
.from(animals)
|
||||
.where(eq(animals.id, params.id))
|
||||
.where(eq(animals.id, data.animalId))
|
||||
.limit(1);
|
||||
|
||||
if (!animal) {
|
||||
|
||||
@@ -9,6 +9,9 @@ import { requireAdmin } from '$lib/server/auth';
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
requireAdmin(event);
|
||||
|
||||
const id = Number(event.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) throw error(404, { message: 'Not found' });
|
||||
|
||||
const body = await event.request.json().catch(() => null);
|
||||
const parsed = moderationActionSchema.safeParse(body);
|
||||
|
||||
@@ -25,7 +28,7 @@ export const POST: RequestHandler = async (event) => {
|
||||
acceptedAt: isAccept ? now : null,
|
||||
deniedAt: isAccept ? null : now
|
||||
})
|
||||
.where(eq(sightings.id, event.params.id))
|
||||
.where(eq(sightings.id, id))
|
||||
.returning({ id: sightings.id });
|
||||
|
||||
if (!updated) {
|
||||
|
||||
@@ -12,6 +12,6 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
throw error(400, { message: 'imageUrl is required' });
|
||||
}
|
||||
|
||||
// const result = await detectBreed(parsed.data.imageUrl);
|
||||
return json(result ?? { breed: null, confidence: 0 });
|
||||
// Breed detection is not configured for this site yet.
|
||||
return json({ breed: null, confidence: 0 });
|
||||
};
|
||||
|
||||
@@ -57,14 +57,14 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
.orderBy(sightings.seenAt);
|
||||
|
||||
const animalMap = new Map<
|
||||
string,
|
||||
number,
|
||||
{
|
||||
animalId: string;
|
||||
animalId: number;
|
||||
species: string;
|
||||
breed: string | null;
|
||||
animalName: string | null;
|
||||
sightings: Array<{
|
||||
id: string;
|
||||
id: number;
|
||||
lat: number;
|
||||
lng: number;
|
||||
seenAt: Date;
|
||||
@@ -118,10 +118,5 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
};
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[map] query returned ${rows.length} rows for bbox ${q.minLng},${q.minLat} → ${q.maxLng},${q.maxLat}`
|
||||
);
|
||||
console.log(`[map] built ${features.length} features`);
|
||||
|
||||
return json({ type: 'FeatureCollection', features });
|
||||
};
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { createPresignedUpload, isAllowedMimeType } from '$lib/server/r2';
|
||||
import { presignedUploadSchema } from '$lib/validation/sighting';
|
||||
import { uploadImage, isAllowedMimeType } from '$lib/server/r2';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = presignedUploadSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
throw error(400, { message: 'Invalid request body' });
|
||||
const form = await request.formData().catch(() => null);
|
||||
const photo = form?.get('photo');
|
||||
if (!(photo instanceof File) || !isAllowedMimeType(photo.type))
|
||||
throw error(400, 'Use a JPEG, PNG, WebP, or HEIC photo.');
|
||||
if (!photo.size || photo.size > 5 * 1024 * 1024)
|
||||
throw error(400, 'Photos must be 5 MB or smaller.');
|
||||
try {
|
||||
return json(await uploadImage(photo));
|
||||
} catch (cause) {
|
||||
console.error('Animaldex image upload failed', cause);
|
||||
throw error(503, 'Image storage is unavailable.');
|
||||
}
|
||||
|
||||
const { mimeType } = parsed.data;
|
||||
|
||||
if (!isAllowedMimeType(mimeType)) {
|
||||
throw error(400, { message: 'Unsupported image type' });
|
||||
}
|
||||
|
||||
const result = await createPresignedUpload(mimeType);
|
||||
return json(result);
|
||||
};
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
@import 'tailwindcss';
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import type { Map as MaptilerMap, Marker as MaptilerMarker } from '@maptiler/sdk';
|
||||
import { onMount } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
@@ -14,14 +14,16 @@
|
||||
let animalName = $state('');
|
||||
let description = $state('');
|
||||
let reporterName = $state('');
|
||||
let seenAt = $state(new Date().toISOString().slice(0, 16));
|
||||
let seenAt = $state(localDateTime());
|
||||
|
||||
let aiSuggestion = $state<{ breed: string; confidence: number } | null>(null);
|
||||
let aiLoading = $state(false);
|
||||
let aiDismissed = $state(false);
|
||||
function localDateTime() {
|
||||
const now = new Date();
|
||||
return new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
let photoFiles = $state<File[]>([]);
|
||||
let photoPreviews = $state<string[]>([]);
|
||||
let draggingPhotos = $state(false);
|
||||
|
||||
let lat = $state<number | null>(null);
|
||||
let lng = $state<number | null>(null);
|
||||
@@ -33,6 +35,8 @@
|
||||
let mapContainer = $state<HTMLDivElement>(null!);
|
||||
let map: MaptilerMap | null = null;
|
||||
let marker: MaptilerMarker | null = null;
|
||||
let mapError = $state('');
|
||||
let destroyed = false;
|
||||
|
||||
let submitting = $state(false);
|
||||
let submitError = $state('');
|
||||
@@ -46,9 +50,26 @@
|
||||
const COMMON_SPECIES = ['Dog', 'Cat', 'Fox', 'Bird', 'Rabbit', 'Deer', 'Squirrel', 'Hedgehog'];
|
||||
const finalSpecies = $derived(species === '__custom__' ? customSpecies : species);
|
||||
|
||||
onMount(async () => {
|
||||
onMount(() => {
|
||||
reporterName = localStorage.getItem('reporterName') ?? '';
|
||||
requestLocation();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
destroyed = true;
|
||||
photoPreviews.forEach((url) => URL.revokeObjectURL(url));
|
||||
marker?.remove();
|
||||
map?.remove();
|
||||
});
|
||||
|
||||
function requestLocation() {
|
||||
locationError = '';
|
||||
locationLoading = true;
|
||||
if (!navigator.geolocation) {
|
||||
locationLoading = false;
|
||||
locationError = 'Location is unavailable in this browser.';
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
deviceLat = pos.coords.latitude;
|
||||
@@ -59,77 +80,78 @@
|
||||
},
|
||||
() => {
|
||||
locationLoading = false;
|
||||
locationError = 'Location access denied. GPS is required to register a sighting.';
|
||||
locationError = 'Allow location access to log a sighting near you.';
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 10000 }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function initMap() {
|
||||
if (!mapContainer || lat === null || lng === null) return;
|
||||
const { Map, Marker, config } = await import('@maptiler/sdk');
|
||||
const { env } = await import('$env/dynamic/public');
|
||||
config.apiKey = env.PUBLIC_MAPTILER_API_KEY;
|
||||
|
||||
map = new Map({
|
||||
container: mapContainer,
|
||||
style:
|
||||
'https://api.maptiler.com/maps/satellite/style.json?key=' + env.PUBLIC_MAPTILER_API_KEY,
|
||||
center: [lng!, lat!],
|
||||
zoom: 15
|
||||
});
|
||||
|
||||
map.on('load', () => {
|
||||
marker = new Marker({ draggable: true }).setLngLat([lng!, lat!]).addTo(map!);
|
||||
marker.on('dragend', () => {
|
||||
const lngLat = marker!.getLngLat();
|
||||
lat = lngLat.lat;
|
||||
lng = lngLat.lng;
|
||||
try {
|
||||
const { Map, Marker, config } = await import('@maptiler/sdk');
|
||||
const { env } = await import('$env/dynamic/public');
|
||||
if (destroyed) return;
|
||||
if (!env.PUBLIC_MAPTILER_API_KEY) {
|
||||
mapError = 'The map is not configured yet. Your GPS location is still available.';
|
||||
return;
|
||||
}
|
||||
config.apiKey = env.PUBLIC_MAPTILER_API_KEY;
|
||||
map = new Map({
|
||||
container: mapContainer,
|
||||
style: `https://api.maptiler.com/maps/satellite/style.json?key=${env.PUBLIC_MAPTILER_API_KEY}`,
|
||||
center: [lng!, lat!],
|
||||
zoom: 15
|
||||
});
|
||||
});
|
||||
map.on('load', () => {
|
||||
marker = new Marker({ draggable: true }).setLngLat([lng!, lat!]).addTo(map!);
|
||||
marker.on('dragend', () => {
|
||||
const lngLat = marker!.getLngLat();
|
||||
lat = lngLat.lat;
|
||||
lng = lngLat.lng;
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
mapError = 'The map could not load. You can still submit using your GPS location.';
|
||||
}
|
||||
}
|
||||
|
||||
function handlePhotoChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const newFiles = Array.from(input.files ?? []).slice(0, 10 - photoFiles.length);
|
||||
addPhotos(Array.from(input.files ?? []));
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function addPhotos(files: File[]) {
|
||||
const newFiles = files.slice(0, 10 - photoFiles.length);
|
||||
if (
|
||||
newFiles.some(
|
||||
(file) => !['image/jpeg', 'image/png', 'image/webp', 'image/heic'].includes(file.type)
|
||||
)
|
||||
) {
|
||||
submitError = 'Use JPEG, PNG, WebP, or HEIC photos.';
|
||||
return;
|
||||
}
|
||||
if (newFiles.some((file) => file.size > 5 * 1024 * 1024)) {
|
||||
submitError = 'Photos must be 5 MB or smaller.';
|
||||
return;
|
||||
}
|
||||
submitError = '';
|
||||
photoPreviews.forEach((url) => URL.revokeObjectURL(url));
|
||||
photoFiles = [...photoFiles, ...newFiles].slice(0, 10);
|
||||
photoPreviews = photoFiles.map((f) => URL.createObjectURL(f));
|
||||
if (photoFiles.length === 1 && !isNewSighting) runAIDetection(photoFiles[0]);
|
||||
}
|
||||
|
||||
function dropPhotos(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
draggingPhotos = false;
|
||||
addPhotos(Array.from(event.dataTransfer?.files ?? []));
|
||||
}
|
||||
|
||||
function removePhoto(index: number) {
|
||||
photoPreviews.forEach((url) => URL.revokeObjectURL(url));
|
||||
photoFiles = photoFiles.filter((_, i) => i !== index);
|
||||
photoPreviews = photoPreviews.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
async function runAIDetection(file: File) {
|
||||
aiLoading = true;
|
||||
aiDismissed = false;
|
||||
try {
|
||||
const presignRes = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mimeType: file.type })
|
||||
});
|
||||
if (!presignRes.ok) return;
|
||||
const { uploadUrl, publicUrl } = await presignRes.json();
|
||||
await fetch(uploadUrl, { method: 'PUT', body: file, headers: { 'Content-Type': file.type } });
|
||||
const aiRes = await fetch('/api/detect-breed', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ imageUrl: publicUrl })
|
||||
});
|
||||
if (!aiRes.ok) return;
|
||||
const result = await aiRes.json();
|
||||
if (result.breed) {
|
||||
aiSuggestion = result;
|
||||
if (!breed) breed = result.breed;
|
||||
}
|
||||
} catch {
|
||||
/* silent */
|
||||
} finally {
|
||||
aiLoading = false;
|
||||
}
|
||||
photoPreviews = photoFiles.map((file) => URL.createObjectURL(file));
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
@@ -152,25 +174,22 @@
|
||||
const photoKeys: string[] = [];
|
||||
if (photoFiles.length > 0) {
|
||||
for (const file of photoFiles) {
|
||||
const presignRes = await fetch('/api/upload', {
|
||||
const uploadForm = new FormData();
|
||||
uploadForm.set('photo', file);
|
||||
const uploadRes = await fetch(resolve('/projects/animaldex/api/upload'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mimeType: file.type })
|
||||
body: uploadForm
|
||||
});
|
||||
if (!presignRes.ok) throw new Error('Failed to get upload URL');
|
||||
const { key, uploadUrl } = await presignRes.json();
|
||||
const uploadRes = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: file,
|
||||
headers: { 'Content-Type': file.type }
|
||||
});
|
||||
if (!uploadRes.ok) throw new Error('Photo upload failed');
|
||||
if (!uploadRes.ok) throw new Error('Photo upload failed. Please try again.');
|
||||
const { key } = await uploadRes.json();
|
||||
photoKeys.push(key);
|
||||
}
|
||||
}
|
||||
if (reporterName) localStorage.setItem('reporterName', reporterName);
|
||||
|
||||
const endpoint = isNewSighting ? `/api/animals/${animalId}/sightings` : '/api/animals';
|
||||
const endpoint = isNewSighting
|
||||
? resolve(`/projects/animaldex/api/animals/${animalId}/sightings`)
|
||||
: resolve('/projects/animaldex/api/animals');
|
||||
const body = isNewSighting
|
||||
? {
|
||||
animalId,
|
||||
@@ -188,8 +207,6 @@
|
||||
animalName: animalName || undefined,
|
||||
description: description || undefined,
|
||||
reporterName: reporterName || undefined,
|
||||
aiBreedSuggestion: aiSuggestion?.breed,
|
||||
aiBreedConfidence: aiSuggestion?.confidence,
|
||||
seenAt: new Date(seenAt).toISOString(),
|
||||
lat,
|
||||
lng,
|
||||
@@ -207,7 +224,7 @@
|
||||
const err = await res.json();
|
||||
throw new Error(err.message ?? 'Submission failed');
|
||||
}
|
||||
goto(resolve('/?submitted=1'));
|
||||
goto(resolve('/projects/animaldex?submitted=1'));
|
||||
} catch (e: unknown) {
|
||||
submitError = e instanceof Error ? e.message : 'Something went wrong.';
|
||||
} finally {
|
||||
@@ -217,46 +234,49 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Animaldex - Log a sighting</title>
|
||||
<link rel="stylesheet" href="https://cdn.maptiler.com/maptiler-sdk-js/latest/maptiler-sdk.css" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div
|
||||
class="sticky top-0 z-10 flex items-center gap-3 border-b border-gray-100 bg-white px-4 py-3"
|
||||
<section class="mx-auto max-w-3xl px-4 py-10 sm:px-6 sm:py-14">
|
||||
<a
|
||||
href={resolve('/projects/animaldex')}
|
||||
class="text-base text-[var(--site-muted)] hover:text-[var(--site-text)]">← animaldex</a
|
||||
>
|
||||
<a href={resolve('/')} class="text-gray-400 hover:text-gray-600">←</a>
|
||||
<h1 class="font-semibold text-gray-900">
|
||||
{isNewSighting ? 'Log another sighting' : 'Register an animal'}
|
||||
</h1>
|
||||
</div>
|
||||
<h1 class="mt-5 text-4xl font-semibold tracking-tight sm:text-5xl">
|
||||
{isNewSighting ? 'add a sighting' : 'add an animal'}
|
||||
</h1>
|
||||
<p class="mt-2 text-base text-[var(--site-muted)]">
|
||||
{isNewSighting ? 'mark where you saw this animal' : 'add a neighborhood animal to the map'}
|
||||
</p>
|
||||
|
||||
<div class="mx-auto max-w-lg space-y-5 px-4 py-6">
|
||||
<div class="mt-8 space-y-8">
|
||||
{#if locationError}
|
||||
<div class="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||
<p class="font-medium">📍 Location required</p>
|
||||
<div role="alert" class="border-y border-[var(--site-border)] py-4 text-base">
|
||||
<p class="font-medium">location required</p>
|
||||
<p class="mt-1">{locationError}</p>
|
||||
<p class="mt-2 text-xs">
|
||||
Go to <strong>Settings → Site permissions → Location</strong> and allow this site.
|
||||
</p>
|
||||
<button type="button" onclick={requestLocation} class="mt-3 underline underline-offset-4"
|
||||
>try again</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<section class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<h2 class="mb-3 text-sm font-semibold text-gray-700">
|
||||
Photos
|
||||
<section class="border-t border-[var(--site-border)] pt-6">
|
||||
<h2 class="mb-3 text-xl font-semibold">
|
||||
photos
|
||||
{#if isNewSighting}
|
||||
<span class="text-xs font-normal text-gray-400">(optional)</span>
|
||||
<span class="text-base font-normal text-[var(--site-muted)]">(optional)</span>
|
||||
{:else}
|
||||
<span class="text-red-400">*</span>
|
||||
<span class="text-base font-normal text-[var(--site-muted)]">(at least one)</span>
|
||||
{/if}
|
||||
</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each photoPreviews as preview, i (i)}
|
||||
<div class="relative h-20 w-20 overflow-hidden rounded-xl">
|
||||
<div class="relative h-20 w-20 overflow-hidden rounded">
|
||||
<img src={preview} alt="" class="h-full w-full object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removePhoto(i)}
|
||||
aria-label={`Remove photo ${i + 1}`}
|
||||
class="absolute top-1 right-1 flex h-5 w-5 items-center justify-center rounded-full bg-black/60 text-xs text-white"
|
||||
>✕</button
|
||||
>
|
||||
@@ -264,59 +284,36 @@
|
||||
{/each}
|
||||
{#if photoFiles.length < 10}
|
||||
<label
|
||||
class="flex h-20 w-20 cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-gray-200 text-gray-400 hover:border-orange-300"
|
||||
class="relative flex min-h-28 w-full cursor-pointer items-center justify-center rounded border border-dashed border-[var(--site-border)] text-center text-base text-[var(--site-muted)] hover:text-[var(--site-text)] focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--site-text)] {draggingPhotos
|
||||
? 'bg-[var(--site-surface)]'
|
||||
: ''}"
|
||||
ondragover={(event) => {
|
||||
event.preventDefault();
|
||||
draggingPhotos = true;
|
||||
}}
|
||||
ondragleave={() => (draggingPhotos = false)}
|
||||
ondrop={dropPhotos}
|
||||
>
|
||||
<span class="text-2xl">+</span>
|
||||
<span class="text-xs">Photo</span>
|
||||
<span>drop photos here or choose files</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/heic"
|
||||
multiple
|
||||
class="hidden"
|
||||
class="absolute inset-0 w-full cursor-pointer opacity-0"
|
||||
onchange={handlePhotoChange}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-400">Max 10 photos · JPEG, PNG, WebP, HEIC</p>
|
||||
<p class="mt-3 text-base text-[var(--site-muted)]">
|
||||
up to 10 photos · JPEG, PNG, WebP, HEIC · 5 MB each
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{#if aiLoading}
|
||||
<div class="flex items-center gap-2 rounded-xl bg-orange-50 p-3 text-sm text-orange-700">
|
||||
<span class="animate-spin">⟳</span> Identifying breed…
|
||||
</div>
|
||||
{:else if aiSuggestion && !aiDismissed}
|
||||
<div class="rounded-xl border border-orange-200 bg-orange-50 p-3">
|
||||
<p class="text-sm font-medium text-orange-800">
|
||||
🤖 AI thinks this is a <strong>{aiSuggestion.breed}</strong>
|
||||
<span class="text-xs font-normal text-orange-600"
|
||||
>({Math.round(aiSuggestion.confidence * 100)}% confident)</span
|
||||
>
|
||||
</p>
|
||||
<div class="mt-2 flex gap-2">
|
||||
<button
|
||||
onclick={() => {
|
||||
if (aiSuggestion) breed = aiSuggestion.breed;
|
||||
}}
|
||||
class="rounded-lg bg-orange-500 px-3 py-1 text-xs font-semibold text-white"
|
||||
>Use this</button
|
||||
>
|
||||
<button
|
||||
onclick={() => {
|
||||
aiDismissed = true;
|
||||
aiSuggestion = null;
|
||||
}}
|
||||
class="rounded-lg border border-orange-200 px-3 py-1 text-xs text-orange-700"
|
||||
>Dismiss</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !isNewSighting}
|
||||
<section class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<h2 class="mb-3 text-sm font-semibold text-gray-700">
|
||||
Species <span class="text-red-400">*</span>
|
||||
<section class="border-t border-[var(--site-border)] pt-6">
|
||||
<h2 class="mb-3 text-xl font-semibold">
|
||||
Species <span class="text-[var(--site-muted)]">*</span>
|
||||
</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each COMMON_SPECIES as s (s)}
|
||||
@@ -325,107 +322,120 @@
|
||||
species = s;
|
||||
customSpecies = '';
|
||||
}}
|
||||
class="rounded-full px-3 py-1.5 text-sm {species === s
|
||||
? 'bg-orange-500 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'}">{s}</button
|
||||
class="rounded border border-[var(--site-border)] px-3 py-1.5 text-base {species === s
|
||||
? 'bg-[var(--site-text)] text-[var(--site-bg)]'
|
||||
: 'bg-[var(--site-surface)] text-[var(--site-text)] hover:bg-[var(--site-hover)]'}"
|
||||
>{s}</button
|
||||
>
|
||||
{/each}
|
||||
<button
|
||||
onclick={() => {
|
||||
species = '__custom__';
|
||||
}}
|
||||
class="rounded-full px-3 py-1.5 text-sm {species === '__custom__'
|
||||
? 'bg-orange-500 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'}">Other…</button
|
||||
class="rounded border border-[var(--site-border)] px-3 py-1.5 text-base {species ===
|
||||
'__custom__'
|
||||
? 'bg-[var(--site-text)] text-[var(--site-bg)]'
|
||||
: 'bg-[var(--site-surface)] text-[var(--site-text)] hover:bg-[var(--site-hover)]'}"
|
||||
>Other…</button
|
||||
>
|
||||
</div>
|
||||
{#if species === '__custom__'}
|
||||
<input
|
||||
bind:value={customSpecies}
|
||||
placeholder="Enter species"
|
||||
class="mt-3 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||
class="mt-3 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
|
||||
/>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<h2 class="mb-1 text-sm font-semibold text-gray-700">
|
||||
Breed <span class="text-xs font-normal text-gray-400">(optional)</span>
|
||||
<section class="border-t border-[var(--site-border)] pt-6">
|
||||
<h2 class="mb-1 text-xl font-semibold">
|
||||
Breed <span class="text-base font-normal text-[var(--site-muted)]">(optional)</span>
|
||||
</h2>
|
||||
<input
|
||||
bind:value={breed}
|
||||
placeholder="e.g. Golden Retriever"
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3 rounded-2xl bg-white p-4 shadow-sm">
|
||||
<section class="space-y-3 border-t border-[var(--site-border)] pt-6">
|
||||
<div>
|
||||
<h2 class="mb-1 text-sm font-semibold text-gray-700">
|
||||
Animal name <span class="text-xs font-normal text-gray-400">(optional)</span>
|
||||
<h2 class="mb-1 text-xl font-semibold">
|
||||
Animal name <span class="text-base font-normal text-[var(--site-muted)]"
|
||||
>(optional)</span
|
||||
>
|
||||
</h2>
|
||||
<input
|
||||
bind:value={animalName}
|
||||
placeholder="e.g. Fluffy"
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="mb-1 text-sm font-semibold text-gray-700">
|
||||
Description <span class="text-xs font-normal text-gray-400">(optional)</span>
|
||||
<h2 class="mb-1 text-xl font-semibold">
|
||||
Description <span class="text-base font-normal text-[var(--site-muted)]"
|
||||
>(optional)</span
|
||||
>
|
||||
</h2>
|
||||
<textarea
|
||||
bind:value={description}
|
||||
placeholder="Behaviour, features, context…"
|
||||
rows="3"
|
||||
class="w-full resize-none rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||
class="w-full resize-none rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
|
||||
></textarea>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="space-y-3 rounded-2xl bg-white p-4 shadow-sm">
|
||||
<section class="space-y-3 border-t border-[var(--site-border)] pt-6">
|
||||
<div>
|
||||
<h2 class="mb-1 text-sm font-semibold text-gray-700">
|
||||
When did you see it? <span class="text-red-400">*</span>
|
||||
<h2 class="mb-1 text-xl font-semibold">
|
||||
When did you see it? <span class="text-[var(--site-muted)]">*</span>
|
||||
</h2>
|
||||
<input
|
||||
type="datetime-local"
|
||||
bind:value={seenAt}
|
||||
max={new Date().toISOString().slice(0, 16)}
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||
max={localDateTime()}
|
||||
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="mb-1 text-sm font-semibold text-gray-700">
|
||||
Your name <span class="text-xs font-normal text-gray-400">(optional)</span>
|
||||
<h2 class="mb-1 text-xl font-semibold">
|
||||
Your name <span class="text-base font-normal text-[var(--site-muted)]">(optional)</span>
|
||||
</h2>
|
||||
<input
|
||||
bind:value={reporterName}
|
||||
placeholder="Saved for next time"
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-orange-400"
|
||||
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<h2 class="mb-2 text-sm font-semibold text-gray-700">
|
||||
Location <span class="text-red-400">*</span>
|
||||
<section class="border-t border-[var(--site-border)] pt-6">
|
||||
<h2 class="mb-2 text-xl font-semibold">
|
||||
Location <span class="text-[var(--site-muted)]">*</span>
|
||||
</h2>
|
||||
{#if locationLoading}
|
||||
<div
|
||||
class="flex h-40 items-center justify-center rounded-xl bg-gray-100 text-sm text-gray-400"
|
||||
class="flex h-64 items-center justify-center rounded bg-[var(--site-surface)] text-base text-[var(--site-muted)]"
|
||||
>
|
||||
Getting your location…
|
||||
</div>
|
||||
{:else if lat !== null}
|
||||
<div bind:this={mapContainer} class="h-48 w-full overflow-hidden rounded-xl"></div>
|
||||
<p class="mt-2 text-xs text-gray-400">Drag the pin to fine-tune (max 30 km from you).</p>
|
||||
<div
|
||||
bind:this={mapContainer}
|
||||
class="h-64 w-full overflow-hidden rounded border border-[var(--site-border)]"
|
||||
></div>
|
||||
{#if mapError}<p role="alert" class="mt-2 text-base">{mapError}</p>{/if}
|
||||
<p class="mt-2 text-base text-[var(--site-muted)]">
|
||||
Drag the pin to fine-tune (max 30 km from you).
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if submitError}
|
||||
<div class="rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||
<div role="alert" class="border-y border-[var(--site-border)] py-3 text-base">
|
||||
{submitError}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -433,13 +443,13 @@
|
||||
<button
|
||||
onclick={submit}
|
||||
disabled={submitting || !!locationError || lat === null}
|
||||
class="w-full rounded-xl bg-orange-500 py-3 font-semibold text-white hover:bg-orange-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
class="w-full rounded bg-[var(--site-text)] py-3 font-medium text-[var(--site-bg)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Submitting…' : isNewSighting ? 'Submit sighting' : 'Register animal'}
|
||||
</button>
|
||||
|
||||
<p class="pb-8 text-center text-xs text-gray-400">
|
||||
<p class="pb-8 text-center text-base text-[var(--site-muted)]">
|
||||
All submissions are reviewed before appearing on the map.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
return { user: locals.user ?? null };
|
||||
};
|
||||
export {};
|
||||
|
||||
@@ -1,30 +1,7 @@
|
||||
<script lang="ts">
|
||||
import './layout.css';
|
||||
import type { LayoutProps } from './$types';
|
||||
|
||||
let { data, children }: LayoutProps = $props();
|
||||
let { children }: LayoutProps = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen flex-col">
|
||||
<header class="flex justify-center border-b border-gray-200 px-6 py-3">
|
||||
<div class="flex items-center gap-4">
|
||||
{#if data.user}
|
||||
<span class="text-sm text-gray-600">Logged in as {data.user.name}</span>
|
||||
<form method="POST" action="/logout">
|
||||
<button
|
||||
class="cursor-pointer rounded-lg bg-gray-200 px-4 py-1.5 text-sm font-bold hover:bg-gray-300"
|
||||
>Log out</button
|
||||
>
|
||||
</form>
|
||||
{:else}
|
||||
<a
|
||||
href="/login"
|
||||
class="rounded-lg bg-blue-500 px-4 py-1.5 text-sm font-bold text-white hover:bg-blue-600"
|
||||
>Log in</a
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 p-4">{@render children()}</main>
|
||||
</div>
|
||||
<div class="p-4">{@render children()}</div>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { game, pokemonGameEntry, userEntry, userGame } from '$lib/server/db/schema';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import {
|
||||
games,
|
||||
pokemonGameEntries,
|
||||
userEntries,
|
||||
userGames,
|
||||
catchEmAllSettings
|
||||
} from '$lib/server/db/schema';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { availabilityValues } from '$lib/availability';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
@@ -11,7 +17,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
with: {
|
||||
entry: true
|
||||
},
|
||||
orderBy: (pokemon) => pokemon.number
|
||||
orderBy: { number: 'asc' }
|
||||
});
|
||||
const pokemon = pokemonRows.map(({ entry, ...rest }) => ({
|
||||
...rest,
|
||||
@@ -21,27 +27,39 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
})
|
||||
}));
|
||||
|
||||
const games = await db.query.game.findMany({
|
||||
orderBy: (game) => [game.generation, game.id]
|
||||
const gameRows = await db.query.games.findMany({
|
||||
orderBy: { generation: 'asc', id: 'asc' }
|
||||
});
|
||||
|
||||
let caughtIds: number[] = [];
|
||||
let selectedGameIds: number[] = [];
|
||||
let gamesConfigured = false;
|
||||
if (locals.user) {
|
||||
const [settings] = await db
|
||||
.select()
|
||||
.from(catchEmAllSettings)
|
||||
.where(eq(catchEmAllSettings.userId, locals.user.id));
|
||||
gamesConfigured = !!settings;
|
||||
const caughtRows = await db
|
||||
.select({ id: userEntry.pokemonGameEntryId })
|
||||
.from(userEntry)
|
||||
.where(eq(userEntry.userId, locals.user.id));
|
||||
.select({ id: userEntries.pokemonGameEntryId })
|
||||
.from(userEntries)
|
||||
.where(eq(userEntries.userId, locals.user.id));
|
||||
caughtIds = caughtRows.map((r) => r.id);
|
||||
|
||||
const gameRows = await db
|
||||
.select({ id: userGame.gameId })
|
||||
.from(userGame)
|
||||
.where(eq(userGame.userId, locals.user.id));
|
||||
.select({ id: userGames.gameId })
|
||||
.from(userGames)
|
||||
.where(eq(userGames.userId, locals.user.id));
|
||||
selectedGameIds = gameRows.map((r) => r.id);
|
||||
}
|
||||
|
||||
return { pokemon, games, caughtIds, selectedGameIds };
|
||||
return {
|
||||
pokemon,
|
||||
games: gameRows,
|
||||
caughtIds,
|
||||
selectedGameIds,
|
||||
gamesConfigured: gamesConfigured || selectedGameIds.length > 0
|
||||
};
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
@@ -50,58 +68,71 @@ export const actions = {
|
||||
|
||||
const data = await request.formData();
|
||||
const entryId = Number(data.get('entryId'));
|
||||
if (!Number.isInteger(entryId) || entryId <= 0) return fail(400, { message: 'Invalid entry' });
|
||||
if (!Number.isSafeInteger(entryId) || entryId <= 0 || entryId > 2147483647)
|
||||
return fail(400, { message: 'Invalid entry' });
|
||||
|
||||
// only catchable entries can be toggled — the client disables these
|
||||
// checkboxes but we can't trust that
|
||||
const [entry] = await db
|
||||
.select({ availability: pokemonGameEntry.availability })
|
||||
.from(pokemonGameEntry)
|
||||
.where(eq(pokemonGameEntry.id, entryId));
|
||||
.select({ availability: pokemonGameEntries.availability })
|
||||
.from(pokemonGameEntries)
|
||||
.where(eq(pokemonGameEntries.id, entryId));
|
||||
if (!entry) return fail(404, { message: 'Unknown entry' });
|
||||
if (entry.availability !== 'catchable') {
|
||||
return fail(400, { message: 'This pokemon is not catchable in this game' });
|
||||
return fail(400, { message: 'This entry does not have confirmed encounter data' });
|
||||
}
|
||||
|
||||
if (data.has('caught') && data.get('caught') !== 'on')
|
||||
return fail(400, { message: 'Invalid caught state' });
|
||||
const caught = data.get('caught') === 'on';
|
||||
|
||||
if (caught) {
|
||||
await db
|
||||
.insert(userEntry)
|
||||
.insert(userEntries)
|
||||
.values({ pokemonGameEntryId: entryId, userId: locals.user.id })
|
||||
.onConflictDoNothing();
|
||||
} else {
|
||||
await db
|
||||
.delete(userEntry)
|
||||
.delete(userEntries)
|
||||
.where(
|
||||
and(eq(userEntry.pokemonGameEntryId, entryId), eq(userEntry.userId, locals.user.id))
|
||||
and(eq(userEntries.pokemonGameEntryId, entryId), eq(userEntries.userId, locals.user.id))
|
||||
);
|
||||
}
|
||||
return { saved: true };
|
||||
},
|
||||
|
||||
setGames: async ({ request, locals }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Log in to set rules' });
|
||||
|
||||
const data = await request.formData();
|
||||
const gameIds = data
|
||||
.getAll('gameIds')
|
||||
.map(Number)
|
||||
.filter((id) => Number.isInteger(id) && id > 0);
|
||||
const submittedIds = data.getAll('gameIds').map(Number);
|
||||
if (
|
||||
submittedIds.length > 200 ||
|
||||
submittedIds.some((id) => !Number.isSafeInteger(id) || id <= 0)
|
||||
) {
|
||||
return fail(400, { message: 'Invalid game selection' });
|
||||
}
|
||||
const gameIds = [...new Set(submittedIds)];
|
||||
|
||||
if (gameIds.length > 0) {
|
||||
const validGames = await db
|
||||
.select({ id: game.id })
|
||||
.from(game)
|
||||
.where(inArray(game.id, gameIds));
|
||||
.select({ id: games.id })
|
||||
.from(games)
|
||||
.where(inArray(games.id, gameIds));
|
||||
if (validGames.length !== gameIds.length) return fail(400, { message: 'Unknown game' });
|
||||
}
|
||||
|
||||
const userId = locals.user.id;
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(userGame).where(eq(userGame.userId, userId));
|
||||
await tx
|
||||
.insert(catchEmAllSettings)
|
||||
.values({ userId })
|
||||
.onConflictDoUpdate({ target: catchEmAllSettings.userId, set: { userId } });
|
||||
await tx.delete(userGames).where(eq(userGames.userId, userId));
|
||||
if (gameIds.length > 0) {
|
||||
await tx.insert(userGame).values(gameIds.map((gameId) => ({ userId, gameId })));
|
||||
await tx.insert(userGames).values(gameIds.map((gameId) => ({ userId, gameId })));
|
||||
}
|
||||
});
|
||||
return { saved: true };
|
||||
}
|
||||
} satisfies Actions;
|
||||
|
||||
@@ -1,59 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { deserialize, enhance } from '$app/forms';
|
||||
import { availabilityValues, type Availability } from '$lib/availability';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { PageProps } from './$types';
|
||||
import type { Game } from '$lib/server/db/schema';
|
||||
import ProgressBar from '$lib/components/ProgressBar.svelte';
|
||||
|
||||
type Entry = { id: number; availability: Availability; note: string | null };
|
||||
let { data, form }: PageProps = $props();
|
||||
const caught = new SvelteSet<number>();
|
||||
const pending = new SvelteSet<number>();
|
||||
let saveError = $state('');
|
||||
$effect(() => {
|
||||
caught.clear();
|
||||
for (const id of data.caughtIds) caught.add(id);
|
||||
});
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
const caught = new SvelteSet(data.caughtIds);
|
||||
|
||||
// the full table is ~1000 rows x ~40 games; only render the rows inside
|
||||
// the scroll viewport (plus some overscan) to keep the DOM small
|
||||
const ROW_HEIGHT = 37; // keep in sync with h-[37px] on the rows
|
||||
const OVERSCAN = 10;
|
||||
let scrollEl = $state<HTMLDivElement>();
|
||||
let scrollTop = $state(0);
|
||||
let viewportHeight = $state(600);
|
||||
const startIndex = $derived(Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN));
|
||||
const endIndex = $derived(
|
||||
Math.min(data.pokemon.length, Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + OVERSCAN)
|
||||
);
|
||||
const visiblePokemon = $derived(data.pokemon.slice(startIndex, endIndex));
|
||||
|
||||
// optimistic toggle without a <form> per cell — one shared handler instead
|
||||
// of ~10k use:enhance initializations
|
||||
async function toggleCaught(entryId: number, checked: boolean) {
|
||||
if (pending.has(entryId)) return;
|
||||
pending.add(entryId);
|
||||
saveError = '';
|
||||
if (checked) caught.add(entryId);
|
||||
else caught.delete(entryId);
|
||||
|
||||
const body = new FormData();
|
||||
body.set('entryId', String(entryId));
|
||||
if (checked) body.set('caught', 'on');
|
||||
let failed = false;
|
||||
let failed: boolean;
|
||||
try {
|
||||
const response = await fetch('?/togglePokemonCaught', { method: 'POST', body });
|
||||
const result = deserialize(await response.text());
|
||||
failed = result.type === 'failure' || result.type === 'error';
|
||||
failed = result.type !== 'success';
|
||||
} catch {
|
||||
failed = true;
|
||||
}
|
||||
pending.delete(entryId);
|
||||
if (failed) {
|
||||
// server rejected the toggle, roll back the optimistic update
|
||||
saveError = 'Could not save your catch. Please try again, or log in again.';
|
||||
if (checked) caught.delete(entryId);
|
||||
else caught.add(entryId);
|
||||
}
|
||||
}
|
||||
|
||||
// pokemonId -> gameId -> entry, for quick cell lookup
|
||||
const entryMap = $derived(
|
||||
new Map(
|
||||
data.pokemon.map((pkmn) => [
|
||||
pkmn.id,
|
||||
data.pokemon.map((pokemon) => [
|
||||
pokemon.id,
|
||||
new Map(
|
||||
pkmn.entries.map(([id, gameId, code, note]) => [
|
||||
pokemon.entries.map(([id, gameId, code, note]) => [
|
||||
gameId,
|
||||
{ id, availability: availabilityValues[code], note: note ?? null } satisfies Entry
|
||||
])
|
||||
@@ -61,17 +55,21 @@
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
// empty selection = user hasn't set rules yet, show everything
|
||||
const selectedGameIds = $derived(new Set(data.selectedGameIds));
|
||||
const visibleGames = $derived(
|
||||
selectedGameIds.size > 0 ? data.games.filter((g) => selectedGameIds.has(g.id)) : data.games
|
||||
data.gamesConfigured ? data.games.filter((game) => selectedGameIds.has(game.id)) : data.games
|
||||
);
|
||||
const visibleGameIds = $derived(new Set(visibleGames.map((g) => g.id)));
|
||||
const visibleGameIds = $derived(new Set(visibleGames.map((game) => game.id)));
|
||||
let viewGameId = $state<number | null>(null);
|
||||
$effect(() => {
|
||||
if (!visibleGames.some((game) => game.id === viewGameId))
|
||||
viewGameId = visibleGames[0]?.id ?? null;
|
||||
});
|
||||
const viewGame = $derived(visibleGames.find((game) => game.id === viewGameId));
|
||||
|
||||
const catchableEntries = $derived(
|
||||
data.pokemon
|
||||
.flatMap((pkmn) => pkmn.entries)
|
||||
.flatMap((pokemon) => pokemon.entries)
|
||||
.filter(
|
||||
([, gameId, code]) => availabilityValues[code] === 'catchable' && visibleGameIds.has(gameId)
|
||||
)
|
||||
@@ -80,194 +78,227 @@
|
||||
const caughtCount = $derived(
|
||||
catchableEntries.reduce((n, [id]) => n + (caught.has(id) ? 1 : 0), 0)
|
||||
);
|
||||
const percentage = $derived(total > 0 ? ((caughtCount / total) * 100).toFixed(2) : '0.00');
|
||||
const percentage = $derived(total > 0 ? ((caughtCount / total) * 100).toFixed(1) : '0.0');
|
||||
|
||||
let search = $state('');
|
||||
let pageNumber = $state(1);
|
||||
const pageSize = 25;
|
||||
const filteredPokemon = $derived(
|
||||
data.pokemon.filter((pokemon) =>
|
||||
`${pokemon.number} ${pokemon.name}`.toLowerCase().includes(search.trim().toLowerCase())
|
||||
)
|
||||
);
|
||||
const pageCount = $derived(Math.max(1, Math.ceil(filteredPokemon.length / pageSize)));
|
||||
$effect(() => {
|
||||
if (pageNumber > pageCount) pageNumber = pageCount;
|
||||
});
|
||||
const visiblePokemon = $derived(
|
||||
filteredPokemon.slice((pageNumber - 1) * pageSize, pageNumber * pageSize)
|
||||
);
|
||||
const gamesByGeneration = $derived(
|
||||
[...Map.groupBy(data.games, (g) => g.generation)].sort(([a], [b]) => a - b)
|
||||
[...Map.groupBy(data.games, (game) => game.generation)].sort(([a], [b]) => a - b)
|
||||
);
|
||||
|
||||
// rules modal
|
||||
let rulesDialog: HTMLDialogElement;
|
||||
let draft = $state(new SvelteSet<number>());
|
||||
|
||||
const draft = new SvelteSet<number>();
|
||||
function openRules() {
|
||||
draft = new SvelteSet(selectedGameIds.size > 0 ? selectedGameIds : data.games.map((g) => g.id));
|
||||
draft.clear();
|
||||
for (const id of data.gamesConfigured ? selectedGameIds : data.games.map((game) => game.id))
|
||||
draft.add(id);
|
||||
rulesDialog.showModal();
|
||||
}
|
||||
|
||||
function tooltip(entry: Entry | undefined, game: Game): string | null {
|
||||
if (!data.user) return 'Log in to track your progress';
|
||||
if (!entry) return `Not available in ${game.name}`;
|
||||
if (!entry) return `No catalog record for ${game.name}; availability is not confirmed`;
|
||||
const reason = {
|
||||
catchable: null,
|
||||
trade_only: 'Only obtainable by trading in this game',
|
||||
transfer_only: 'Only obtainable by transferring from another game',
|
||||
event_only: 'Only obtainable through an event distribution'
|
||||
event_only: 'Only obtainable through an event distribution',
|
||||
unknown: 'Availability has not been confirmed'
|
||||
}[entry.availability];
|
||||
if (reason === null) return null;
|
||||
return entry.note ? `${reason} (${entry.note})` : reason;
|
||||
}
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
Normal: '#A8A77A',
|
||||
Fire: '#EE8130',
|
||||
Water: '#6390F0',
|
||||
Electric: '#F7D02C',
|
||||
Grass: '#7AC74C',
|
||||
Ice: '#96D9D6',
|
||||
Fighting: '#C22E28',
|
||||
Poison: '#A33EA1',
|
||||
Ground: '#E2BF65',
|
||||
Flying: '#A98FF3',
|
||||
Psychic: '#F95587',
|
||||
Bug: '#A6B91A',
|
||||
Rock: '#B6A136',
|
||||
Ghost: '#735797',
|
||||
Dragon: '#6F35FC',
|
||||
Dark: '#705746',
|
||||
Steel: '#B7B7CE',
|
||||
Fairy: '#D685AD'
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>catchemall</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<h1 class="py-4 text-center text-2xl font-bold">Gotta catch 'em all!</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<p class="max-w-200 py-6 text-center">
|
||||
Below are all {data.pokemon.length} Pokemon across {data.games.length} games. Play through all generations
|
||||
and catch all catchable pokemon in each. Use the "Set rules" button to pick which games you want to
|
||||
complete.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if !data.user}
|
||||
<p class="pb-4 text-center text-sm text-gray-500">
|
||||
<a href="/login" class="font-bold text-blue-500 underline">Log in</a> to save your progress.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-center gap-4 py-4">
|
||||
<button
|
||||
class="cursor-pointer rounded-lg bg-blue-400 px-4 py-2 font-bold text-white hover:bg-blue-500"
|
||||
onclick={openRules}>Set rules</button
|
||||
>
|
||||
|
||||
<p>{caughtCount}/{total} entries caught ({percentage} %)</p>
|
||||
<progress value={total > 0 ? caughtCount / total : 0}></progress>
|
||||
</div>
|
||||
|
||||
<div
|
||||
bind:this={scrollEl}
|
||||
bind:clientHeight={viewportHeight}
|
||||
onscroll={() => (scrollTop = scrollEl?.scrollTop ?? 0)}
|
||||
class="max-h-[75vh] overflow-auto rounded-xl border border-gray-300"
|
||||
>
|
||||
<table class="min-w-full table-fixed border-separate border-spacing-0 text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
class="sticky top-0 left-0 z-30 w-90 border-r border-b border-gray-300 bg-white px-2 text-left"
|
||||
>Pokemon</th
|
||||
>
|
||||
{#each visibleGames as game (game.id)}
|
||||
<th
|
||||
class="sticky top-0 z-20 w-9 border-b border-gray-300 bg-white px-1 pt-2 pb-1 align-bottom"
|
||||
>
|
||||
<span class="mx-auto rotate-180 [writing-mode:vertical-rl]">{game.name}</span>
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if startIndex > 0}
|
||||
<tr aria-hidden="true">
|
||||
<td colspan={visibleGames.length + 1} style="height: {startIndex * ROW_HEIGHT}px"></td>
|
||||
</tr>
|
||||
{/if}
|
||||
{#each visiblePokemon as pokemon (pokemon.id)}
|
||||
<tr class="h-[37px] hover:bg-blue-50">
|
||||
<th
|
||||
class="sticky left-0 z-10 border-r border-b border-gray-200 bg-white px-2 py-0.5 text-left font-normal whitespace-nowrap"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{pokemon.number}.png"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
class="h-8 w-8"
|
||||
/>
|
||||
<span class="min-w-10 font-bold">{pokemon.number}</span>
|
||||
<span class="min-w-30">{pokemon.name}</span>
|
||||
<span
|
||||
class="rounded-lg px-2 py-0.5 text-xs"
|
||||
style:background-color={typeColors[pokemon.type1]}>{pokemon.type1}</span
|
||||
>
|
||||
{#if pokemon.type2}
|
||||
<span
|
||||
class="rounded-lg px-2 py-0.5 text-xs"
|
||||
style:background-color={typeColors[pokemon.type2]}>{pokemon.type2}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
{#each visibleGames as game (game.id)}
|
||||
{@const entry = entryMap.get(pokemon.id)?.get(game.id)}
|
||||
{@const reason = tooltip(entry, game)}
|
||||
<td class="border-b border-gray-200 text-center">
|
||||
{#if entry && reason === null}
|
||||
<input
|
||||
type="checkbox"
|
||||
class="cursor-pointer"
|
||||
checked={caught.has(entry.id)}
|
||||
onchange={(e) => toggleCaught(entry.id, e.currentTarget.checked)}
|
||||
/>
|
||||
{:else}
|
||||
<span title={reason} class="cursor-help">
|
||||
<input type="checkbox" disabled checked={entry ? caught.has(entry.id) : false} />
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
{#if endIndex < data.pokemon.length}
|
||||
<tr aria-hidden="true">
|
||||
<td
|
||||
colspan={visibleGames.length + 1}
|
||||
style="height: {(data.pokemon.length - endIndex) * ROW_HEIGHT}px"
|
||||
></td>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<dialog bind:this={rulesDialog} class="m-auto w-full max-w-2xl rounded-xl p-6 backdrop:bg-black/50">
|
||||
<h2 class="pb-2 text-xl font-bold">Set rules</h2>
|
||||
<p class="pb-4 text-sm text-gray-600">
|
||||
Pick the games you want to catch 'em all in. Only those show up in the table and count towards
|
||||
your progress.
|
||||
</p>
|
||||
|
||||
<div class="flex gap-2 pb-4">
|
||||
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
|
||||
<div class="flex flex-wrap items-end justify-between gap-5">
|
||||
<div>
|
||||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">catchemall</h1>
|
||||
<p class="mt-2 text-[var(--site-muted)]">
|
||||
{data.pokemon.length} Pokémon · {data.games.length} games
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer rounded-lg bg-gray-200 px-3 py-1 text-sm hover:bg-gray-300"
|
||||
onclick={() => (draft = new SvelteSet(data.games.map((g) => g.id)))}>All games</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer rounded-lg bg-gray-200 px-3 py-1 text-sm hover:bg-gray-300"
|
||||
onclick={() => (draft = new SvelteSet())}>None</button
|
||||
onclick={openRules}
|
||||
class="rounded border border-[var(--site-border)] px-4 py-2 hover:bg-[var(--site-hover)]"
|
||||
>set rules</button
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if !data.user}<p class="mt-6">
|
||||
<a
|
||||
href={resolve('/login?returnTo=/projects/catch-em-all')}
|
||||
class="underline underline-offset-4">sign in</a
|
||||
> to save your progress.
|
||||
</p>{/if}
|
||||
{#if saveError}<p role="alert" class="mt-5 text-red-500">{saveError}</p>{/if}
|
||||
{#if !data.pokemon.length}<p class="mt-8 text-[var(--site-muted)]">
|
||||
the Pokémon catalog has not been loaded yet.
|
||||
</p>{/if}
|
||||
|
||||
<div class="mt-8 border-y border-[var(--site-border)] py-6">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<p class="font-medium">overall progress</p>
|
||||
<p class="text-2xl font-semibold tabular-nums">{percentage}%</p>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<ProgressBar value={caughtCount} max={total} label="Overall catch progress" />
|
||||
</div>
|
||||
<p class="mt-2 text-base text-[var(--site-muted)]">{caughtCount} of {total} entries caught</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 grid gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<label class="block"
|
||||
>search Pokémon
|
||||
<input
|
||||
type="search"
|
||||
bind:value={search}
|
||||
oninput={() => (pageNumber = 1)}
|
||||
placeholder="name or number"
|
||||
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-3"
|
||||
/>
|
||||
</label>
|
||||
<label class="block"
|
||||
>game
|
||||
<select
|
||||
bind:value={viewGameId}
|
||||
disabled={!visibleGames.length}
|
||||
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-3"
|
||||
>
|
||||
{#each visibleGames as game (game.id)}<option value={game.id}>{game.name}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if viewGame}
|
||||
<div class="mt-8">
|
||||
<table class="w-full table-fixed border-collapse">
|
||||
<colgroup><col /><col class="w-24 sm:w-36" /></colgroup>
|
||||
<thead
|
||||
><tr class="border-b border-[var(--site-border)] text-left text-[var(--site-muted)]"
|
||||
><th scope="col" class="py-3 font-medium">Pokémon</th><th
|
||||
scope="col"
|
||||
class="py-3 text-center font-medium">caught</th
|
||||
></tr
|
||||
></thead
|
||||
>
|
||||
<tbody>
|
||||
{#each visiblePokemon as pokemon (pokemon.id)}
|
||||
{@const entry = entryMap.get(pokemon.id)?.get(viewGame.id)}
|
||||
{@const reason = tooltip(entry, viewGame)}
|
||||
<tr class="border-b border-[var(--site-border)]">
|
||||
<th scope="row" class="py-2 text-left font-normal">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<img
|
||||
src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{pokemon.number}.png"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
width="40"
|
||||
height="40"
|
||||
class="h-10 w-10 shrink-0"
|
||||
/>
|
||||
<span class="min-w-0"
|
||||
><span class="mr-2 text-[var(--site-muted)]">#{pokemon.number}</span
|
||||
>{pokemon.name}</span
|
||||
>
|
||||
</div>
|
||||
</th>
|
||||
<td class="py-2 text-center">
|
||||
{#if entry && reason === null}
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-5 w-5 cursor-pointer accent-[var(--site-text)]"
|
||||
checked={caught.has(entry.id)}
|
||||
disabled={!data.user || pending.has(entry.id)}
|
||||
aria-label={`Caught ${pokemon.name} in ${viewGame.name}`}
|
||||
title={data.user ? (entry.note ?? undefined) : 'Sign in to save your progress'}
|
||||
onchange={(event) => toggleCaught(entry.id, event.currentTarget.checked)}
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
title={reason ?? undefined}
|
||||
aria-label={`${pokemon.name} in ${viewGame.name}: ${reason}`}
|
||||
class="text-[var(--site-muted)]"
|
||||
>{!entry ? '—' : entry.availability === 'unknown' ? '?' : '·'}</span
|
||||
>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr><td colspan="2" class="py-8 text-[var(--site-muted)]">no Pokémon found</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="mt-5 flex flex-wrap items-center justify-between gap-4">
|
||||
<p class="text-[var(--site-muted)]">
|
||||
{filteredPokemon.length ? (pageNumber - 1) * pageSize + 1 : 0}–{Math.min(
|
||||
pageNumber * pageSize,
|
||||
filteredPokemon.length
|
||||
)} of {filteredPokemon.length}
|
||||
</p>
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
disabled={pageNumber === 1}
|
||||
onclick={() => (pageNumber -= 1)}
|
||||
class="disabled:opacity-40">previous</button
|
||||
>
|
||||
<span class="text-[var(--site-muted)]">{pageNumber}/{pageCount}</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pageNumber === pageCount}
|
||||
onclick={() => (pageNumber += 1)}
|
||||
class="disabled:opacity-40">next</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if data.pokemon.length}
|
||||
<p class="mt-8 text-[var(--site-muted)]">no games selected. use “set rules” to choose one.</p>
|
||||
{/if}
|
||||
|
||||
<details class="mt-10 border-t border-[var(--site-border)] pt-5 text-[var(--site-muted)]">
|
||||
<summary class="cursor-pointer">about availability</summary>
|
||||
<p class="mt-3 max-w-3xl">
|
||||
checkboxes represent confirmed encounters from PokéAPI. “?” means the Pokémon appears in a
|
||||
game catalog but its acquisition method is unconfirmed. “—” means there is no source record.
|
||||
Neither proves it is unobtainable.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<dialog
|
||||
bind:this={rulesDialog}
|
||||
class="m-auto w-[calc(100%-2rem)] max-w-2xl rounded-xl border border-[var(--site-border)] bg-[var(--site-surface)] p-5 text-[var(--site-text)] backdrop:bg-black/70 sm:p-6"
|
||||
>
|
||||
<h2 class="text-2xl font-semibold">set rules</h2>
|
||||
<p class="mt-2 text-[var(--site-muted)]">choose the games that count toward your progress.</p>
|
||||
{#if form?.message}<p role="alert" class="mt-3 text-red-500">{form.message}</p>{/if}
|
||||
<div class="mt-5 flex gap-5">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
draft.clear();
|
||||
for (const game of data.games) draft.add(game.id);
|
||||
}}
|
||||
class="underline underline-offset-4">all</button
|
||||
>
|
||||
<button type="button" onclick={() => draft.clear()} class="underline underline-offset-4"
|
||||
>none</button
|
||||
>
|
||||
</div>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/setGames"
|
||||
@@ -278,51 +309,45 @@
|
||||
};
|
||||
}}
|
||||
>
|
||||
<div class="grid max-h-96 grid-cols-2 gap-x-6 overflow-y-auto sm:grid-cols-3">
|
||||
<div class="mt-5 grid max-h-[50vh] gap-5 overflow-y-auto pr-2 sm:grid-cols-2">
|
||||
{#each gamesByGeneration as [generation, games] (generation)}
|
||||
<fieldset class="pb-3">
|
||||
<legend class="flex items-center gap-2 pb-1 font-bold">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={games.every((g) => draft.has(g.id))}
|
||||
onchange={(e) => {
|
||||
for (const g of games) {
|
||||
if (e.currentTarget.checked) draft.add(g.id);
|
||||
else draft.delete(g.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
Gen {generation}
|
||||
</legend>
|
||||
{#each games as game (game.id)}
|
||||
<label class="flex items-center gap-2 py-0.5 pl-4 text-sm">
|
||||
<input
|
||||
<fieldset>
|
||||
<legend class="mb-2 font-semibold"
|
||||
><label class="flex items-center gap-2"
|
||||
><input
|
||||
type="checkbox"
|
||||
checked={games.every((game) => draft.has(game.id))}
|
||||
onchange={(event) => {
|
||||
for (const game of games) {
|
||||
if (event.currentTarget.checked) draft.add(game.id);
|
||||
else draft.delete(game.id);
|
||||
}
|
||||
}}
|
||||
/>generation {generation}</label
|
||||
></legend
|
||||
>
|
||||
{#each games as game (game.id)}<label class="flex items-center gap-2 py-1 pl-5"
|
||||
><input
|
||||
type="checkbox"
|
||||
name="gameIds"
|
||||
value={game.id}
|
||||
checked={draft.has(game.id)}
|
||||
onchange={(e) => {
|
||||
if (e.currentTarget.checked) draft.add(game.id);
|
||||
onchange={(event) => {
|
||||
if (event.currentTarget.checked) draft.add(game.id);
|
||||
else draft.delete(game.id);
|
||||
}}
|
||||
/>
|
||||
{game.name}
|
||||
</label>
|
||||
{/each}
|
||||
/>{game.name}</label
|
||||
>{/each}
|
||||
</fieldset>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-4">
|
||||
<div class="mt-6 flex justify-end gap-5">
|
||||
<button type="button" onclick={() => rulesDialog.close()}>cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer rounded-lg bg-gray-200 px-4 py-2 font-bold hover:bg-gray-300"
|
||||
onclick={() => rulesDialog.close()}>Cancel</button
|
||||
>
|
||||
<button
|
||||
class="cursor-pointer rounded-lg bg-blue-400 px-4 py-2 font-bold text-white hover:bg-blue-500"
|
||||
disabled={!data.user}
|
||||
title={data.user ? undefined : 'Log in to save rules'}>Save</button
|
||||
title={data.user ? undefined : 'Sign in to save rules'}
|
||||
class="rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)] disabled:opacity-40"
|
||||
>save</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1638
src/routes/projects/derby/[race]/+page.svelte
Normal file
1638
src/routes/projects/derby/[race]/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
11
src/routes/projects/derby/[race]/+page.ts
Normal file
11
src/routes/projects/derby/[race]/+page.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { raceBySlug, races } from '$lib/derby/races';
|
||||
import type { EntryGenerator, PageLoad } from './$types';
|
||||
|
||||
export const entries: EntryGenerator = () => races.map((r) => ({ race: r.slug }));
|
||||
|
||||
export const load: PageLoad = ({ params }) => {
|
||||
const race = raceBySlug(params.race);
|
||||
if (!race) error(404, 'Onbekende koers');
|
||||
return { race };
|
||||
};
|
||||
@@ -1,8 +1,11 @@
|
||||
import { isAdmin, requireAdmin } from '$lib/server/authorization';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
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 }) => {
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
const { params } = event;
|
||||
return {
|
||||
task: await db.query.tasks.findFirst({
|
||||
with: {
|
||||
@@ -17,28 +20,24 @@ export const load = async ({ cookies, params }) => {
|
||||
}
|
||||
}),
|
||||
topics: await db.query.topics.findMany(),
|
||||
editable: cookies.get('token')
|
||||
editable: isAdmin(event)
|
||||
};
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
createTopic: async ({ cookies, request, params }) => {
|
||||
if (!cookies.get('token')) {
|
||||
return { status: 401 };
|
||||
}
|
||||
export const actions: Actions = {
|
||||
createTopic: async (event) => {
|
||||
requireAdmin(event);
|
||||
const { request, params } = event;
|
||||
|
||||
const data = await request.formData();
|
||||
|
||||
const topic = await db
|
||||
.insert(topics)
|
||||
.values({ name: data.get('name') })
|
||||
.values({ name: String(data.get('name') ?? '').trim() })
|
||||
.returning();
|
||||
|
||||
const existing = await db.query.tasksToTopics.findFirst({
|
||||
where: and(
|
||||
eq(tasksToTopics.taskId, Number(params.id)),
|
||||
eq(tasksToTopics.topicId, topic[0].id)
|
||||
)
|
||||
where: { taskId: Number(params.id), topicId: topic[0].id }
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
@@ -48,21 +47,18 @@ export const actions = {
|
||||
.returning();
|
||||
}
|
||||
},
|
||||
addTopic: async ({ cookies, request, params }) => {
|
||||
if (!cookies.get('token')) {
|
||||
return { status: 401 };
|
||||
}
|
||||
addTopic: async (event) => {
|
||||
requireAdmin(event);
|
||||
const { request, params } = event;
|
||||
|
||||
const data = await request.formData();
|
||||
|
||||
const topicId = Number(data.get('topic-id'));
|
||||
|
||||
const existing = await db.query.tasksToTopics.findFirst({
|
||||
where: and(eq(tasksToTopics.taskId, Number(params.id)), eq(tasksToTopics.topicId, topicId))
|
||||
where: { taskId: Number(params.id), topicId }
|
||||
});
|
||||
|
||||
console.log(existing);
|
||||
|
||||
if (!existing) {
|
||||
await db
|
||||
.insert(tasksToTopics)
|
||||
@@ -70,17 +66,16 @@ export const actions = {
|
||||
.returning();
|
||||
}
|
||||
},
|
||||
removeTopic: async ({ cookies, request, params }) => {
|
||||
if (!cookies.get('token')) {
|
||||
return { status: 401 };
|
||||
}
|
||||
removeTopic: async (event) => {
|
||||
requireAdmin(event);
|
||||
const { request, params } = event;
|
||||
|
||||
const data = await request.formData();
|
||||
|
||||
const topicId = Number(data.get('topic-id'));
|
||||
|
||||
const existing = await db.query.tasksToTopics.findFirst({
|
||||
where: and(eq(tasksToTopics.taskId, Number(params.id)), eq(tasksToTopics.topicId, topicId))
|
||||
where: { taskId: Number(params.id), topicId }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
@@ -91,10 +86,9 @@ export const actions = {
|
||||
);
|
||||
}
|
||||
},
|
||||
updateProgress: async ({ cookies, request, params }) => {
|
||||
if (!cookies.get('token')) {
|
||||
return { status: 401 };
|
||||
}
|
||||
updateProgress: async (event) => {
|
||||
requireAdmin(event);
|
||||
const { request, params } = event;
|
||||
|
||||
const data = await request.formData();
|
||||
|
||||
@@ -112,10 +106,9 @@ export const actions = {
|
||||
})
|
||||
.where(eq(tasks.id, Number(params.id)));
|
||||
},
|
||||
updateNotes: async ({ cookies, request, params }) => {
|
||||
if (!cookies.get('token')) {
|
||||
return { status: 401 };
|
||||
}
|
||||
updateNotes: async (event) => {
|
||||
requireAdmin(event);
|
||||
const { request, params } = event;
|
||||
|
||||
const data = await request.formData();
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user