This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { requireAdmin } from '$lib/server/authorization';
|
||||
import type { Actions } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { tasks } from '$lib/server/db/schema';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const actions = {
|
||||
default: async ({ cookies, request }) => {
|
||||
if (!cookies.get('token')) {
|
||||
return { status: 401 };
|
||||
}
|
||||
export const actions: Actions = {
|
||||
default: async (event) => {
|
||||
requireAdmin(event);
|
||||
const { request } = event;
|
||||
|
||||
const data = await request.formData();
|
||||
|
||||
const task = await db
|
||||
.insert(tasks)
|
||||
.values({ name: data.get('name') })
|
||||
.values({ name: String(data.get('name') ?? '').trim() })
|
||||
.returning();
|
||||
|
||||
redirect(303, '/progress/' + task[0].id);
|
||||
redirect(303, '/projects/progress/' + task[0].id);
|
||||
}
|
||||
};
|
||||
|
||||
75
src/routes/projects/spotify-beatmap-finder/+page.server.ts
Normal file
75
src/routes/projects/spotify-beatmap-finder/+page.server.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { Actions } from './$types';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { parseSpotifyInput } from '$lib/map-finder/input';
|
||||
import type { TrackMatchResult } from '$lib/map-finder/types';
|
||||
import { getSpotifyMetadata } from '$lib/server/spotify';
|
||||
import { getOsuMaps, MATCH_CONCURRENCY } from '$lib/server/osu';
|
||||
import { ApiError, publicError, logApiError, mapConcurrent } from '$lib/server/finder-http';
|
||||
import { acquireSearch } from '$lib/server/finder-limit';
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, fetch, getClientAddress }) => {
|
||||
const data = await request.formData();
|
||||
const value = data.get('query');
|
||||
const query = typeof value === 'string' ? value.trim().slice(0, 501) : '';
|
||||
let input;
|
||||
try {
|
||||
input = parseSpotifyInput(query);
|
||||
} catch (error) {
|
||||
return fail(400, { query, error: (error as Error).message });
|
||||
}
|
||||
if (input.kind === 'unsupported')
|
||||
return fail(400, {
|
||||
query,
|
||||
error: `Unsupported Spotify resource: ${input.resource}. Use a track, playlist, or album.`
|
||||
});
|
||||
let address: string;
|
||||
try {
|
||||
address = getClientAddress();
|
||||
} catch {
|
||||
address = 'shared-unknown';
|
||||
}
|
||||
const release = acquireSearch(
|
||||
address,
|
||||
input.kind === 'playlist' || input.kind === 'album' ? 6 : 1
|
||||
);
|
||||
if (!release)
|
||||
return fail(429, {
|
||||
query,
|
||||
error:
|
||||
'Site search limit reached. Wait up to 10 minutes, and finish any pending search before trying again.'
|
||||
});
|
||||
try {
|
||||
const metadata = await getSpotifyMetadata(fetch, input);
|
||||
const results = await mapConcurrent(
|
||||
metadata.tracks,
|
||||
MATCH_CONCURRENCY,
|
||||
async (track): Promise<TrackMatchResult> => {
|
||||
try {
|
||||
const matches = await getOsuMaps(fetch, track);
|
||||
return {
|
||||
track,
|
||||
matches,
|
||||
status: matches.some((m) => m.confidence !== 'low') ? 'matched' : 'no_match'
|
||||
};
|
||||
} catch (error) {
|
||||
logApiError(error);
|
||||
return { track, matches: [], status: 'error', error: publicError(error) };
|
||||
}
|
||||
}
|
||||
);
|
||||
return { query, results, collection: metadata.collection, truncated: metadata.truncated };
|
||||
} catch (error) {
|
||||
logApiError(error);
|
||||
return fail(
|
||||
error instanceof ApiError && error.status === 429
|
||||
? 429
|
||||
: error instanceof ApiError && [403, 404].includes(error.status)
|
||||
? 404
|
||||
: 502,
|
||||
{ query, error: publicError(error) }
|
||||
);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
};
|
||||
153
src/routes/projects/spotify-beatmap-finder/+page.svelte
Normal file
153
src/routes/projects/spotify-beatmap-finder/+page.svelte
Normal file
@@ -0,0 +1,153 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageProps } from './$types';
|
||||
import Artwork from '$lib/map-finder/Artwork.svelte';
|
||||
import MapCard from '$lib/map-finder/MapCard.svelte';
|
||||
let { form }: PageProps = $props();
|
||||
let pending = $state(false);
|
||||
let filter = $state('all');
|
||||
const filters = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'matched', label: 'Matched' },
|
||||
{ key: 'no_match', label: 'No match' },
|
||||
{ key: 'error', label: 'Errors' }
|
||||
];
|
||||
let results = $derived(form?.results ?? []);
|
||||
let visible = $derived(results.filter((result) => filter === 'all' || result.status === filter));
|
||||
let matched = $derived(results.filter((result) => result.status === 'matched').length);
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-5xl px-4 py-6 sm:py-12">
|
||||
<div class="rounded-3xl bg-white p-5 text-gray-800 shadow sm:p-10">
|
||||
<h1 class="mb-6 text-center text-3xl font-semibold lg:text-4xl">Spotify Map Finder</h1>
|
||||
<form
|
||||
method="POST"
|
||||
class="space-y-4"
|
||||
aria-busy={pending}
|
||||
use:enhance={() => {
|
||||
pending = true;
|
||||
return async ({ update }) => {
|
||||
try {
|
||||
filter = 'all';
|
||||
await update({ reset: false });
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<label for="query" class="sr-only">Song or Spotify URL</label>
|
||||
<input
|
||||
id="query"
|
||||
name="query"
|
||||
type="text"
|
||||
value={form?.query ?? ''}
|
||||
required
|
||||
maxlength="500"
|
||||
placeholder="Deftones Change or a Spotify URL"
|
||||
aria-describedby="input-help"
|
||||
class="block w-full rounded border border-gray-300 bg-gray-100 p-3 focus-visible:outline-2 focus-visible:outline-blue-600"
|
||||
/>
|
||||
<p id="input-help" class="text-sm text-gray-600">
|
||||
Search a song, or enter a Spotify track, playlist or album URL/URI. Up to 50 tracks.
|
||||
</p>
|
||||
<button
|
||||
disabled={pending}
|
||||
class="w-full rounded bg-blue-600 px-4 py-3 text-white hover:bg-blue-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-60"
|
||||
>{pending ? 'Searching…' : 'Search'}</button
|
||||
>
|
||||
</form>
|
||||
<div role="status">
|
||||
{#if pending}<p class="mt-4 text-sm text-gray-600">
|
||||
Searching for maps. Playlists and albums may take a few minutes.
|
||||
</p>{/if}
|
||||
</div>
|
||||
{#if form?.error}<p role="alert" class="mt-4 text-red-700">{form.error}</p>{/if}
|
||||
|
||||
{#if form?.results}
|
||||
<section class="mt-8" aria-label="Search results" aria-busy={pending}>
|
||||
{#if form.collection}
|
||||
<div class="mb-4 flex items-start gap-3">
|
||||
<Artwork src={form.collection.image} class="h-16 w-16 shrink-0" />
|
||||
<div class="min-w-0 break-words">
|
||||
<h2 class="text-xl font-semibold">{form.collection.name}</h2>
|
||||
{#if form.collection.owner}<p class="text-sm text-gray-600">
|
||||
{form.collection.owner}
|
||||
</p>{/if}
|
||||
<p class="text-sm">{results.length} tracks processed · {matched} matched</p>
|
||||
<a
|
||||
class="text-sm text-blue-700 underline"
|
||||
href={`https://open.spotify.com/${form.collection.kind}/${form.collection.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">Open in Spotify</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{#if form.truncated}<p class="mb-4 text-sm text-amber-800">
|
||||
Showing the first {results.length} usable tracks. The collection reached the processing
|
||||
limit.
|
||||
</p>{/if}
|
||||
<label class="mb-6 block text-sm"
|
||||
>Show
|
||||
<select bind:value={filter} class="ml-2 rounded border border-gray-300 bg-white p-2">
|
||||
{#each filters as option (option.key)}<option value={option.key}
|
||||
>{option.label}</option
|
||||
>{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
{#if !results.length}<p class="text-gray-600">
|
||||
No usable tracks found.
|
||||
</p>{:else if !visible.length}<p class="text-gray-600">No tracks in this filter.</p>{/if}
|
||||
<ul class="space-y-8">
|
||||
{#each visible as result (result.track.id)}
|
||||
<li>
|
||||
<div class="mb-4 flex items-start gap-3">
|
||||
<Artwork src={result.track.album.images[0]?.url} class="h-16 w-16 shrink-0" />
|
||||
<div class="min-w-0 break-words">
|
||||
<h3 class="font-semibold">
|
||||
{result.track.artists.map((artist) => artist.name).join(', ')} – {result.track
|
||||
.name}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">{result.track.album.name}</p>
|
||||
<a
|
||||
class="text-sm text-blue-700 underline"
|
||||
href={`https://open.spotify.com/track/${result.track.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">Open in Spotify</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{#if result.status === 'error'}<p class="text-red-700">{result.error}</p>{:else}
|
||||
{#if result.status === 'no_match'}<p class="mb-3 text-gray-600">
|
||||
No confident match found.
|
||||
</p>{/if}
|
||||
<ul class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each result.matches.filter((map) => map.confidence !== 'low') as map (map.id)}<li
|
||||
>
|
||||
<MapCard {map} />
|
||||
</li>{/each}
|
||||
</ul>
|
||||
{#if result.matches.some((map) => map.confidence === 'low')}
|
||||
<details class="mt-4">
|
||||
<summary class="cursor-pointer text-sm text-gray-600"
|
||||
>Low-confidence candidates ({result.matches.filter(
|
||||
(map) => map.confidence === 'low'
|
||||
).length})</summary
|
||||
>
|
||||
<ul class="mt-3 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each result.matches.filter((map) => map.confidence === 'low') as map (map.id)}<li
|
||||
>
|
||||
<MapCard {map} />
|
||||
</li>{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
61
src/routes/projects/spotify-beatmap-finder/action.test.ts
Normal file
61
src/routes/projects/spotify-beatmap-finder/action.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { expect, it, vi } from 'vitest';
|
||||
vi.mock('$lib/server/spotify', () => ({ getSpotifyMetadata: vi.fn() }));
|
||||
vi.mock('$lib/server/osu', () => ({ getOsuMaps: vi.fn(), MATCH_CONCURRENCY: 3 }));
|
||||
import { actions } from './+page.server';
|
||||
import { getSpotifyMetadata } from '$lib/server/spotify';
|
||||
import { getOsuMaps } from '$lib/server/osu';
|
||||
import type { RequestEvent } from './$types';
|
||||
const event = (query: string, address = 'validation-test') => {
|
||||
const data = new FormData();
|
||||
data.set('query', query);
|
||||
return {
|
||||
request: new Request('https://site.test', { method: 'POST', body: data }),
|
||||
fetch: vi.fn(),
|
||||
getClientAddress: () => address
|
||||
} as unknown as RequestEvent;
|
||||
};
|
||||
it.each(['', 'spotify:track:bad', 'https://open.spotify.com/artist/6PUzxtIHkv346yP89NzP9X'])(
|
||||
'validates before making API calls: %s',
|
||||
async (query) => {
|
||||
const result = await actions.default(event(query));
|
||||
expect(result).toMatchObject({ status: 400 });
|
||||
expect(getSpotifyMetadata).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
it('keeps successful tracks when another match fails', async () => {
|
||||
const track = {
|
||||
id: '6PUzxtIHkv346yP89NzP9X',
|
||||
name: 'Song',
|
||||
artists: [{ name: 'Artist' }],
|
||||
album: { name: '', images: [] }
|
||||
};
|
||||
vi.mocked(getSpotifyMetadata).mockResolvedValue({
|
||||
tracks: [track, { ...track, id: '2PUzxtIHkv346yP89NzP9X' }],
|
||||
truncated: false
|
||||
});
|
||||
vi.mocked(getOsuMaps)
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 1, title: 'Song', artist: 'Artist', score: 100, confidence: 'high', reasons: [] }
|
||||
])
|
||||
.mockRejectedValueOnce(new Error('sensitive upstream error'));
|
||||
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const result = await actions.default(event('Song', 'partial-test'));
|
||||
expect(result).toMatchObject({
|
||||
results: [
|
||||
{ status: 'matched' },
|
||||
{ status: 'error', error: 'Search could not be completed. Please try again.' }
|
||||
]
|
||||
});
|
||||
log.mockRestore();
|
||||
});
|
||||
|
||||
it('returns site-level 429 after the per-client budget is exhausted', async () => {
|
||||
vi.mocked(getSpotifyMetadata).mockResolvedValue({ tracks: [], truncated: false });
|
||||
for (let i = 0; i < 2; i++)
|
||||
await actions.default(event('spotify:album:6PUzxtIHkv346yP89NzP9X', 'limited-test'));
|
||||
const result = await actions.default(event('Song', 'limited-test'));
|
||||
expect(result).toMatchObject({
|
||||
status: 429,
|
||||
data: { query: 'Song', error: expect.stringContaining('Site search limit') }
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user