trackers: group cars, trains, planes and wildlife under one spotting tracker with /trackers/spotting landing page and category switcher
All checks were successful
Deploy production / deploy (push) Successful in 1m9s
All checks were successful
Deploy production / deploy (push) Successful in 1m9s
This commit is contained in:
74
src/lib/components/SpottingSwitcher.svelte
Normal file
74
src/lib/components/SpottingSwitcher.svelte
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Small switcher shown above the cars/trains/planes/wildlife pages (via their +layout),
|
||||||
|
// to hop between the spotting pokedexes or back to /trackers/spotting.
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import Binoculars from 'lucide-svelte/icons/binoculars';
|
||||||
|
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
|
||||||
|
import { spottingTrackers } from '#lib/projects.js';
|
||||||
|
|
||||||
|
const active = $derived(
|
||||||
|
spottingTrackers.find(
|
||||||
|
(t) => page.url.pathname === t.href || page.url.pathname.startsWith(`${t.href}/`)
|
||||||
|
)?.name
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
aria-label="Spotting trackers"
|
||||||
|
class="relative mx-auto -mb-6 max-w-6xl px-4 pt-6 sm:-mb-8 sm:px-6 sm:pt-8"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-1.5">
|
||||||
|
<a
|
||||||
|
href={resolve('/trackers/spotting')}
|
||||||
|
class="pill flex items-center gap-1.5 px-2.5 py-1.5 text-sm"
|
||||||
|
><Binoculars size={14} aria-hidden="true" class="text-[var(--site-muted)]" />spotting</a
|
||||||
|
>
|
||||||
|
<span class="mx-1 h-4 w-px bg-border" aria-hidden="true"></span>
|
||||||
|
{#each spottingTrackers as t (t.name)}
|
||||||
|
<a
|
||||||
|
href={resolve(t.href as '/')}
|
||||||
|
aria-current={active === t.name ? 'page' : undefined}
|
||||||
|
class="pill flex items-center gap-1.5 py-1 pr-2.5 pl-1 text-sm"
|
||||||
|
><TrackerIcon name={t.name} class="pill-icon size-6" />{t.name}</a
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pill {
|
||||||
|
border: 1px solid color-mix(in oklab, var(--foreground) 9%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in oklab, var(--foreground) 2%, transparent);
|
||||||
|
color: var(--foreground);
|
||||||
|
outline: none;
|
||||||
|
transition:
|
||||||
|
transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||||
|
background-color 160ms ease,
|
||||||
|
border-color 160ms ease;
|
||||||
|
}
|
||||||
|
.pill:hover {
|
||||||
|
background: color-mix(in oklab, var(--foreground) 6%, transparent);
|
||||||
|
border-color: color-mix(in oklab, var(--foreground) 22%, transparent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.pill:focus-visible {
|
||||||
|
outline: 2px solid var(--foreground);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.pill[aria-current='page'] {
|
||||||
|
background: color-mix(in oklab, var(--foreground) 10%, transparent);
|
||||||
|
border-color: color-mix(in oklab, var(--foreground) 35%, transparent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.pill :global(.pill-icon) {
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.pill,
|
||||||
|
.pill:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -7,13 +7,13 @@ describe('trackers', () => {
|
|||||||
visibleTrackers(true)
|
visibleTrackers(true)
|
||||||
.map((t) => t.name)
|
.map((t) => t.name)
|
||||||
.slice(0, 4)
|
.slice(0, 4)
|
||||||
).toEqual(['tasks', 'money', 'books', 'cars']);
|
).toEqual(['tasks', 'money', 'books', 'spotting']);
|
||||||
});
|
});
|
||||||
it('hides private trackers when logged out', () => {
|
it('hides private trackers when logged out', () => {
|
||||||
const names = visibleTrackers(false).map((t) => t.name);
|
const names = visibleTrackers(false).map((t) => t.name);
|
||||||
expect(names).not.toContain('tasks');
|
expect(names).not.toContain('tasks');
|
||||||
expect(names).not.toContain('health');
|
expect(names).not.toContain('health');
|
||||||
expect(names.slice(0, 3)).toEqual(['money', 'books', 'cars']);
|
expect(names.slice(0, 3)).toEqual(['money', 'books', 'spotting']);
|
||||||
});
|
});
|
||||||
it('uses short single-word names', () => {
|
it('uses short single-word names', () => {
|
||||||
for (const t of visibleTrackers(true)) expect(t.name).toMatch(/^[a-z]+$/);
|
for (const t of visibleTrackers(true)) expect(t.name).toMatch(/^[a-z]+$/);
|
||||||
|
|||||||
@@ -59,9 +59,9 @@ const allTrackers = [
|
|||||||
private: false
|
private: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'cars',
|
name: 'spotting',
|
||||||
subtitle: 'a car pokedex: every brand and model, with Dutch and US data',
|
subtitle: "cars, trains, planes & wildlife I've spotted",
|
||||||
href: '/trackers/cars',
|
href: '/trackers/spotting',
|
||||||
external: false,
|
external: false,
|
||||||
private: false
|
private: false
|
||||||
},
|
},
|
||||||
@@ -114,7 +114,6 @@ const allTrackers = [
|
|||||||
external: false,
|
external: false,
|
||||||
private: false
|
private: false
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: 'skins',
|
name: 'skins',
|
||||||
subtitle: 'Counter-Strike skin market prices across time',
|
subtitle: 'Counter-Strike skin market prices across time',
|
||||||
@@ -135,29 +134,6 @@ const allTrackers = [
|
|||||||
href: '/lists',
|
href: '/lists',
|
||||||
external: false,
|
external: false,
|
||||||
private: false
|
private: false
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'planes',
|
|
||||||
subtitle: 'a plane pokedex: airliners, helicopters, bizjets, light aircraft and warbirds',
|
|
||||||
href: '/trackers/planes',
|
|
||||||
external: false,
|
|
||||||
private: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'trains',
|
|
||||||
subtitle:
|
|
||||||
'a train pokedex: steam, diesel and electric locos, high-speed trains, multiple units, metros and trams',
|
|
||||||
href: '/trackers/trains',
|
|
||||||
external: false,
|
|
||||||
private: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'wildlife',
|
|
||||||
subtitle:
|
|
||||||
'a wild animal pokedex: everything you can spot in the Netherlands, plus famous animals worldwide',
|
|
||||||
href: '/trackers/wildlife',
|
|
||||||
external: false,
|
|
||||||
private: false
|
|
||||||
}
|
}
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -168,6 +144,36 @@ const strip = ({ name, subtitle, href, external }: Tracker): Tracker => ({
|
|||||||
href,
|
href,
|
||||||
external
|
external
|
||||||
});
|
});
|
||||||
|
// The spotting trackers: pokedexes of things I can spot in the wild. Grouped under one
|
||||||
|
// "spotting" item in the header, home page and /trackers; each keeps its own URL.
|
||||||
|
export const spottingTrackers: readonly Tracker[] = [
|
||||||
|
{
|
||||||
|
name: 'cars',
|
||||||
|
subtitle: 'a car pokedex: every brand and model, with Dutch and US data',
|
||||||
|
href: '/trackers/cars',
|
||||||
|
external: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'trains',
|
||||||
|
subtitle:
|
||||||
|
'a train pokedex: steam, diesel and electric locos, high-speed trains, multiple units, metros and trams',
|
||||||
|
href: '/trackers/trains',
|
||||||
|
external: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'planes',
|
||||||
|
subtitle: 'a plane pokedex: airliners, helicopters, bizjets, light aircraft and warbirds',
|
||||||
|
href: '/trackers/planes',
|
||||||
|
external: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'wildlife',
|
||||||
|
subtitle:
|
||||||
|
'a wild animal pokedex: everything you can spot in the Netherlands, plus famous animals worldwide',
|
||||||
|
href: '/trackers/wildlife',
|
||||||
|
external: false
|
||||||
|
}
|
||||||
|
];
|
||||||
/** Public trackers (sitemap, logged-out navigation). */
|
/** Public trackers (sitemap, logged-out navigation). */
|
||||||
export const trackers: readonly Tracker[] = allTrackers.filter((t) => !t.private).map(strip);
|
export const trackers: readonly Tracker[] = allTrackers.filter((t) => !t.private).map(strip);
|
||||||
/** Owner-only trackers. */
|
/** Owner-only trackers. */
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ const publicPages: Record<string, { title: string; description: string }> = {
|
|||||||
title: 'Books',
|
title: 'Books',
|
||||||
description: 'Stan Runge’s library: books being read, finished and on the to-read pile.'
|
description: 'Stan Runge’s library: books being read, finished and on the to-read pile.'
|
||||||
},
|
},
|
||||||
|
'/trackers/spotting': {
|
||||||
|
title: 'Spotting',
|
||||||
|
description:
|
||||||
|
'Stan Runge’s spotting pokedexes: cars, trains, planes and wildlife, with specs, photos and how to recognise them.'
|
||||||
|
},
|
||||||
'/trackers/cars': {
|
'/trackers/cars': {
|
||||||
title: 'Car pokedex',
|
title: 'Car pokedex',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
|
|||||||
'/projects': 'Projects',
|
'/projects': 'Projects',
|
||||||
'/trackers': 'Trackers',
|
'/trackers': 'Trackers',
|
||||||
'/trackers/books': 'Books',
|
'/trackers/books': 'Books',
|
||||||
|
'/trackers/spotting': 'Spotting',
|
||||||
'/trackers/cars': 'Cars',
|
'/trackers/cars': 'Cars',
|
||||||
'/trackers/planes': 'Planes',
|
'/trackers/planes': 'Planes',
|
||||||
'/trackers/trains': 'Trains',
|
'/trackers/trains': 'Trains',
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { trackerIcons } from './tracker-icons';
|
import { trackerIcons } from './tracker-icons';
|
||||||
import { visibleTrackers } from './projects';
|
import { spottingTrackers, visibleTrackers } from './projects';
|
||||||
|
|
||||||
describe('tracker icons', () => {
|
describe('tracker icons', () => {
|
||||||
it('gives every tracker its own colour', () => {
|
it('gives every tracker its own colour', () => {
|
||||||
const names = visibleTrackers(true).map((t) => t.name);
|
const names = [...visibleTrackers(true), ...spottingTrackers].map((t) => t.name);
|
||||||
for (const name of names) expect(trackerIcons[name], name).toBeDefined();
|
for (const name of names) expect(trackerIcons[name], name).toBeDefined();
|
||||||
const colours = names.map((n) => trackerIcons[n].to);
|
const colours = names.map((n) => trackerIcons[n].to);
|
||||||
expect(new Set(colours).size).toBe(colours.length);
|
expect(new Set(colours).size).toBe(colours.length);
|
||||||
|
|||||||
@@ -144,6 +144,20 @@ export const trackerIcons: Record<string, TrackerIcon> = {
|
|||||||
'M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12'
|
'M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
spotting: {
|
||||||
|
from: '#fbbf24',
|
||||||
|
to: '#e11d48',
|
||||||
|
ink: WHITE,
|
||||||
|
// binoculars
|
||||||
|
paths: [
|
||||||
|
'M10 10h4',
|
||||||
|
'M19 7V4a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v3',
|
||||||
|
'M20 21a2 2 0 0 0 2-2v-3.851c0-1.39-2-2.962-2-4.829V8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v11a2 2 0 0 0 2 2z',
|
||||||
|
'M22 16H2',
|
||||||
|
'M4 21a2 2 0 0 1-2-2v-3.851c0-1.39 2-2.962 2-4.829V8a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v11a2 2 0 0 1-2 2z',
|
||||||
|
'M9 7V4a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1v3'
|
||||||
|
]
|
||||||
|
},
|
||||||
lists: {
|
lists: {
|
||||||
from: '#94a3b8',
|
from: '#94a3b8',
|
||||||
to: '#475569',
|
to: '#475569',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const staticPaths = [
|
|||||||
'/blog',
|
'/blog',
|
||||||
'/lists',
|
'/lists',
|
||||||
'/trackers/books',
|
'/trackers/books',
|
||||||
|
'/trackers/spotting',
|
||||||
'/trackers/cars',
|
'/trackers/cars',
|
||||||
'/trackers/cars/brands',
|
'/trackers/cars/brands',
|
||||||
'/trackers/planes',
|
'/trackers/planes',
|
||||||
|
|||||||
8
src/routes/trackers/cars/+layout.svelte
Normal file
8
src/routes/trackers/cars/+layout.svelte
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import SpottingSwitcher from '#lib/components/SpottingSwitcher.svelte';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SpottingSwitcher />
|
||||||
|
{@render children()}
|
||||||
8
src/routes/trackers/planes/+layout.svelte
Normal file
8
src/routes/trackers/planes/+layout.svelte
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import SpottingSwitcher from '#lib/components/SpottingSwitcher.svelte';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SpottingSwitcher />
|
||||||
|
{@render children()}
|
||||||
32
src/routes/trackers/spotting/+page.server.ts
Normal file
32
src/routes/trackers/spotting/+page.server.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { carOverview } from '#lib/server/cars.js';
|
||||||
|
import { planeOverview } from '#lib/server/planes.js';
|
||||||
|
import { trainOverview } from '#lib/server/trains.js';
|
||||||
|
import { wildlifeOverview } from '#lib/server/wildlife.js';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
// Headline counts per spotting tracker. Each overview is cached for 10 minutes; a failing
|
||||||
|
// one just hides its count instead of breaking the landing page.
|
||||||
|
const safe = <T>(p: Promise<T>) => p.catch(() => null);
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
const [cars, trains, planes, wildlife] = await Promise.all([
|
||||||
|
safe(carOverview()),
|
||||||
|
safe(trainOverview()),
|
||||||
|
safe(planeOverview()),
|
||||||
|
safe(wildlifeOverview())
|
||||||
|
]);
|
||||||
|
const counts: Record<string, { value: number; label: string } | null> = {
|
||||||
|
cars: cars ? { value: cars.stats.models, label: 'models' } : null,
|
||||||
|
trains: trains ? { value: trains.stats.types, label: 'types' } : null,
|
||||||
|
planes: planes ? { value: planes.stats.types, label: 'types' } : null,
|
||||||
|
wildlife: wildlife ? { value: wildlife.stats.species, label: 'species' } : null
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
counts,
|
||||||
|
seo: {
|
||||||
|
title: 'Spotting',
|
||||||
|
description:
|
||||||
|
'Stan Runge’s spotting pokedexes: cars, trains, planes and wildlife, with specs, photos and how to recognise them.'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
99
src/routes/trackers/spotting/+page.svelte
Normal file
99
src/routes/trackers/spotting/+page.svelte
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||||
|
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
||||||
|
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
|
||||||
|
import { spottingTrackers } from '#lib/projects.js';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||||
|
<a
|
||||||
|
href={resolve('/trackers')}
|
||||||
|
class="mb-6 inline-flex items-center gap-1 text-sm text-[var(--site-muted)] hover:text-foreground"
|
||||||
|
><ArrowLeft size={14} aria-hidden="true" /> trackers</a
|
||||||
|
>
|
||||||
|
<h1 class="mb-3 flex items-center gap-3 text-4xl font-semibold tracking-tight sm:text-5xl">
|
||||||
|
<TrackerIcon name="spotting" class="size-10 sm:size-12" />spotting
|
||||||
|
</h1>
|
||||||
|
<p class="mb-10 text-[var(--site-muted)]">
|
||||||
|
Cars, trains, planes and wildlife I've spotted. Pick a pokedex.
|
||||||
|
</p>
|
||||||
|
<ul class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{#each spottingTrackers as t (t.name)}
|
||||||
|
{@const count = data.counts[t.name]}
|
||||||
|
<li>
|
||||||
|
<a href={resolve(t.href as '/')} class="spot-tile flex h-full flex-col gap-4 p-5">
|
||||||
|
<span class="flex items-start justify-between">
|
||||||
|
<TrackerIcon name={t.name} class="spot-icon size-14" />
|
||||||
|
<ArrowRight size={18} aria-hidden="true" class="spot-arrow text-[var(--site-muted)]" />
|
||||||
|
</span>
|
||||||
|
<span class="min-w-0">
|
||||||
|
<span class="block text-xl font-semibold tracking-tight">{t.name}</span>
|
||||||
|
{#if count}
|
||||||
|
<span class="mt-0.5 block text-sm font-medium tabular-nums"
|
||||||
|
>{count.value.toLocaleString('en-GB')}
|
||||||
|
<span class="text-[var(--site-muted)]">{count.label}</span></span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<span class="mt-2 block text-sm text-muted-foreground">{t.subtitle}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.spot-tile {
|
||||||
|
border: 1px solid color-mix(in oklab, var(--foreground) 9%, transparent);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
background: color-mix(in oklab, var(--foreground) 2%, transparent);
|
||||||
|
outline: none;
|
||||||
|
transition:
|
||||||
|
transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||||
|
background-color 160ms ease,
|
||||||
|
border-color 160ms ease,
|
||||||
|
box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
.spot-tile:hover {
|
||||||
|
background: color-mix(in oklab, var(--foreground) 6%, transparent);
|
||||||
|
border-color: color-mix(in oklab, var(--foreground) 22%, transparent);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 12px 28px -16px rgb(0 0 0 / 0.55);
|
||||||
|
}
|
||||||
|
.spot-tile:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.spot-tile:focus-visible {
|
||||||
|
outline: 2px solid var(--foreground);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.spot-tile :global(.spot-icon),
|
||||||
|
.spot-tile :global(.spot-arrow) {
|
||||||
|
transition: transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
|
.spot-tile:hover :global(.spot-icon) {
|
||||||
|
transform: scale(1.06) rotate(-3deg);
|
||||||
|
}
|
||||||
|
.spot-tile:hover :global(.spot-arrow) {
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.spot-tile,
|
||||||
|
.spot-tile :global(.spot-icon),
|
||||||
|
.spot-tile :global(.spot-arrow) {
|
||||||
|
transition:
|
||||||
|
background-color 160ms ease,
|
||||||
|
border-color 160ms ease;
|
||||||
|
}
|
||||||
|
.spot-tile:hover,
|
||||||
|
.spot-tile:hover :global(.spot-icon),
|
||||||
|
.spot-tile:hover :global(.spot-arrow) {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
8
src/routes/trackers/trains/+layout.svelte
Normal file
8
src/routes/trackers/trains/+layout.svelte
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import SpottingSwitcher from '#lib/components/SpottingSwitcher.svelte';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SpottingSwitcher />
|
||||||
|
{@render children()}
|
||||||
8
src/routes/trackers/wildlife/+layout.svelte
Normal file
8
src/routes/trackers/wildlife/+layout.svelte
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import SpottingSwitcher from '#lib/components/SpottingSwitcher.svelte';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SpottingSwitcher />
|
||||||
|
{@render children()}
|
||||||
Reference in New Issue
Block a user