spotters: most-active-in sort with country/region selector, collection tracking (seen marks, progress, filters), refresh scripts score activity and report new NL entries
Some checks failed
Deploy production / deploy (push) Has been cancelled
Some checks failed
Deploy production / deploy (push) Has been cancelled
This commit is contained in:
@@ -1,18 +1,41 @@
|
||||
import { carOverview, PAGE_SIZE, parseFilters, searchCars } from '#lib/server/cars.js';
|
||||
import {
|
||||
areasWithData,
|
||||
groupActivity,
|
||||
progress,
|
||||
rankByActivity,
|
||||
seenByGroup
|
||||
} from '#lib/server/spotting.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
const filters = parseFilters(url.searchParams);
|
||||
const [{ cars, total }, overview] = await Promise.all([searchCars(filters), carOverview()]);
|
||||
const [{ cars, total }, overview, collected, seenBrands, areas, act] = await Promise.all([
|
||||
searchCars(filters),
|
||||
carOverview(),
|
||||
progress('cars'),
|
||||
seenByGroup('cars'),
|
||||
areasWithData('cars'),
|
||||
groupActivity('cars', filters.area)
|
||||
]);
|
||||
return {
|
||||
filters,
|
||||
cars,
|
||||
total,
|
||||
pageSize: PAGE_SIZE,
|
||||
collected,
|
||||
areas,
|
||||
brands: overview.brands.map(({ slug, name, models }) => ({ slug, name, models })),
|
||||
topBrands: overview.byNl
|
||||
topBrands: rankByActivity(overview.byNl, act)
|
||||
.slice(0, 16)
|
||||
.map(({ slug, name, models, nlCount, logo }) => ({ slug, name, models, nlCount, logo })),
|
||||
.map(({ id, slug, name, models, nlCount, logo }) => ({
|
||||
slug,
|
||||
name,
|
||||
models,
|
||||
nlCount,
|
||||
logo,
|
||||
seen: seenBrands.get(id) ?? 0
|
||||
})),
|
||||
stats: overview.stats,
|
||||
currentYear: new Date().getFullYear(),
|
||||
seo: {
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
LOGO_NOTE,
|
||||
SORTS
|
||||
} from '#lib/cars/shared.js';
|
||||
import AreaSelect from '#lib/spotting/AreaSelect.svelte';
|
||||
import { GOT } from '#lib/spotting/areas.js';
|
||||
import SpotItem from '#lib/spotting/SpotItem.svelte';
|
||||
import SpotProgress from '#lib/spotting/SpotProgress.svelte';
|
||||
import { Input } from '#lib/components/ui/input/index.js';
|
||||
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
||||
import Search from 'lucide-svelte/icons/search';
|
||||
@@ -26,7 +30,7 @@
|
||||
const f = $derived(data.filters);
|
||||
const pages = $derived(Math.max(1, Math.ceil(data.total / data.pageSize)));
|
||||
const filtering = $derived(
|
||||
!!(f.q || f.brand || f.fuel || f.body || f.drive || f.market || f.from || f.to)
|
||||
!!(f.q || f.brand || f.fuel || f.body || f.drive || f.market || f.from || f.to || f.got)
|
||||
);
|
||||
|
||||
let form: HTMLFormElement | undefined = $state();
|
||||
@@ -51,13 +55,14 @@
|
||||
return `/trackers/cars${qs ? `?${qs}` : ''}`;
|
||||
}
|
||||
|
||||
const quick = [
|
||||
const quick: { label: string; params: Record<string, string> }[] = [
|
||||
{ label: '📖 dex order', params: { sort: 'dex' } },
|
||||
{ label: '⚡ electric', params: { fuel: 'electric', sort: 'popular' } },
|
||||
{ label: '🍃 hybrids', params: { fuel: 'hybrid', sort: 'popular' } },
|
||||
{ label: '🏎️ coupés', params: { body: 'coupe', sort: 'popular' } },
|
||||
{ label: '💪 most powerful', params: { sort: 'power' } },
|
||||
{ label: '🐎 Mustang', params: { q: 'mustang' } }
|
||||
{ label: '🐎 Mustang', params: { q: 'mustang' } },
|
||||
{ label: '⬜ still to go', params: { got: 'no' } }
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -94,6 +99,15 @@
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="mb-8">
|
||||
<SpotProgress
|
||||
seen={data.collected.seen}
|
||||
total={data.collected.total}
|
||||
label="models collected"
|
||||
href={withParams({ got: 'no' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if !filtering && f.page === 1}
|
||||
<section class="mb-8" aria-labelledby="brands-heading">
|
||||
<div class="mb-3 flex items-baseline justify-between gap-3">
|
||||
@@ -184,6 +198,14 @@
|
||||
{#each Object.entries(SORTS) as [key, label] (key)}<option value={key}>{label}</option
|
||||
>{/each}
|
||||
</NativeSelect>
|
||||
{#if f.sort === 'active'}<AreaSelect
|
||||
value={f.area}
|
||||
available={data.areas}
|
||||
onchange={submit}
|
||||
/>{/if}
|
||||
<NativeSelect name="got" aria-label="Collection" value={f.got} onchange={submit}>
|
||||
{#each Object.entries(GOT) as [key, label] (key)}<option value={key}>{label}</option>{/each}
|
||||
</NativeSelect>
|
||||
<noscript><button type="submit" class="pill">search</button></noscript>
|
||||
</div>
|
||||
</form>
|
||||
@@ -206,7 +228,11 @@
|
||||
{#if data.cars.length}
|
||||
<ul class="grid">
|
||||
{#each data.cars as car, i (car.dex)}
|
||||
<li><CarCard {car} current={data.currentYear} eager={i < 4} /></li>
|
||||
<li>
|
||||
<SpotItem tracker="cars" id={car.id} seen={car.seen} label={car.name}>
|
||||
<CarCard {car} current={data.currentYear} eager={i < 4} />
|
||||
</SpotItem>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
|
||||
@@ -10,11 +10,18 @@
|
||||
thumbSrcset,
|
||||
years
|
||||
} from '#lib/cars/shared.js';
|
||||
import { page } from '$app/state';
|
||||
import { parseGot } from '#lib/spotting/areas.js';
|
||||
import { countSeen, filterGot } from '#lib/spotting/seen.svelte.js';
|
||||
import SpotItem from '#lib/spotting/SpotItem.svelte';
|
||||
import SpotProgress from '#lib/spotting/SpotProgress.svelte';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
const b = $derived(data.brand);
|
||||
const got = $derived(parseGot(page.url.searchParams.get('got')));
|
||||
const shown = $derived(filterGot('cars', data.models, got));
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||
@@ -57,9 +64,21 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mb-6">
|
||||
<SpotProgress
|
||||
seen={countSeen('cars', data.models)}
|
||||
total={data.models.length}
|
||||
label="{b.name} models collected"
|
||||
{got}
|
||||
/>
|
||||
</div>
|
||||
<ul class="grid">
|
||||
{#each data.models as car, i (car.dex)}
|
||||
<li><CarCard {car} current={data.currentYear} eager={i < 4} /></li>
|
||||
{#each shown as car, i (car.dex)}
|
||||
<li>
|
||||
<SpotItem tracker="cars" id={car.id} seen={car.seen} label={car.name}>
|
||||
<CarCard {car} current={data.currentYear} eager={i < 4} />
|
||||
</SpotItem>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="mt-10 text-xs text-[var(--site-muted)]">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { getModel } from '#lib/server/cars.js';
|
||||
import type { carModels, carVariants } from '#lib/server/db/schema/index.js';
|
||||
import { getSeen } from '#lib/server/spotting.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
type Model = typeof carModels.$inferSelect;
|
||||
@@ -32,7 +33,9 @@ export const load: PageServerLoad = async ({ params }) => {
|
||||
const found = await getModel(params.brand, params.model);
|
||||
if (!found) error(404, 'No such car in the pokedex');
|
||||
const { model, brand, variants, photos, prev, next } = found;
|
||||
const seenEntry = await getSeen('cars', model.id);
|
||||
return {
|
||||
spot: { id: model.id, entry: seenEntry },
|
||||
car: publicCar(model),
|
||||
brand: { slug: brand.slug, name: brand.name, country: brand.country, logo: brand.logo },
|
||||
variants: variants.map(publicVariant),
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
|
||||
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
||||
import Shuffle from 'lucide-svelte/icons/shuffle';
|
||||
import SeenPanel from '#lib/spotting/SeenPanel.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
@@ -174,6 +175,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
<SeenPanel tracker="cars" id={data.spot.id} entry={data.spot.entry} label="this one" />
|
||||
|
||||
{#if data.photos.length}
|
||||
<Gallery photos={data.photos} name="{data.brand.name} {car.name}" wikipedia={car.wikipedia} />
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { carOverview } from '#lib/server/cars.js';
|
||||
import { seenByGroup } from '#lib/server/spotting.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const { byNl, stats } = await carOverview();
|
||||
const [{ byNl, stats }, seen] = await Promise.all([carOverview(), seenByGroup('cars')]);
|
||||
return {
|
||||
brands: byNl.map(({ slug, name, models, nlCount, logo }) => ({
|
||||
brands: byNl.map(({ id, slug, name, models, nlCount, logo }) => ({
|
||||
seen: seen.get(id) ?? 0,
|
||||
slug,
|
||||
name,
|
||||
models,
|
||||
|
||||
14
src/routes/trackers/cars/seen/+server.ts
Normal file
14
src/routes/trackers/cars/seen/+server.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { isOwner } from '#lib/server/owner.js';
|
||||
import { parseSeenBody, setSeen } from '#lib/server/spotting.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/** Owner-only: mark / unmark an entry as seen (collected), optionally with a date and note. */
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
if (!isOwner(event)) error(404, 'Not Found');
|
||||
const body = parseSeenBody(await event.request.json().catch(() => null));
|
||||
if (!body) error(400, 'Bad request');
|
||||
const ok = await setSeen('cars', body.id, body.seen, body.seenOn, body.note);
|
||||
if (!ok) error(404, 'No such entry');
|
||||
return json({ ok: true, ...body });
|
||||
};
|
||||
@@ -1,18 +1,41 @@
|
||||
import { PAGE_SIZE, parseFilters, planeOverview, searchPlanes } from '#lib/server/planes.js';
|
||||
import {
|
||||
areasWithData,
|
||||
groupActivity,
|
||||
progress,
|
||||
rankByActivity,
|
||||
seenByGroup
|
||||
} from '#lib/server/spotting.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
const filters = parseFilters(url.searchParams);
|
||||
const [{ planes, total }, overview] = await Promise.all([searchPlanes(filters), planeOverview()]);
|
||||
const [{ planes, total }, overview, collected, seenMakers, areas, act] = await Promise.all([
|
||||
searchPlanes(filters),
|
||||
planeOverview(),
|
||||
progress('planes'),
|
||||
seenByGroup('planes'),
|
||||
areasWithData('planes'),
|
||||
groupActivity('planes', filters.area)
|
||||
]);
|
||||
return {
|
||||
filters,
|
||||
planes,
|
||||
total,
|
||||
pageSize: PAGE_SIZE,
|
||||
collected,
|
||||
areas,
|
||||
makers: overview.makers.map(({ slug, name, types }) => ({ slug, name, types })),
|
||||
topMakers: overview.byBuilt
|
||||
topMakers: rankByActivity(overview.byBuilt, act)
|
||||
.slice(0, 16)
|
||||
.map(({ slug, name, types, built, logo }) => ({ slug, name, types, built, logo })),
|
||||
.map(({ id, slug, name, types, built, logo }) => ({
|
||||
slug,
|
||||
name,
|
||||
types,
|
||||
built,
|
||||
logo,
|
||||
seen: seenMakers.get(id) ?? 0
|
||||
})),
|
||||
stats: overview.stats,
|
||||
seo: {
|
||||
title: 'Plane pokedex',
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
roleLabel,
|
||||
SORTS
|
||||
} from '#lib/planes/shared.js';
|
||||
import AreaSelect from '#lib/spotting/AreaSelect.svelte';
|
||||
import { GOT } from '#lib/spotting/areas.js';
|
||||
import SpotItem from '#lib/spotting/SpotItem.svelte';
|
||||
import SpotProgress from '#lib/spotting/SpotProgress.svelte';
|
||||
import { Input } from '#lib/components/ui/input/index.js';
|
||||
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
||||
import Search from 'lucide-svelte/icons/search';
|
||||
@@ -25,7 +29,9 @@
|
||||
|
||||
const f = $derived(data.filters);
|
||||
const pages = $derived(Math.max(1, Math.ceil(data.total / data.pageSize)));
|
||||
const filtering = $derived(!!(f.q || f.maker || f.role || f.engine || f.era || f.service));
|
||||
const filtering = $derived(
|
||||
!!(f.q || f.maker || f.role || f.engine || f.era || f.service || f.got)
|
||||
);
|
||||
|
||||
let form: HTMLFormElement | undefined = $state();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -49,7 +55,7 @@
|
||||
return `/trackers/planes${qs ? `?${qs}` : ''}`;
|
||||
}
|
||||
|
||||
const quick = [
|
||||
const quick: { label: string; params: Record<string, string> }[] = [
|
||||
{ label: '📖 dex order', params: { sort: 'dex' } },
|
||||
{ label: '🛫 airliners', params: { role: 'airliner', sort: 'built' } },
|
||||
{ label: '🚁 helicopters', params: { role: 'helicopter', sort: 'built' } },
|
||||
@@ -57,7 +63,8 @@
|
||||
{ label: '🎖️ WW2 warbirds', params: { role: 'military', era: 'ww2', sort: 'built' } },
|
||||
{ label: '🚀 fastest', params: { sort: 'fastest' } },
|
||||
{ label: '🐘 heaviest', params: { sort: 'biggest' } },
|
||||
{ label: '✈️ 737', params: { q: '737' } }
|
||||
{ label: '✈️ 737', params: { q: '737' } },
|
||||
{ label: '⬜ still to go', params: { got: 'no' } }
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -94,6 +101,15 @@
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="mb-8">
|
||||
<SpotProgress
|
||||
seen={data.collected.seen}
|
||||
total={data.collected.total}
|
||||
label="types collected"
|
||||
href={withParams({ got: 'no' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if !filtering && f.page === 1}
|
||||
<section class="mb-8" aria-labelledby="makers-heading">
|
||||
<div class="mb-3 flex items-baseline justify-between gap-3">
|
||||
@@ -159,6 +175,14 @@
|
||||
{#each Object.entries(SORTS) as [key, label] (key)}<option value={key}>{label}</option
|
||||
>{/each}
|
||||
</NativeSelect>
|
||||
{#if f.sort === 'active'}<AreaSelect
|
||||
value={f.area}
|
||||
available={data.areas}
|
||||
onchange={submit}
|
||||
/>{/if}
|
||||
<NativeSelect name="got" aria-label="Collection" value={f.got} onchange={submit}>
|
||||
{#each Object.entries(GOT) as [key, label] (key)}<option value={key}>{label}</option>{/each}
|
||||
</NativeSelect>
|
||||
<noscript><button type="submit" class="pill">search</button></noscript>
|
||||
</div>
|
||||
</form>
|
||||
@@ -181,7 +205,11 @@
|
||||
{#if data.planes.length}
|
||||
<ul class="grid">
|
||||
{#each data.planes as plane, i (plane.dex)}
|
||||
<li><PlaneCard {plane} eager={i < 4} /></li>
|
||||
<li>
|
||||
<SpotItem tracker="planes" id={plane.id} seen={plane.seen} label={plane.fullName}>
|
||||
<PlaneCard {plane} eager={i < 4} />
|
||||
</SpotItem>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
|
||||
@@ -11,10 +11,17 @@
|
||||
thumbSrcset
|
||||
} from '#lib/planes/shared.js';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import { page } from '$app/state';
|
||||
import { parseGot } from '#lib/spotting/areas.js';
|
||||
import { countSeen, filterGot } from '#lib/spotting/seen.svelte.js';
|
||||
import SpotItem from '#lib/spotting/SpotItem.svelte';
|
||||
import SpotProgress from '#lib/spotting/SpotProgress.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
const b = $derived(data.maker);
|
||||
const got = $derived(parseGot(page.url.searchParams.get('got')));
|
||||
const shown = $derived(filterGot('planes', data.types, got));
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||
@@ -61,9 +68,21 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mb-6">
|
||||
<SpotProgress
|
||||
seen={countSeen('planes', data.types)}
|
||||
total={data.types.length}
|
||||
label="{b.name} types collected"
|
||||
{got}
|
||||
/>
|
||||
</div>
|
||||
<ul class="grid">
|
||||
{#each data.types as plane, i (plane.dex)}
|
||||
<li><PlaneCard {plane} eager={i < 4} /></li>
|
||||
{#each shown as plane, i (plane.dex)}
|
||||
<li>
|
||||
<SpotItem tracker="planes" id={plane.id} seen={plane.seen} label={plane.fullName}>
|
||||
<PlaneCard {plane} eager={i < 4} />
|
||||
</SpotItem>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="mt-10 text-xs text-[var(--site-muted)]">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { getType } from '#lib/server/planes.js';
|
||||
import type { planeTypes } from '#lib/server/db/schema/index.js';
|
||||
import { getSeen } from '#lib/server/spotting.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
type Type = typeof planeTypes.$inferSelect;
|
||||
@@ -28,12 +29,14 @@ export const load: PageServerLoad = async ({ params }) => {
|
||||
const found = await getType(params.maker, params.type);
|
||||
if (!found) error(404, 'No such aircraft in the pokedex');
|
||||
const { type, maker, photos, parent, variants, prev, next } = found;
|
||||
const seenEntry = await getSeen('planes', type.id);
|
||||
const strip = <T extends { id: number }>(row: T): Omit<T, 'id'> => {
|
||||
const rest: Partial<T> = { ...row };
|
||||
delete rest.id;
|
||||
return rest as Omit<T, 'id'>;
|
||||
};
|
||||
return {
|
||||
spot: { id: type.id, entry: seenEntry },
|
||||
plane: publicType(type),
|
||||
maker: { slug: maker.slug, name: maker.name, country: maker.country, logo: maker.logo },
|
||||
photos,
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
|
||||
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
||||
import Shuffle from 'lucide-svelte/icons/shuffle';
|
||||
import SeenPanel from '#lib/spotting/SeenPanel.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
@@ -215,6 +216,7 @@
|
||||
>{:else if p.inService === false}<span class="chip">retired</span>{/if}
|
||||
</div>
|
||||
</header>
|
||||
<SeenPanel tracker="planes" id={data.spot.id} entry={data.spot.entry} label="this one" />
|
||||
|
||||
{#if data.photos.length}
|
||||
<Gallery photos={data.photos} name={p.fullName} wikipedia={p.wikipedia} />
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { planeOverview } from '#lib/server/planes.js';
|
||||
import { seenByGroup } from '#lib/server/spotting.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const { byBuilt, stats } = await planeOverview();
|
||||
const [{ byBuilt, stats }, seen] = await Promise.all([planeOverview(), seenByGroup('planes')]);
|
||||
return {
|
||||
makers: byBuilt.map(({ slug, name, types, built, logo }) => ({
|
||||
makers: byBuilt.map(({ id, slug, name, types, built, logo }) => ({
|
||||
seen: seen.get(id) ?? 0,
|
||||
slug,
|
||||
name,
|
||||
types,
|
||||
|
||||
14
src/routes/trackers/planes/seen/+server.ts
Normal file
14
src/routes/trackers/planes/seen/+server.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { isOwner } from '#lib/server/owner.js';
|
||||
import { parseSeenBody, setSeen } from '#lib/server/spotting.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/** Owner-only: mark / unmark an entry as seen (collected), optionally with a date and note. */
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
if (!isOwner(event)) error(404, 'Not Found');
|
||||
const body = parseSeenBody(await event.request.json().catch(() => null));
|
||||
if (!body) error(400, 'Bad request');
|
||||
const ok = await setSeen('planes', body.id, body.seen, body.seenOn, body.note);
|
||||
if (!ok) error(404, 'No such entry');
|
||||
return json({ ok: true, ...body });
|
||||
};
|
||||
@@ -1,30 +1,76 @@
|
||||
import { PAGE_SIZE, parseFilters, searchTrains, trainOverview } from '#lib/server/trains.js';
|
||||
import {
|
||||
areasWithData,
|
||||
groupActivity,
|
||||
progress,
|
||||
rankByActivity,
|
||||
seenByGroup
|
||||
} from '#lib/server/spotting.js';
|
||||
import { regionOf } from '#lib/spotting/areas.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
const names = new Intl.DisplayNames(['en'], { type: 'region' });
|
||||
/** English country names (as stored on operators) inside an area. */
|
||||
const areaCountries = (area: string) =>
|
||||
new Set(
|
||||
(regionOf(area)?.countries ?? [area]).map((c) => {
|
||||
try {
|
||||
return names.of(c.toUpperCase()) ?? c;
|
||||
} catch {
|
||||
return c;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
const filters = parseFilters(url.searchParams);
|
||||
const [{ trains, total }, overview] = await Promise.all([searchTrains(filters), trainOverview()]);
|
||||
const [{ trains, total }, overview, collected, seenMakers, areas, act] = await Promise.all([
|
||||
searchTrains(filters),
|
||||
trainOverview(),
|
||||
progress('trains'),
|
||||
seenByGroup('trains'),
|
||||
areasWithData('trains'),
|
||||
groupActivity('trains', filters.area)
|
||||
]);
|
||||
const home = areaCountries(filters.area);
|
||||
return {
|
||||
filters,
|
||||
trains,
|
||||
total,
|
||||
pageSize: PAGE_SIZE,
|
||||
collected,
|
||||
areas,
|
||||
makers: overview.makers
|
||||
.filter((m) => m.types >= 2 || m.slug === filters.maker)
|
||||
.map(({ slug, name, types }) => ({ slug, name, types })),
|
||||
countries: overview.countries
|
||||
.filter((c) => c.types >= 3 || c.name === filters.country)
|
||||
.map(({ name, types }) => ({ name, types })),
|
||||
topMakers: overview.byBuilt
|
||||
topMakers: rankByActivity(overview.byBuilt, act)
|
||||
.slice(0, 16)
|
||||
.map(({ slug, name, types, built, logo }) => ({ slug, name, types, built, logo })),
|
||||
topOperators: overview.operators.slice(0, 16).map(({ slug, name, types, logo }) => ({
|
||||
slug,
|
||||
name,
|
||||
types,
|
||||
logo,
|
||||
href: `/trackers/trains/operators/${slug}`
|
||||
})),
|
||||
.map(({ id, slug, name, types, built, logo }) => ({
|
||||
slug,
|
||||
name,
|
||||
types,
|
||||
built,
|
||||
logo,
|
||||
seen: seenMakers.get(id) ?? 0
|
||||
})),
|
||||
// Operators from the chosen area first (the world region keeps the plain fleet-size order).
|
||||
topOperators: (filters.area === 'world'
|
||||
? overview.operators
|
||||
: [...overview.operators].sort(
|
||||
(a, b) => Number(home.has(b.country ?? '')) - Number(home.has(a.country ?? ''))
|
||||
)
|
||||
)
|
||||
.slice(0, 16)
|
||||
.map(({ slug, name, types, logo }) => ({
|
||||
slug,
|
||||
name,
|
||||
types,
|
||||
logo,
|
||||
href: `/trackers/trains/operators/${slug}`
|
||||
})),
|
||||
stats: overview.stats,
|
||||
seo: {
|
||||
title: 'Train pokedex',
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
TRACTIONS,
|
||||
tractionLabel
|
||||
} from '#lib/trains/shared.js';
|
||||
import AreaSelect from '#lib/spotting/AreaSelect.svelte';
|
||||
import { GOT } from '#lib/spotting/areas.js';
|
||||
import SpotItem from '#lib/spotting/SpotItem.svelte';
|
||||
import SpotProgress from '#lib/spotting/SpotProgress.svelte';
|
||||
import { Input } from '#lib/components/ui/input/index.js';
|
||||
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
||||
import Search from 'lucide-svelte/icons/search';
|
||||
@@ -27,7 +31,7 @@
|
||||
const f = $derived(data.filters);
|
||||
const pages = $derived(Math.max(1, Math.ceil(data.total / data.pageSize)));
|
||||
const filtering = $derived(
|
||||
!!(f.q || f.maker || f.category || f.traction || f.country || f.era || f.service)
|
||||
!!(f.q || f.maker || f.category || f.traction || f.country || f.era || f.service || f.got)
|
||||
);
|
||||
|
||||
let form: HTMLFormElement | undefined = $state();
|
||||
@@ -61,7 +65,8 @@
|
||||
{ label: '🚇 metros', params: { category: 'metro', sort: 'built' } },
|
||||
{ label: '🚋 trams', params: { category: 'tram', sort: 'built' } },
|
||||
{ label: '💪 most powerful', params: { sort: 'power' } },
|
||||
{ label: '🟡 VIRM', params: { q: 'virm' } }
|
||||
{ label: '🟡 VIRM', params: { q: 'virm' } },
|
||||
{ label: '⬜ still to go', params: { got: 'no' } }
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -107,6 +112,15 @@
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="mb-8">
|
||||
<SpotProgress
|
||||
seen={data.collected.seen}
|
||||
total={data.collected.total}
|
||||
label="types collected"
|
||||
href={withParams({ got: 'no' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if !filtering && f.page === 1}
|
||||
<section class="mb-8" aria-labelledby="makers-heading">
|
||||
<div class="mb-3 flex items-baseline justify-between gap-3">
|
||||
@@ -194,6 +208,14 @@
|
||||
{#each Object.entries(SORTS) as [key, label] (key)}<option value={key}>{label}</option
|
||||
>{/each}
|
||||
</NativeSelect>
|
||||
{#if f.sort === 'active'}<AreaSelect
|
||||
value={f.area}
|
||||
available={data.areas}
|
||||
onchange={submit}
|
||||
/>{/if}
|
||||
<NativeSelect name="got" aria-label="Collection" value={f.got} onchange={submit}>
|
||||
{#each Object.entries(GOT) as [key, label] (key)}<option value={key}>{label}</option>{/each}
|
||||
</NativeSelect>
|
||||
<noscript><button type="submit" class="pill">search</button></noscript>
|
||||
</div>
|
||||
</form>
|
||||
@@ -216,7 +238,11 @@
|
||||
{#if data.trains.length}
|
||||
<ul class="grid">
|
||||
{#each data.trains as train, i (train.dex)}
|
||||
<li><TrainCard {train} eager={i < 4} /></li>
|
||||
<li>
|
||||
<SpotItem tracker="trains" id={train.id} seen={train.seen} label={train.fullName}>
|
||||
<TrainCard {train} eager={i < 4} />
|
||||
</SpotItem>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
|
||||
@@ -11,10 +11,17 @@
|
||||
thumbSrcset
|
||||
} from '#lib/trains/shared.js';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import { page } from '$app/state';
|
||||
import { parseGot } from '#lib/spotting/areas.js';
|
||||
import { countSeen, filterGot } from '#lib/spotting/seen.svelte.js';
|
||||
import SpotItem from '#lib/spotting/SpotItem.svelte';
|
||||
import SpotProgress from '#lib/spotting/SpotProgress.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
const b = $derived(data.maker);
|
||||
const got = $derived(parseGot(page.url.searchParams.get('got')));
|
||||
const shown = $derived(filterGot('trains', data.types, got));
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||
@@ -61,9 +68,21 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mb-6">
|
||||
<SpotProgress
|
||||
seen={countSeen('trains', data.types)}
|
||||
total={data.types.length}
|
||||
label="{b.name} types collected"
|
||||
{got}
|
||||
/>
|
||||
</div>
|
||||
<ul class="grid">
|
||||
{#each data.types as train, i (train.dex)}
|
||||
<li><TrainCard {train} eager={i < 4} /></li>
|
||||
{#each shown as train, i (train.dex)}
|
||||
<li>
|
||||
<SpotItem tracker="trains" id={train.id} seen={train.seen} label={train.fullName}>
|
||||
<TrainCard {train} eager={i < 4} />
|
||||
</SpotItem>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="mt-10 text-xs text-[var(--site-muted)]">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { getType } from '#lib/server/trains.js';
|
||||
import type { trainTypes } from '#lib/server/db/schema/index.js';
|
||||
import { getSeen } from '#lib/server/spotting.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
type Type = typeof trainTypes.$inferSelect;
|
||||
@@ -28,12 +29,14 @@ export const load: PageServerLoad = async ({ params }) => {
|
||||
const found = await getType(params.maker, params.type);
|
||||
if (!found) error(404, 'No such train in the pokedex');
|
||||
const { type, maker, photos, parent, variants, operators, prev, next } = found;
|
||||
const seenEntry = await getSeen('trains', type.id);
|
||||
const strip = <T extends { id: number }>(row: T): Omit<T, 'id'> => {
|
||||
const rest: Partial<T> = { ...row };
|
||||
delete rest.id;
|
||||
return rest as Omit<T, 'id'>;
|
||||
};
|
||||
return {
|
||||
spot: { id: type.id, entry: seenEntry },
|
||||
train: publicType(type),
|
||||
maker: { slug: maker.slug, name: maker.name, country: maker.country, logo: maker.logo },
|
||||
photos,
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
|
||||
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
||||
import Shuffle from 'lucide-svelte/icons/shuffle';
|
||||
import SeenPanel from '#lib/spotting/SeenPanel.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
@@ -222,6 +223,7 @@
|
||||
>{:else if p.inService === false}<span class="chip">retired</span>{/if}
|
||||
</div>
|
||||
</header>
|
||||
<SeenPanel tracker="trains" id={data.spot.id} entry={data.spot.entry} label="this one" />
|
||||
|
||||
{#if data.photos.length}
|
||||
<Gallery photos={data.photos} name={p.fullName} wikipedia={p.wikipedia} />
|
||||
|
||||
@@ -9,13 +9,19 @@
|
||||
type TrainCard as Card
|
||||
} from '#lib/trains/shared.js';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import { page } from '$app/state';
|
||||
import { parseGot } from '#lib/spotting/areas.js';
|
||||
import { countSeen, filterGot } from '#lib/spotting/seen.svelte.js';
|
||||
import SpotItem from '#lib/spotting/SpotItem.svelte';
|
||||
import SpotProgress from '#lib/spotting/SpotProgress.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
const got = $derived(parseGot(page.url.searchParams.get('got')));
|
||||
const groups = $derived(
|
||||
CATEGORIES.map((cat) => ({
|
||||
cat,
|
||||
types: data.types.filter((t: Card) => t.category === cat)
|
||||
types: filterGot('trains', data.types, got).filter((t: Card) => t.category === cat)
|
||||
})).filter((g) => g.types.length)
|
||||
);
|
||||
</script>
|
||||
@@ -42,6 +48,14 @@
|
||||
{/each}
|
||||
</nav>
|
||||
</header>
|
||||
<div class="mb-8">
|
||||
<SpotProgress
|
||||
seen={countSeen('trains', data.types)}
|
||||
total={data.types.length}
|
||||
label="types collected"
|
||||
{got}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#each groups as g, gi (g.cat)}
|
||||
<section class="mb-10" id={g.cat.replace(/ /g, '-')}>
|
||||
@@ -52,7 +66,11 @@
|
||||
</h2>
|
||||
<ul class="grid">
|
||||
{#each g.types as train, i (train.dex)}
|
||||
<li><TrainCard {train} eager={gi === 0 && i < 4} /></li>
|
||||
<li>
|
||||
<SpotItem tracker="trains" id={train.id} seen={train.seen} label={train.fullName}>
|
||||
<TrainCard {train} eager={gi === 0 && i < 4} />
|
||||
</SpotItem>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { trainOverview } from '#lib/server/trains.js';
|
||||
import { seenByGroup } from '#lib/server/spotting.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const { byBuilt, stats } = await trainOverview();
|
||||
const [{ byBuilt, stats }, seen] = await Promise.all([trainOverview(), seenByGroup('trains')]);
|
||||
return {
|
||||
makers: byBuilt.map(({ slug, name, types, built, logo }) => ({
|
||||
makers: byBuilt.map(({ id, slug, name, types, built, logo }) => ({
|
||||
seen: seen.get(id) ?? 0,
|
||||
slug,
|
||||
name,
|
||||
types,
|
||||
|
||||
@@ -10,10 +10,17 @@
|
||||
thumbSrcset
|
||||
} from '#lib/trains/shared.js';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import { page } from '$app/state';
|
||||
import { parseGot } from '#lib/spotting/areas.js';
|
||||
import { countSeen, filterGot } from '#lib/spotting/seen.svelte.js';
|
||||
import SpotItem from '#lib/spotting/SpotItem.svelte';
|
||||
import SpotProgress from '#lib/spotting/SpotProgress.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
const b = $derived(data.operator);
|
||||
const got = $derived(parseGot(page.url.searchParams.get('got')));
|
||||
const shown = $derived(filterGot('trains', data.types, got));
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||
@@ -58,9 +65,21 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mb-6">
|
||||
<SpotProgress
|
||||
seen={countSeen('trains', data.types)}
|
||||
total={data.types.length}
|
||||
label="{b.name} types collected"
|
||||
{got}
|
||||
/>
|
||||
</div>
|
||||
<ul class="grid">
|
||||
{#each data.types as train, i (train.dex)}
|
||||
<li><TrainCard {train} eager={i < 4} /></li>
|
||||
{#each shown as train, i (train.dex)}
|
||||
<li>
|
||||
<SpotItem tracker="trains" id={train.id} seen={train.seen} label={train.fullName}>
|
||||
<TrainCard {train} eager={i < 4} />
|
||||
</SpotItem>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="mt-10 text-xs text-[var(--site-muted)]">
|
||||
|
||||
14
src/routes/trackers/trains/seen/+server.ts
Normal file
14
src/routes/trackers/trains/seen/+server.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { isOwner } from '#lib/server/owner.js';
|
||||
import { parseSeenBody, setSeen } from '#lib/server/spotting.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/** Owner-only: mark / unmark an entry as seen (collected), optionally with a date and note. */
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
if (!isOwner(event)) error(404, 'Not Found');
|
||||
const body = parseSeenBody(await event.request.json().catch(() => null));
|
||||
if (!body) error(400, 'Bad request');
|
||||
const ok = await setSeen('trains', body.id, body.seen, body.seenOn, body.note);
|
||||
if (!ok) error(404, 'No such entry');
|
||||
return json({ ok: true, ...body });
|
||||
};
|
||||
@@ -1,18 +1,24 @@
|
||||
import { PAGE_SIZE, parseFilters, searchSpecies, wildlifeOverview } from '#lib/server/wildlife.js';
|
||||
import { areasWithData, progress, seenByGroup } from '#lib/server/spotting.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
const filters = parseFilters(url.searchParams);
|
||||
const [{ species, total }, overview] = await Promise.all([
|
||||
const [{ species, total }, overview, collected, seenGroups, areas] = await Promise.all([
|
||||
searchSpecies(filters),
|
||||
wildlifeOverview()
|
||||
wildlifeOverview(),
|
||||
progress('wildlife'),
|
||||
seenByGroup('wildlife'),
|
||||
areasWithData('wildlife')
|
||||
]);
|
||||
return {
|
||||
filters,
|
||||
species,
|
||||
total,
|
||||
pageSize: PAGE_SIZE,
|
||||
groups: overview.groups,
|
||||
collected,
|
||||
areas,
|
||||
groups: overview.groups.map((g) => ({ ...g, seen: seenGroups.get(g.id) ?? 0 })),
|
||||
stats: overview.stats,
|
||||
seo: {
|
||||
title: 'Wildlife pokedex',
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
const f = $derived(data.filters);
|
||||
const filtering = $derived(!!(f.q || f.group || f.status || f.where || f.sort !== 'nl'));
|
||||
const filtering = $derived(
|
||||
!!(f.q || f.group || f.status || f.where || f.got || f.sort !== 'active')
|
||||
);
|
||||
|
||||
const quick: { label: string; params: Record<string, string> }[] = [
|
||||
{ label: '📖 dex order', params: { sort: 'dex' } },
|
||||
@@ -79,6 +81,8 @@
|
||||
pageSize={data.pageSize}
|
||||
groups={data.groups}
|
||||
{quick}
|
||||
areas={data.areas}
|
||||
collected={data.collected}
|
||||
/>
|
||||
|
||||
<footer class="mt-12 text-xs leading-relaxed text-[var(--site-muted)]">
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { getGroup, PAGE_SIZE, parseFilters, searchSpecies } from '#lib/server/wildlife.js';
|
||||
import { areasWithData, groupProgress } from '#lib/server/spotting.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, url }) => {
|
||||
const group = await getGroup(params.group);
|
||||
if (!group) error(404, 'No such group in the wildlife pokedex');
|
||||
const filters = parseFilters(url.searchParams, group.slug);
|
||||
const { species, total } = await searchSpecies(filters);
|
||||
const [{ species, total }, collected, areas] = await Promise.all([
|
||||
searchSpecies(filters),
|
||||
groupProgress('wildlife', group.id),
|
||||
areasWithData('wildlife')
|
||||
]);
|
||||
return {
|
||||
group: {
|
||||
slug: group.slug,
|
||||
@@ -18,6 +23,8 @@ export const load: PageServerLoad = async ({ params, url }) => {
|
||||
photo: group.photo
|
||||
},
|
||||
filters,
|
||||
collected,
|
||||
areas,
|
||||
species,
|
||||
total,
|
||||
pageSize: PAGE_SIZE,
|
||||
|
||||
@@ -41,6 +41,9 @@
|
||||
total={data.total}
|
||||
pageSize={data.pageSize}
|
||||
{quick}
|
||||
areas={data.areas}
|
||||
collected={data.collected}
|
||||
collectedLabel="{g.name.toLowerCase()} collected"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { getSpecies } from '#lib/server/wildlife.js';
|
||||
import { displayName, photoAt } from '#lib/wildlife/shared.js';
|
||||
import { getSeen } from '#lib/server/spotting.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const found = await getSpecies(params.group, params.species);
|
||||
if (!found) error(404, 'No such animal in the wildlife pokedex');
|
||||
const { species: s, group, photos, relatives, prev, next } = found;
|
||||
const seenEntry = await getSeen('wildlife', s.id);
|
||||
const name = displayName(s);
|
||||
const alt = [s.nameNl && s.nameNl !== name ? s.nameNl : null, s.scientificName]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
return {
|
||||
spot: { id: s.id, entry: seenEntry },
|
||||
species: {
|
||||
dex: s.dex,
|
||||
slug: s.slug,
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
|
||||
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
||||
import Shuffle from 'lucide-svelte/icons/shuffle';
|
||||
import SeenPanel from '#lib/spotting/SeenPanel.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
@@ -165,6 +166,7 @@
|
||||
{#if s.extinct}<span class="chip">🪦 extinct</span>{/if}
|
||||
</div>
|
||||
</header>
|
||||
<SeenPanel tracker="wildlife" id={data.spot.id} entry={data.spot.entry} label="this one" />
|
||||
|
||||
{#if data.photos.length}
|
||||
<Gallery photos={data.photos} {name} />
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { wildlifeOverview } from '#lib/server/wildlife.js';
|
||||
import { seenByGroup } from '#lib/server/spotting.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const { groups, stats } = await wildlifeOverview();
|
||||
const [{ groups, stats }, seen] = await Promise.all([
|
||||
wildlifeOverview(),
|
||||
seenByGroup('wildlife')
|
||||
]);
|
||||
return {
|
||||
groups,
|
||||
groups: groups.map((g) => ({ ...g, seen: seen.get(g.id) ?? 0 })),
|
||||
stats,
|
||||
seo: {
|
||||
title: 'Wildlife groups',
|
||||
|
||||
14
src/routes/trackers/wildlife/seen/+server.ts
Normal file
14
src/routes/trackers/wildlife/seen/+server.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { isOwner } from '#lib/server/owner.js';
|
||||
import { parseSeenBody, setSeen } from '#lib/server/spotting.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/** Owner-only: mark / unmark an entry as seen (collected), optionally with a date and note. */
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
if (!isOwner(event)) error(404, 'Not Found');
|
||||
const body = parseSeenBody(await event.request.json().catch(() => null));
|
||||
if (!body) error(400, 'Bad request');
|
||||
const ok = await setSeen('wildlife', body.id, body.seen, body.seenOn, body.note);
|
||||
if (!ok) error(404, 'No such entry');
|
||||
return json({ ok: true, ...body });
|
||||
};
|
||||
Reference in New Issue
Block a user