update homepage draft and other pages

This commit is contained in:
2026-08-29 11:38:16 +00:00
parent 6452331ee7
commit 97ff9852cd
38 changed files with 2185 additions and 111 deletions

View File

@@ -0,0 +1,53 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { animals, sightings } from '$lib/server/db/schema';
import { isAdmin } from '$lib/server/auth';
import { and, eq, isNull, sql } from 'drizzle-orm';
export const load: PageServerLoad = async (event) => {
const admin = isAdmin(event);
if (!admin) return { admin: false, queue: [] };
const pending = await db
.select({
animalId: animals.id,
species: animals.species,
breed: animals.breed,
animalName: animals.animalName,
description: animals.description,
aiBreedSuggestion: animals.aiBreedSuggestion,
aiBreedConfidence: animals.aiBreedConfidence,
submittedAt: animals.submittedAt,
acceptedAt: animals.acceptedAt,
deniedAt: animals.deniedAt,
sightingId: sightings.id,
sightingLat: sightings.lat,
sightingLng: sightings.lng,
seenAt: sightings.seenAt,
reporterName: sightings.reporterName,
sightingAcceptedAt: sightings.acceptedAt,
sightingDeniedAt: sightings.deniedAt,
photoUrl: sql<string>`(
SELECT url FROM photos
WHERE sighting_id = ${sightings.id}
ORDER BY sort_order ASC
LIMIT 1
)`
})
.from(animals)
.innerJoin(
sightings,
and(
eq(sightings.animalId, animals.id),
sql`${sightings.id} = (
SELECT id FROM sightings
WHERE animal_id = ${animals.id}
ORDER BY submitted_at ASC
LIMIT 1
)`
)
)
.orderBy(animals.submittedAt);
return { admin: true, queue: pending };
};

View File

@@ -0,0 +1,170 @@
<script lang="ts">
import type { PageData } from './$types';
import { invalidateAll } from '$app/navigation';
import { resolve } from '$app/paths';
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);
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();
}
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"
>
{loginLoading ? 'Logging in…' : 'Log in'}
</button>
</div>
</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"
>
<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">
{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 queue.length === 0}
<div class="rounded-2xl bg-white p-8 text-center text-gray-400 shadow-sm">
No submissions yet
</div>
{/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="flex gap-4 p-4">
<div class="h-24 w-24 shrink-0 overflow-hidden rounded-xl bg-gray-100">
{#if item.photoUrl}
<img src={item.photoUrl} alt="" class="h-full w-full object-cover" />
{:else}
<div class="flex h-full items-center justify-center text-2xl">🐾</div>
{/if}
</div>
<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">
{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'}"
>
{status}
</span>
</div>
{#if item.aiBreedSuggestion}
<p class="mt-1 text-xs text-gray-400">
🤖 AI: {item.aiBreedSuggestion} ({Math.round(
(item.aiBreedConfidence ?? 0) * 100
)}%)
</p>
{/if}
<p class="mt-1 text-xs text-gray-400">
{new Date(item.submittedAt).toLocaleDateString('nl-NL', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
{#if item.reporterName}· by {item.reporterName}{/if}
</p>
<p class="mt-0.5 text-xs text-gray-400">
📍 {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">
<button
onclick={() => moderate('animals', item.animalId, 'accept')}
disabled={actionLoading !== null || status === 'accepted'}
class="flex-1 rounded-lg py-1.5 text-sm font-medium {status === 'accepted'
? 'cursor-default bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-700 hover:bg-green-100 hover:text-green-700'}"
>
{actionLoading === `animals-${item.animalId}-accept` ? '…' : '✓ Accept'}
</button>
<button
onclick={() => moderate('animals', item.animalId, 'deny')}
disabled={actionLoading !== null || status === 'denied'}
class="flex-1 rounded-lg py-1.5 text-sm font-medium {status === 'denied'
? 'cursor-default bg-red-100 text-red-700'
: 'bg-gray-100 text-gray-700 hover:bg-red-100 hover:text-red-700'}"
>
{actionLoading === `animals-${item.animalId}-deny` ? '…' : '✕ Deny'}
</button>
</div>
</div>
{/each}
</div>
</div>
{/if}

View File

@@ -0,0 +1,25 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private';
import { z } from 'zod';
const loginSchema = z.object({ password: z.string().min(1) });
export const POST: RequestHandler = async ({ request, cookies }) => {
const body = await request.json().catch(() => null);
const parsed = loginSchema.safeParse(body);
if (!parsed.success || parsed.data.password !== env.ADMIN_SECRET) {
throw error(401, { message: 'Invalid password' });
}
cookies.set('admin_token', env.ADMIN_SECRET, {
path: '/',
httpOnly: true,
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7
});
return json({ ok: true });
};