| 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);
}
@@ -246,12 +283,14 @@
{#if openMenu === item.href}
+ {:else}
+
+
+
+ {#if item.href === '/trackers'}
+
+ {:else}
+
+
+
+ {/if}
+ {project.name}{project.subtitle}
+ {#if project.external}{/if}
+
+
+ {/if}
{/each}
{/if}
@@ -399,6 +511,23 @@
>
{/each}
+ {#each menus[item.href].items.filter((p) => p.children) as group (group.name)}
+
+ {/each}
{:else}
@@ -533,6 +662,19 @@
background: color-mix(in oklab, var(--foreground) 9%, 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) {
transition: transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
@@ -555,6 +697,9 @@
background-image: linear-gradient(135deg, #10b981, #0d9488);
}
@media (prefers-reduced-motion: reduce) {
+ .flyout {
+ animation: none;
+ }
.tile,
.tile :global(.tile-icon) {
transition:
diff --git a/src/lib/projects.ts b/src/lib/projects.ts
index d88f4fe..6f7fc86 100644
--- a/src/lib/projects.ts
+++ b/src/lib/projects.ts
@@ -32,11 +32,51 @@ export const projects = [
}
] 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
// /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
// in the sitemap; their routes 404 for everyone else.
-const allTrackers = [
+const allTrackers: readonly (Tracker & { private: boolean })[] = [
{
name: 'tasks',
subtitle: 'private: to-dos, goals and my TickTick history',
@@ -63,6 +103,7 @@ const allTrackers = [
subtitle: "cars, trains, planes & wildlife I've spotted",
href: '/trackers/spotting',
external: false,
+ children: spottingTrackers,
private: false
},
{
@@ -135,45 +176,10 @@ const allTrackers = [
external: 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). */
export const trackers: readonly Tracker[] = allTrackers.filter((t) => !t.private).map(strip);
/** Owner-only trackers. */