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,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}