start: trackers section mirroring the header dropdown (same order and icons, spotting chips, f filter)
All checks were successful
Deploy production / deploy (push) Successful in 1m11s

This commit is contained in:
2026-10-25 14:21:23 +00:00
parent 11fa8ec006
commit 8a8ed1642e

View File

@@ -3,6 +3,8 @@
import { invalidate } from '$app/navigation'; import { invalidate } from '$app/navigation';
import { ago, bytes, duration, timerStatus, type Container } from '#lib/home.js'; import { ago, bytes, duration, timerStatus, type Container } from '#lib/home.js';
import { linkIcon } from '#lib/link-icons.js'; import { linkIcon } from '#lib/link-icons.js';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import { visibleTrackers } from '#lib/projects.js';
let { data, form } = $props(); let { data, form } = $props();
@@ -26,16 +28,12 @@
} }
}); });
const trackers = [ // Same list, order and icons as the header Trackers dropdown (owner-only ones only when
{ name: 'Tasks', href: '/trackers/tasks' }, // the header shows them). Spotting is a group tile with one-click chips per sub-tracker.
{ name: 'Money', href: '/trackers/money' }, const trackers = $derived(visibleTrackers(data.isOwner));
{ name: 'Health', href: '/trackers/health' }, // Search-only shortcuts that aren't trackers of their own.
const extraHits = [
{ name: 'Weigh-in', href: '/trackers/health#body' }, { name: 'Weigh-in', href: '/trackers/health#body' },
{ name: 'Books', href: '/trackers/books' },
{ name: 'Houses', href: '/trackers/houses' },
{ name: 'Groceries', href: '/trackers/groceries' },
{ name: 'Energy', href: '/projects/energy' },
{ name: 'Lists', href: '/lists' },
{ name: 'DB', href: '/lists/db' } { name: 'DB', href: '/lists/db' }
]; ];
@@ -65,6 +63,18 @@
}); });
const filtering = $derived(linkFilter.trim() !== ''); const filtering = $derived(linkFilter.trim() !== '');
// The links filter (f) also narrows the trackers; a group stays if it or a chip matches.
const shownTrackers = $derived.by(() => {
const q = linkFilter.trim().toLowerCase();
const hit = (t: { name: string; subtitle: string; href: string }) =>
t.name.includes(q) || t.subtitle.toLowerCase().includes(q) || t.href.includes(q);
if (!q || 'trackers'.startsWith(q)) return trackers;
return trackers.flatMap((t) => {
if (hit(t)) return [t];
const children = t.children?.filter(hit) ?? [];
return children.length ? [{ ...t, children }] : [];
});
});
const shownSections = $derived.by(() => { const shownSections = $derived.by(() => {
const q = linkFilter.trim().toLowerCase(); const q = linkFilter.trim().toLowerCase();
if (!q) return sections; if (!q) return sections;
@@ -97,10 +107,16 @@
linkFilter = ''; linkFilter = '';
linkFilterEl?.blur(); linkFilterEl?.blur();
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
const first = shownSections[0]?.[1][0]; // First visible item in page order: trackers sit above the links.
if (!first) return; const t = shownTrackers[0];
if (e.metaKey || e.ctrlKey) window.open(first.url, '_blank'); const url = t
else location.href = first.url; ? t.children?.length === 1
? t.children[0].href
: t.href
: shownSections[0]?.[1][0]?.url;
if (!url) return;
if (e.metaKey || e.ctrlKey) window.open(url, '_blank');
else location.href = url;
} }
} }
@@ -108,7 +124,10 @@
const everything = $derived<Hit[]>([ const everything = $derived<Hit[]>([
...data.links.map((l) => ({ name: l.title, href: l.url, kind: l.section })), ...data.links.map((l) => ({ name: l.title, href: l.url, kind: l.section })),
...(host?.services ?? []).map((s) => ({ name: s.name, href: s.url, kind: 'service' })), ...(host?.services ?? []).map((s) => ({ name: s.name, href: s.url, kind: 'service' })),
...trackers.map((t) => ({ name: t.name, href: t.href, kind: 'tracker' })) ...trackers
.flatMap((t) => [t, ...(t.children ?? [])])
.map((t) => ({ name: t.name, href: t.href, kind: 'tracker' })),
...extraHits.map((t) => ({ ...t, kind: 'tracker' }))
]); ]);
const hits = $derived.by(() => { const hits = $derived.by(() => {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
@@ -260,6 +279,45 @@
</div> </div>
</header> </header>
<!-- Trackers: mirrors the header dropdown -->
{#if shownTrackers.length}
<section class="card trackers" aria-label="Trackers">
<h2>
<a href="/trackers">Trackers</a>
<small
>{filtering ? `${shownTrackers.length} of ${trackers.length}` : trackers.length}</small
>
</h2>
<ul class="tgrid">
{#each shownTrackers as t (t.name)}
<li class={{ group: !!t.children }}>
{#if t.children}
<!-- Group tile: stretched main link, chips layered above it (no nested <a>). -->
<div class="ttile">
<a class="stretched" href={t.href} title={t.subtitle}>
<TrackerIcon name={t.name} class="t-icon size-8" />
<span class="tname">{t.name}</span>
</a>
<span class="tchips">
{#each t.children as c (c.name)}
<a class="tchip" href={c.href} title={c.subtitle}
><TrackerIcon name={c.name} class="t-icon size-5" />{c.name}</a
>
{/each}
</span>
</div>
{:else}
<a class="ttile" href={t.href} title={t.subtitle}>
<TrackerIcon name={t.name} class="t-icon size-8" />
<span class="tname">{t.name}</span>
</a>
{/if}
</li>
{/each}
</ul>
</section>
{/if}
<div class="grid"> <div class="grid">
<div class="col"> <div class="col">
<!-- Tasks --> <!-- Tasks -->
@@ -421,12 +479,6 @@
</li> </li>
{/each} {/each}
</ul> </ul>
<h3>Trackers</h3>
<ul class="tiles">
{#each trackers as t (t.href)}
<li><a href={t.href}>{t.name}</a></li>
{/each}
</ul>
</section> </section>
</div> </div>
<div class="col"> <div class="col">
@@ -440,8 +492,8 @@
bind:value={linkFilter} bind:value={linkFilter}
onkeydown={onFilterKey} onkeydown={onFilterKey}
type="search" type="search"
placeholder="Filter links (f)" placeholder="Filter links & trackers (f)"
aria-label="Filter links" aria-label="Filter links and trackers"
/> />
<button class="ghost" onclick={() => ((editing = !editing), (editId = null))} <button class="ghost" onclick={() => ((editing = !editing), (editId = null))}
>{editing ? 'done' : 'edit'}</button >{editing ? 'done' : 'edit'}</button
@@ -979,6 +1031,129 @@
margin: 0 0 0 auto; margin: 0 0 0 auto;
flex: none; flex: none;
} }
/* Trackers: black/white tiles with the colourful header icons; spotting spans three. */
.trackers {
margin-bottom: 12px;
}
.tgrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
gap: 6px;
}
.tgrid > li.group {
grid-column: span 3;
}
.ttile {
position: relative;
display: flex;
align-items: center;
gap: 9px;
height: 100%;
padding: 6px 8px;
border: 1px solid var(--line);
border-radius: 8px;
background: color-mix(in oklab, var(--site-text) 2%, transparent);
transition:
transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1),
background-color 160ms ease,
border-color 160ms ease,
box-shadow 160ms ease;
}
a.ttile:hover,
div.ttile:has(.stretched:hover) {
background: var(--site-hover);
border-color: var(--site-muted);
transform: translateY(-1px);
box-shadow: 0 6px 16px -10px rgb(0 0 0 / 0.45);
text-decoration: none;
}
.ttile :global(.t-icon) {
transition: transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
a.ttile:hover :global(.t-icon),
.stretched:hover :global(.t-icon),
.tchip:hover :global(.t-icon) {
transform: scale(1.06);
}
.tname {
font-weight: 500;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stretched {
display: flex;
align-items: center;
gap: 9px;
flex: none;
}
.stretched::after {
content: '';
position: absolute;
inset: 0;
border-radius: 8px;
}
.stretched:hover {
text-decoration: none;
}
.tchips {
position: relative;
z-index: 1;
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-left: auto;
}
.tchip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 9px 2px 2px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--site-surface);
font-size: 12px;
font-weight: 500;
transition:
transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1),
border-color 160ms ease,
background-color 160ms ease;
}
.tchip :global(.t-icon) {
border-radius: 999px;
}
.tchip:hover {
background: var(--site-hover);
border-color: var(--site-muted);
transform: translateY(-1px);
text-decoration: none;
}
.ttile:focus-visible,
.stretched:focus-visible,
.tchip:focus-visible {
outline: 2px solid var(--site-text);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.ttile,
.tchip,
.ttile :global(.t-icon) {
transition: none;
transform: none !important;
}
}
@media (max-width: 760px) {
.tgrid > li.group {
grid-column: 1 / -1;
}
div.ttile {
flex-wrap: wrap;
}
.tchips {
margin-left: 0;
}
}
.ico { .ico {
flex: none; flex: none;
width: 16px; width: 16px;