trackers: one-click spotting links: header flyout (hover/focus, delayed close, side-aware), chips on the spotting tile, mobile sub-links
All checks were successful
Deploy production / deploy (push) Successful in 1m8s
All checks were successful
Deploy production / deploy (push) Successful in 1m8s
This commit is contained in:
@@ -9,7 +9,13 @@
|
|||||||
import ProjectIcon from '#lib/components/ProjectIcon.svelte';
|
import ProjectIcon from '#lib/components/ProjectIcon.svelte';
|
||||||
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
|
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
|
||||||
|
|
||||||
type Item = { name: string; subtitle: string; href: string; external: boolean };
|
type Item = {
|
||||||
|
name: string;
|
||||||
|
subtitle: string;
|
||||||
|
href: string;
|
||||||
|
external: boolean;
|
||||||
|
children?: readonly Item[];
|
||||||
|
};
|
||||||
let {
|
let {
|
||||||
items,
|
items,
|
||||||
subtitleClass = 'mt-1 block text-sm text-muted-foreground',
|
subtitleClass = 'mt-1 block text-sm text-muted-foreground',
|
||||||
@@ -60,9 +66,32 @@
|
|||||||
<ul class="grid gap-3 sm:grid-cols-2" class:wide>
|
<ul class="grid gap-3 sm:grid-cols-2" class:wide>
|
||||||
{#each items as project (project.name)}
|
{#each items as project (project.name)}
|
||||||
<li>
|
<li>
|
||||||
<Card.Root class="catalog-card h-full py-0 transition-colors hover:bg-muted/50"
|
<Card.Root class="catalog-card relative h-full py-0 transition-colors hover:bg-muted/50"
|
||||||
><Card.Content class="h-full p-0">
|
><Card.Content class="h-full p-0">
|
||||||
{#if project.external}
|
{#if project.children}
|
||||||
|
<!-- Group tile: a stretched main link plus direct links to each sub-tracker layered
|
||||||
|
above it (siblings, so no nested <a>). -->
|
||||||
|
<div class="flex h-full min-h-28 flex-col gap-3 p-5">
|
||||||
|
<a
|
||||||
|
href={resolve(project.href as '/')}
|
||||||
|
class="stretched flex items-start justify-between gap-4 outline-none"
|
||||||
|
>
|
||||||
|
{@render body(project)}
|
||||||
|
</a>
|
||||||
|
<ul class="relative z-10 mt-auto flex flex-wrap gap-1.5 pl-13">
|
||||||
|
{#each project.children as child (child.name)}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={resolve(child.href as '/')}
|
||||||
|
class="chip flex items-center gap-1.5 py-1 pr-2.5 pl-1 text-xs font-medium"
|
||||||
|
title={child.subtitle}
|
||||||
|
><TrackerIcon name={child.name} class="chip-icon size-5" />{child.name}</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{:else if project.external}
|
||||||
<a
|
<a
|
||||||
href={project.href}
|
href={project.href}
|
||||||
rel="external"
|
rel="external"
|
||||||
@@ -121,17 +150,57 @@
|
|||||||
ul :global(.catalog-card:hover .catalog-arrow) {
|
ul :global(.catalog-card:hover .catalog-arrow) {
|
||||||
transform: translateX(2px);
|
transform: translateX(2px);
|
||||||
}
|
}
|
||||||
|
.stretched::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
.stretched:focus-visible::after {
|
||||||
|
outline: 2px solid var(--foreground);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in oklab, var(--background) 70%, transparent);
|
||||||
|
outline: none;
|
||||||
|
transition:
|
||||||
|
transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||||
|
background-color 160ms ease,
|
||||||
|
border-color 160ms ease;
|
||||||
|
}
|
||||||
|
.chip:hover {
|
||||||
|
background: color-mix(in oklab, var(--foreground) 10%, transparent);
|
||||||
|
border-color: color-mix(in oklab, var(--foreground) 35%, transparent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.chip:focus-visible {
|
||||||
|
outline: 2px solid var(--foreground);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.chip :global(.chip-icon) {
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
|
.chip:hover :global(.chip-icon) {
|
||||||
|
transform: scale(1.12);
|
||||||
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
ul :global(.catalog-card),
|
ul :global(.catalog-card),
|
||||||
ul :global(.catalog-icon),
|
ul :global(.catalog-icon),
|
||||||
ul :global(.catalog-arrow) {
|
ul :global(.catalog-arrow),
|
||||||
|
.chip,
|
||||||
|
.chip :global(.chip-icon) {
|
||||||
transition:
|
transition:
|
||||||
background-color 160ms ease,
|
background-color 160ms ease,
|
||||||
border-color 160ms ease;
|
border-color 160ms ease;
|
||||||
}
|
}
|
||||||
ul :global(.catalog-card:hover),
|
ul :global(.catalog-card:hover),
|
||||||
ul :global(.catalog-card:hover .catalog-icon),
|
ul :global(.catalog-card:hover .catalog-icon),
|
||||||
ul :global(.catalog-card:hover .catalog-arrow) {
|
ul :global(.catalog-card:hover .catalog-arrow),
|
||||||
|
.chip:hover,
|
||||||
|
.chip:hover :global(.chip-icon) {
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import * as Sheet from '#lib/components/ui/sheet/index.js';
|
import * as Sheet from '#lib/components/ui/sheet/index.js';
|
||||||
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
||||||
import ChevronDown from 'lucide-svelte/icons/chevron-down';
|
import ChevronDown from 'lucide-svelte/icons/chevron-down';
|
||||||
|
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
||||||
import ExternalLink from 'lucide-svelte/icons/external-link';
|
import ExternalLink from 'lucide-svelte/icons/external-link';
|
||||||
import Menu from 'lucide-svelte/icons/menu';
|
import Menu from 'lucide-svelte/icons/menu';
|
||||||
import LayoutDashboard from 'lucide-svelte/icons/layout-dashboard';
|
import LayoutDashboard from 'lucide-svelte/icons/layout-dashboard';
|
||||||
@@ -51,7 +52,13 @@
|
|||||||
{ label: 'blog', href: '/blog' },
|
{ label: 'blog', href: '/blog' },
|
||||||
{ label: 'lists', href: '/lists' }
|
{ label: 'lists', href: '/lists' }
|
||||||
] as const;
|
] as const;
|
||||||
type MenuItem = { name: string; subtitle: string; href: string; external: boolean };
|
type MenuItem = {
|
||||||
|
name: string;
|
||||||
|
subtitle: string;
|
||||||
|
href: string;
|
||||||
|
external: boolean;
|
||||||
|
children?: readonly MenuItem[];
|
||||||
|
};
|
||||||
const menus: Record<string, { label: string; items: readonly MenuItem[] }> = $derived({
|
const menus: Record<string, { label: string; items: readonly MenuItem[] }> = $derived({
|
||||||
'/projects': { label: 'projects', items: projects },
|
'/projects': { label: 'projects', items: projects },
|
||||||
'/trackers': { label: 'trackers', items: visibleTrackers(data.isOwner) }
|
'/trackers': { label: 'trackers', items: visibleTrackers(data.isOwner) }
|
||||||
@@ -168,6 +175,36 @@
|
|||||||
function handleMenuPointerLeave(event: PointerEvent) {
|
function handleMenuPointerLeave(event: PointerEvent) {
|
||||||
if (event.pointerType === 'touch') return;
|
if (event.pointerType === 'touch') return;
|
||||||
openMenu = null;
|
openMenu = null;
|
||||||
|
flyout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group trackers (spotting) open a flyout with their sub-trackers next to their tile in the
|
||||||
|
// Trackers dropdown. It opens on hover or keyboard focus, on the outer side of the dropdown
|
||||||
|
// when there is room, and closes after a short delay so a diagonal mouse move from the tile
|
||||||
|
// into the flyout (crossing a neighbouring tile) does not close it.
|
||||||
|
let flyout = $state<string | null>(null);
|
||||||
|
let flyoutSide = $state<'left' | 'right'>('right');
|
||||||
|
let flyoutTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const FLYOUT_WIDTH = 240;
|
||||||
|
function openFlyout(name: string, el: HTMLElement) {
|
||||||
|
clearTimeout(flyoutTimer);
|
||||||
|
if (flyout === name) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const panel = el.closest('[data-menu-panel]')?.getBoundingClientRect() ?? rect;
|
||||||
|
const leftColumn = rect.left + rect.width / 2 < panel.left + panel.width / 2;
|
||||||
|
const roomLeft = rect.left - FLYOUT_WIDTH >= 8;
|
||||||
|
const roomRight = rect.right + FLYOUT_WIDTH <= window.innerWidth - 8;
|
||||||
|
flyoutSide = leftColumn ? (roomLeft ? 'left' : 'right') : roomRight ? 'right' : 'left';
|
||||||
|
flyout = name;
|
||||||
|
}
|
||||||
|
function closeFlyoutOnEscape(event: KeyboardEvent) {
|
||||||
|
if (event.key !== 'Escape' || !flyout) return;
|
||||||
|
event.stopPropagation();
|
||||||
|
flyout = null;
|
||||||
|
}
|
||||||
|
function closeFlyoutSoon() {
|
||||||
|
clearTimeout(flyoutTimer);
|
||||||
|
flyoutTimer = setTimeout(() => (flyout = null), 300);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -246,12 +283,14 @@
|
|||||||
{#if openMenu === item.href}
|
{#if openMenu === item.href}
|
||||||
<div
|
<div
|
||||||
class={[
|
class={[
|
||||||
'absolute top-full z-50 max-h-[75vh] overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md',
|
'absolute top-full z-50 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md',
|
||||||
|
// No overflow clipping on the trackers panel: its spotting flyout sticks out.
|
||||||
item.href === '/trackers'
|
item.href === '/trackers'
|
||||||
? 'left-1/2 grid w-[min(36rem,calc(100vw-2rem))] -translate-x-1/2 grid-cols-2 gap-1.5 p-2'
|
? 'left-1/2 grid w-[min(36rem,calc(100vw-2rem))] -translate-x-1/2 grid-cols-2 gap-1.5 p-2'
|
||||||
: 'left-0 w-72'
|
: 'left-0 max-h-[75vh] w-72 overflow-y-auto'
|
||||||
]}
|
]}
|
||||||
aria-label={menu.label}
|
aria-label={menu.label}
|
||||||
|
data-menu-panel
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href={resolve(item.href)}
|
href={resolve(item.href)}
|
||||||
@@ -261,6 +300,75 @@
|
|||||||
>
|
>
|
||||||
<div class="col-span-2 my-1 h-px bg-border"></div>
|
<div class="col-span-2 my-1 h-px bg-border"></div>
|
||||||
{#each menu.items as project (project.name)}
|
{#each menu.items as project (project.name)}
|
||||||
|
{#if project.children}
|
||||||
|
<div
|
||||||
|
class="relative"
|
||||||
|
class:z-10={flyout === project.name}
|
||||||
|
role="group"
|
||||||
|
aria-label={`${project.name} trackers`}
|
||||||
|
onpointerenter={(event) => {
|
||||||
|
if (event.pointerType !== 'touch')
|
||||||
|
openFlyout(project.name, event.currentTarget);
|
||||||
|
}}
|
||||||
|
onpointerleave={(event) => {
|
||||||
|
if (event.pointerType !== 'touch') closeFlyoutSoon();
|
||||||
|
}}
|
||||||
|
onfocusin={(event) => openFlyout(project.name, event.currentTarget)}
|
||||||
|
onfocusout={(event) => {
|
||||||
|
if (!event.currentTarget.contains(event.relatedTarget as Node | null))
|
||||||
|
flyout = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={resolve(project.href as '/')}
|
||||||
|
class="tile flex h-full items-center gap-2.5 p-2 text-sm"
|
||||||
|
aria-haspopup="true"
|
||||||
|
onkeydown={closeFlyoutOnEscape}
|
||||||
|
aria-expanded={flyout === project.name}
|
||||||
|
><TrackerIcon name={project.name} class="tile-icon size-9" /><span
|
||||||
|
class="min-w-0"
|
||||||
|
><span class="block font-medium">{project.name}</span><span
|
||||||
|
class="block truncate text-xs text-muted-foreground"
|
||||||
|
>{project.subtitle}</span
|
||||||
|
></span
|
||||||
|
><ChevronRight
|
||||||
|
size={14}
|
||||||
|
class="ml-auto shrink-0 text-muted-foreground transition-transform {flyout ===
|
||||||
|
project.name
|
||||||
|
? flyoutSide === 'left'
|
||||||
|
? 'rotate-180'
|
||||||
|
: 'translate-x-0.5'
|
||||||
|
: ''}"
|
||||||
|
/></a
|
||||||
|
>
|
||||||
|
{#if flyout === project.name}
|
||||||
|
<!-- The padding facing the tile is a hover bridge into the panel. -->
|
||||||
|
<div
|
||||||
|
class={[
|
||||||
|
'absolute -top-2 w-60',
|
||||||
|
flyoutSide === 'right' ? 'left-full pl-3' : 'right-full pr-3'
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flyout grid gap-1 rounded-md border border-border bg-popover p-1.5 shadow-lg"
|
||||||
|
class:from-left={flyoutSide === 'left'}
|
||||||
|
>
|
||||||
|
{#each project.children as child (child.name)}
|
||||||
|
<a
|
||||||
|
href={resolve(child.href as '/')}
|
||||||
|
class="tile flex items-center gap-2.5 p-1.5 text-sm"
|
||||||
|
aria-current={current(child.href) ? 'page' : undefined}
|
||||||
|
onkeydown={closeFlyoutOnEscape}
|
||||||
|
><TrackerIcon name={child.name} class="tile-icon size-8" /><span
|
||||||
|
class="font-medium">{child.name}</span
|
||||||
|
></a
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
<!-- Internal project links use resolve; external URLs are from the project catalog. -->
|
<!-- Internal project links use resolve; external URLs are from the project catalog. -->
|
||||||
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
||||||
<a
|
<a
|
||||||
@@ -285,9 +393,13 @@
|
|||||||
>{project.subtitle}</span
|
>{project.subtitle}</span
|
||||||
></span
|
></span
|
||||||
>
|
>
|
||||||
{#if project.external}<ExternalLink size={14} class="ml-auto shrink-0" />{/if}
|
{#if project.external}<ExternalLink
|
||||||
|
size={14}
|
||||||
|
class="ml-auto shrink-0"
|
||||||
|
/>{/if}
|
||||||
</a>
|
</a>
|
||||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -399,6 +511,23 @@
|
|||||||
>
|
>
|
||||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
||||||
{/each}
|
{/each}
|
||||||
|
{#each menus[item.href].items.filter((p) => p.children) as group (group.name)}
|
||||||
|
<div class="col-span-2 mt-1">
|
||||||
|
<span class="mb-1 block text-xs text-muted-foreground">{group.name}</span>
|
||||||
|
<div class="ml-1 grid grid-cols-2 gap-1.5 border-l border-border pl-2">
|
||||||
|
{#each group.children ?? [] as child (child.name)}
|
||||||
|
<a
|
||||||
|
href={resolve(child.href as '/')}
|
||||||
|
class="tile flex min-w-0 items-center gap-2 p-1.5 text-sm"
|
||||||
|
onclick={() => (menuOpen = false)}
|
||||||
|
><TrackerIcon name={child.name} class="tile-icon size-6" /><span
|
||||||
|
class="truncate">{child.name}</span
|
||||||
|
></a
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="ml-3 flex flex-col border-l border-border pl-3">
|
<div class="ml-3 flex flex-col border-l border-border pl-3">
|
||||||
@@ -533,6 +662,19 @@
|
|||||||
background: color-mix(in oklab, var(--foreground) 9%, transparent);
|
background: color-mix(in oklab, var(--foreground) 9%, transparent);
|
||||||
border-color: color-mix(in oklab, var(--foreground) 35%, transparent);
|
border-color: color-mix(in oklab, var(--foreground) 35%, transparent);
|
||||||
}
|
}
|
||||||
|
.flyout {
|
||||||
|
animation: flyout-in 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
--flyout-from: -4px;
|
||||||
|
}
|
||||||
|
.flyout.from-left {
|
||||||
|
--flyout-from: 4px;
|
||||||
|
}
|
||||||
|
@keyframes flyout-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(var(--flyout-from));
|
||||||
|
}
|
||||||
|
}
|
||||||
.tile :global(.tile-icon) {
|
.tile :global(.tile-icon) {
|
||||||
transition: transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
transition: transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
}
|
}
|
||||||
@@ -555,6 +697,9 @@
|
|||||||
background-image: linear-gradient(135deg, #10b981, #0d9488);
|
background-image: linear-gradient(135deg, #10b981, #0d9488);
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.flyout {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
.tile,
|
.tile,
|
||||||
.tile :global(.tile-icon) {
|
.tile :global(.tile-icon) {
|
||||||
transition:
|
transition:
|
||||||
|
|||||||
@@ -32,11 +32,51 @@ export const projects = [
|
|||||||
}
|
}
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
type Tracker = {
|
||||||
|
name: string;
|
||||||
|
subtitle: string;
|
||||||
|
href: string;
|
||||||
|
external: boolean;
|
||||||
|
/** Sub-trackers linked directly from this item (header flyout, tile chips). */
|
||||||
|
children?: readonly Tracker[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
// Trackers, in display order. Names are short single nouns. Most keep their existing
|
// Trackers, in display order. Names are short single nouns. Most keep their existing
|
||||||
// /projects/... URLs (no moves, so no redirects needed). `private` trackers are shown in the
|
// /projects/... URLs (no moves, so no redirects needed). `private` trackers are shown in the
|
||||||
// header, home page and /trackers only to the logged-in owner (layout data `isOwner`), never
|
// header, home page and /trackers only to the logged-in owner (layout data `isOwner`), never
|
||||||
// in the sitemap; their routes 404 for everyone else.
|
// in the sitemap; their routes 404 for everyone else.
|
||||||
const allTrackers = [
|
const allTrackers: readonly (Tracker & { private: boolean })[] = [
|
||||||
{
|
{
|
||||||
name: 'tasks',
|
name: 'tasks',
|
||||||
subtitle: 'private: to-dos, goals and my TickTick history',
|
subtitle: 'private: to-dos, goals and my TickTick history',
|
||||||
@@ -63,6 +103,7 @@ const allTrackers = [
|
|||||||
subtitle: "cars, trains, planes & wildlife I've spotted",
|
subtitle: "cars, trains, planes & wildlife I've spotted",
|
||||||
href: '/trackers/spotting',
|
href: '/trackers/spotting',
|
||||||
external: false,
|
external: false,
|
||||||
|
children: spottingTrackers,
|
||||||
private: false
|
private: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -135,45 +176,10 @@ const allTrackers = [
|
|||||||
external: false,
|
external: false,
|
||||||
private: false
|
private: false
|
||||||
}
|
}
|
||||||
] as const;
|
|
||||||
|
|
||||||
type Tracker = { name: string; subtitle: string; href: string; external: boolean };
|
|
||||||
const strip = ({ name, subtitle, href, external }: Tracker): Tracker => ({
|
|
||||||
name,
|
|
||||||
subtitle,
|
|
||||||
href,
|
|
||||||
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
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const strip = ({ name, subtitle, href, external, children }: Tracker): Tracker =>
|
||||||
|
children ? { name, subtitle, href, external, children } : { name, subtitle, href, external };
|
||||||
/** 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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user