Files
website/src/routes/start/+page.svelte

1921 lines
52 KiB
Svelte

<script lang="ts">
import { deserialize, enhance } from '$app/forms';
import { invalidate, invalidateAll } from '$app/navigation';
import { Tween } from 'svelte/motion';
import {
ago,
bytes,
duration,
timerStatus,
type Container,
type LiveFrame,
type LiveHistory
} from '#lib/home.js';
import { linkIcon } from '#lib/link-icons.js';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import PrivateLock from '#lib/components/PrivateLock.svelte';
import Sparkline from '#lib/components/Sparkline.svelte';
import { projects, visibleTrackers } from '#lib/projects.js';
let { data, form } = $props();
// Ticks every 30 s for relative times; starts at the server's clock so hydration matches, and resets on every reload.
let now = $derived(data.now);
let query = $state('');
let editing = $state(false);
let editId = $state<number | null>(null);
let searchEl = $state<HTMLInputElement>();
let linkFilter = $state('');
let linkFilterEl = $state<HTMLInputElement>();
// Link groups: collapsible, open/closed remembered per browser; these start expanded.
const DEFAULT_OPEN = ['daily', 'self-hosted', 'dev', 'infra', 'money'];
const OPEN_KEY = 'start:links:open';
let openGroups = $state<Record<string, boolean>>({});
$effect(() => {
try {
openGroups = JSON.parse(localStorage.getItem(OPEN_KEY) ?? '{}');
} catch {
openGroups = {};
}
});
// Same list, order and icons as the header Trackers dropdown (owner-only ones only when
// the header shows them). Spotting is a group tile with one-click chips per sub-tracker.
const trackers = $derived(visibleTrackers(data.isOwner, data.inactiveGames, data.hiddenTrackers));
// Search-only shortcuts that aren't trackers of their own.
const extraHits = [
{ name: 'Weigh-in', href: '/trackers/health#body' },
{ name: 'DB', href: '/lists/db' }
];
const statsHits = [
{ name: 'Stats', href: '/stats' },
{ name: 'Stats: economy', href: '/stats/economy' },
{ name: 'Stats: energy', href: '/stats/energy' },
{ name: 'Stats: Gouda', href: '/stats/gouda' },
{ name: 'Stats: countries', href: '/stats/countries' },
{ name: 'Stats: Curaçao', href: '/stats/countries/CUW' }
];
const host = $derived(data.host);
// Live stats: a Server-Sent Events stream (/start/live, owner only) sends a frame every
// second while this tab is visible; the minute-old snapshot stays the fallback. Hidden tab
// = stream closed, so the server stops polling when nobody is looking.
let live = $state<LiveFrame | null>(null);
let hist = $state<LiveHistory>([]);
let liveState = $state<'off' | 'connecting' | 'live' | 'down'>('off');
$effect(() => {
let es: EventSource | null = null;
let retry: ReturnType<typeof setTimeout> | undefined;
let fails = 0;
const open = () => {
clearTimeout(retry);
if (es || document.visibilityState !== 'visible') return;
liveState = 'connecting';
const src = new EventSource('/start/live');
es = src;
src.addEventListener('init', (e) => {
const d = JSON.parse(e.data) as { history: LiveHistory; last: LiveFrame | null };
hist = d.history;
if (d.last) live = d.last;
fails = 0;
liveState = 'live';
});
src.addEventListener('frame', (e) => {
const f = JSON.parse(e.data) as LiveFrame;
live = f;
const { t, cpu, mem, net, io } = f;
hist = [...hist.slice(-59), { t, cpu, mem, net, io }];
liveState = 'live';
});
src.addEventListener('fail', () => (liveState = 'down'));
src.onerror = () => {
// The browser retries by itself unless the stream was refused (e.g. logged out).
if (src.readyState !== EventSource.CLOSED) return void (liveState = 'connecting');
es = null;
liveState = 'down';
retry = setTimeout(open, Math.min(60_000, 3_000 * 2 ** fails++));
};
};
const close = () => {
clearTimeout(retry);
es?.close();
es = null;
liveState = 'off';
};
const onVis = () => (document.visibilityState === 'visible' ? open() : close());
document.addEventListener('visibilitychange', onVis);
open();
return () => {
document.removeEventListener('visibilitychange', onVis);
close();
};
});
const isLive = $derived(liveState === 'live' && !!live);
const stale = $derived(!isLive && (!host || now - host.at > 3 * 60_000));
const cpuNow = $derived(isLive ? live!.cpu : (host?.cpuPct ?? 0));
const memUsed = $derived(isLive ? live!.mem.used : (host?.mem.used ?? 0));
const memTotal = $derived(isLive ? live!.mem.total : (host?.mem.total ?? 1));
const load = $derived(isLive ? live!.load : (host?.load ?? []));
// Numbers glide to the new value instead of jumping.
const ease = { duration: 450 };
const cpuT = Tween.of(() => cpuNow, ease);
const memT = Tween.of(() => memUsed, ease);
const rxT = Tween.of(() => live?.net.rx ?? 0, ease);
const txT = Tween.of(() => live?.net.tx ?? 0, ease);
const rdT = Tween.of(() => live?.io.read ?? 0, ease);
const wrT = Tween.of(() => live?.io.write ?? 0, ease);
type Row = Container & { cpuN: number | null; memN: number | null };
const containers = $derived<Row[]>(
(host?.containers ?? [])
.filter((c) => !c.name.startsWith('GITEA-ACTIONS-'))
.map((c) => {
const l = isLive ? live!.containers[c.name] : undefined;
const snap = { cpuN: c.cpu ? parseFloat(c.cpu) || 0 : null, memN: memBytes(c.mem) || null };
if (!l) return { ...c, ...snap };
const state = l.state ?? c.state;
return {
...c,
state,
health: l.state ? l.health : c.health,
cpuN: state === 'running' ? (l.cpu ?? snap.cpuN) : null,
memN: state === 'running' ? (l.mem ?? snap.memN) : null
};
})
);
const containersUp = $derived(containers.filter((c) => c.state === 'running').length);
// Containers grouped by Docker Compose project (com.docker.compose.project label);
// standalone containers go last under "other". Groups are collapsible, remembered per browser.
const STACKS_KEY = 'start:containers:closed';
let closedStacks = $state<Record<string, boolean>>({});
$effect(() => {
try {
closedStacks = JSON.parse(localStorage.getItem(STACKS_KEY) ?? '{}');
} catch {
closedStacks = {};
}
});
function toggleStack(key: string, open: boolean) {
if (!closedStacks[key] === open) return;
closedStacks = { ...closedStacks, [key]: !open };
try {
localStorage.setItem(STACKS_KEY, JSON.stringify(closedStacks));
} catch {
// private mode: just don't remember
}
}
const DOT_RANK: Record<string, number> = { off: 0, ok: 1, warn: 2, bad: 3 };
const containerGroups = $derived.by(() => {
const byProject: Record<string, Row[]> = {};
for (const c of containers) (byProject[c.project || ''] ??= []).push(c);
return Object.entries(byProject)
.map(([key, items]) => {
items.sort((a, b) => label(a).localeCompare(label(b)));
const dots = items.map(containerDot);
const running = dots.filter((d) => d !== 'off');
return {
key,
name: key || 'other',
items,
up: items.filter((c) => c.state === 'running').length,
// Worst state of the running containers; grey only when everything is stopped.
dot: running.length
? running.reduce((a, b) => (DOT_RANK[b] > DOT_RANK[a] ? b : a), 'ok')
: 'off',
cpu: items.reduce((n, c) => n + (c.cpuN ?? 0), 0),
mem: items.reduce((n, c) => n + (c.memN ?? 0), 0)
};
})
.sort((a, b) => Number(!a.key) - Number(!b.key) || a.name.localeCompare(b.name));
});
const UNITS: Record<string, number> = {
B: 1,
KIB: 1024,
MIB: 1024 ** 2,
GIB: 1024 ** 3,
TIB: 1024 ** 4,
KB: 1e3,
MB: 1e6,
GB: 1e9
};
function memBytes(m: string | null) {
const x = /^([\d.]+)\s*([A-Za-z]+)$/.exec(m?.trim() ?? '');
return x ? parseFloat(x[1]) * (UNITS[x[2].toUpperCase()] ?? 0) : 0;
}
/** Row label: the compose service name inside a stack, else the trimmed container name. */
const label = (c: Container) => (c.project && c.service ? c.service : shortName(c.name));
const servicesDown = $derived((host?.services ?? []).filter((s) => s.ok === false));
const timers = $derived(
(host?.timers ?? []).map((t) => ({ ...t, status: timerStatus(t, Math.floor(now / 1000)) }))
);
const timerProblems = $derived(timers.filter((t) => t.status === 'fail' || t.status === 'late'));
const disk = $derived(host?.disks[0]);
const memPct = $derived(host || isLive ? (100 * memUsed) / memTotal : 0);
const pct = (n: number | null) =>
n == null ? '' : `${n < 10 ? n.toFixed(2) : n < 100 ? n.toFixed(1) : Math.round(n)}%`;
const rate = (bitsPerS: number) => {
const u = ['b/s', 'Kb/s', 'Mb/s', 'Gb/s'];
let i = 0;
while (bitsPerS >= 1000 && i < u.length - 1) {
bitsPerS /= 1000;
i++;
}
return `${bitsPerS >= 100 || i === 0 ? Math.round(bitsPerS) : bitsPerS.toFixed(1)} ${u[i]}`;
};
const C = {
cpu: '#58a6ff',
ram: '#bc8cff',
rx: '#3fb950',
tx: '#58a6ff',
read: '#d29922',
write: '#f778ba'
};
const swapPct = $derived(host?.swap.total ? (100 * host.swap.used) / host.swap.total : 0);
// Optimistic copy of the links while a drag-and-drop move is being saved.
let pendingLinks = $state<typeof data.links | null>(null);
const allLinks = $derived(pendingLinks ?? data.links);
const sections = $derived.by(() => {
const groups: [string, typeof data.links][] = [];
for (const l of allLinks) {
const g = groups.find(([name]) => name === l.section);
if (g) g[1].push(l);
else groups.push([l.section, [l]]);
}
return groups;
});
const filtering = $derived(linkFilter.trim() !== '');
// Big groups (games): featured chips first, the rest behind "+N more" (all while filtering).
let expandedGroups = $state<Record<string, boolean>>({});
type Group = (typeof trackers)[number];
const tileChildren = (t: Group) =>
filtering || expandedGroups[t.name]
? (t.children ?? [])
: (t.children ?? []).some((c) => c.featured)
? (t.children ?? []).filter((c) => c.featured)
: (t.children ?? []);
const hiddenChildren = (t: Group) => (t.children?.length ?? 0) - tileChildren(t).length;
// Games split like the header's Trackers dropdown: "active" (my pick, always shown) and
// "non-active" (behind its own toggle, shown while filtering).
type Child = NonNullable<Group['children']>[number];
const splitGroup = (t: Group) => (t.children ?? []).some((c) => c.inactive);
const chipSections = (t: Group): { label: string; items: readonly Child[] }[] => {
const all = t.children ?? [];
if (!splitGroup(t)) return [{ label: '', items: tileChildren(t) }];
const off = all.filter((c) => c.inactive);
return [
{ label: 'active', items: all.filter((c) => !c.inactive) },
{ label: 'non-active', items: filtering || expandedGroups[t.name] ? off : [] }
];
};
const inactiveCount = (t: Group) => (t.children ?? []).filter((c) => c.inactive).length;
// 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) => {
// Matching chips narrow a group; otherwise the group shows whole if it matches itself.
const children = t.children?.filter(hit) ?? [];
if (children.length) return [{ ...t, children }];
return hit(t) ? [t] : [];
});
});
const shownSections = $derived.by(() => {
const q = linkFilter.trim().toLowerCase();
if (!q) return sections;
return sections
.map(
([section, links]) =>
[
section,
section.includes(q)
? links
: links.filter(
(l) => l.title.toLowerCase().includes(q) || l.url.toLowerCase().includes(q)
)
] as [string, typeof data.links]
)
.filter(([, links]) => links.length);
});
const isOpen = (section: string) => openGroups[section] ?? DEFAULT_OPEN.includes(section);
function toggleGroup(section: string, open: boolean) {
if (filtering || isOpen(section) === open) return;
openGroups = { ...openGroups, [section]: open };
try {
localStorage.setItem(OPEN_KEY, JSON.stringify(openGroups));
} catch {
// private mode: just don't remember
}
}
function onFilterKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
linkFilter = '';
linkFilterEl?.blur();
} else if (e.key === 'Enter') {
// First visible item in page order: trackers sit above the links.
const t = shownTrackers[0];
const url = t
? 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;
}
}
type Hit = { name: string; href: string; kind: string };
const everything = $derived<Hit[]>([
...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' })),
...trackers
.flatMap((t) => [t, ...(t.children ?? [])])
.map((t) => ({ name: t.name, href: t.href, kind: 'tracker' })),
...extraHits.map((t) => ({ ...t, kind: 'tracker' })),
...statsHits.map((t) => ({ ...t, kind: 'stats' })),
...projects.map((p) => ({ name: p.name, href: p.href, kind: 'project' })),
{ name: 'Worldwide todo queue', href: '/projects/worldwide-todo/queue', kind: 'project' }
]);
const hits = $derived.by(() => {
const q = query.trim().toLowerCase();
if (!q) return [];
return everything
.filter((h) => h.name.toLowerCase().includes(q) || h.href.toLowerCase().includes(q))
.sort(
(a, b) =>
Number(!a.name.toLowerCase().startsWith(q)) - Number(!b.name.toLowerCase().startsWith(q))
)
.slice(0, 8);
});
$effect(() => {
// Live refresh: re-run the load (one DB round trip) every 60 s while the tab is visible.
const tick = setInterval(() => (now = Date.now()), 30_000);
const refresh = setInterval(() => {
if (document.visibilityState === 'visible') invalidate('home:live');
}, 60_000);
return () => {
clearInterval(tick);
clearInterval(refresh);
};
});
function onVisible() {
if (document.visibilityState === 'visible' && Date.now() - data.now > 20_000) {
now = Date.now();
invalidate('home:live');
}
}
function onKey(e: KeyboardEvent) {
const t = e.target as HTMLElement;
const typing = t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable;
if (e.key === '/' && !typing) {
e.preventDefault();
searchEl?.focus();
} else if (e.key === 'f' && !typing && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault();
linkFilterEl?.focus();
}
}
function onSearchKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
query = '';
searchEl?.blur();
} else if (e.key === 'Enter') {
const q = query.trim();
if (!q) return;
const target = hits[0]?.href ?? `https://www.google.com/search?q=${encodeURIComponent(q)}`;
if (e.metaKey || e.ctrlKey) window.open(target, '_blank');
else location.href = target;
}
}
function move(id: number, dir: -1 | 1) {
const ids = allLinks.map((l) => l.id);
const i = ids.indexOf(id);
const j = i + dir;
if (i < 0 || j < 0 || j >= ids.length) return '';
[ids[i], ids[j]] = [ids[j], ids[i]];
return ids.join(',');
}
// Drag and drop: reorder links within a section, or drop one into another section.
let dragId = $state<number | null>(null);
let dropAt = $state<{ section: string; before: number | null } | null>(null);
let dropError = $state('');
function onLinkDragStart(e: DragEvent, id: number) {
dragId = id;
dropError = '';
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/x-start-link', String(id));
}
}
function onLinkDragEnd() {
dragId = null;
dropAt = null;
}
/** `over` is the link under the pointer (null = the section itself, drop at its end). */
function onLinkDragOver(
e: DragEvent,
section: string,
over: number | null,
axis: 'x' | 'y' = 'x'
) {
if (dragId === null) return;
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
let before = over;
if (over !== null && e.currentTarget instanceof HTMLElement) {
const r = e.currentTarget.getBoundingClientRect();
const after =
axis === 'x' ? e.clientX > r.left + r.width / 2 : e.clientY > r.top + r.height / 2;
if (after) {
const ls = sections.find(([name]) => name === section)?.[1] ?? [];
before = ls[ls.findIndex((l) => l.id === over) + 1]?.id ?? null;
}
}
if (before === dragId) return;
if (dropAt?.section !== section || dropAt.before !== before) dropAt = { section, before };
}
async function onLinkDrop(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
const id = dragId;
const target = dropAt;
onLinkDragEnd();
const link = allLinks.find((l) => l.id === id);
if (id === null || !target || !link) return;
// Rebuild in section order so moving a link never reshuffles the sections themselves.
const groups = sections.map(
([name, ls]) => [name, ls.filter((l) => l.id !== id)] as [string, typeof data.links]
);
let g = groups.find(([name]) => name === target.section);
if (!g) groups.push((g = [target.section, []]));
const at = target.before === null ? -1 : g[1].findIndex((l) => l.id === target.before);
const moved = { ...link, section: target.section };
if (at < 0) g[1].push(moved);
else g[1].splice(at, 0, moved);
const next = groups.flatMap(([, ls]) => ls);
if (link.section === target.section && next.every((l, i) => l.id === allLinks[i]?.id)) return;
pendingLinks = next;
const body = new FormData();
body.set('order', next.map((l) => l.id).join(','));
body.set('id', String(id));
body.set('section', target.section);
try {
const res = await fetch('?/orderLinks', {
method: 'POST',
body,
headers: { 'x-sveltekit-action': 'true' }
});
const result = deserialize(await res.text());
if (result.type !== 'success') dropError = "Couldn't save the new order";
await invalidateAll();
} catch {
dropError = "Couldn't save the new order";
} finally {
pendingLinks = null;
}
}
const dropBefore = (section: string, id: number) =>
dragId !== null && dropAt?.section === section && dropAt.before === id;
const dropEnd = (section: string) =>
dragId !== null && dropAt?.section === section && dropAt.before === null;
function host_(url: string) {
try {
return new URL(url).host.replace(/^www\./, '');
} catch {
return url;
}
}
function containerDot(c: Container) {
if (c.state !== 'running') return c.state === 'exited' && c.exitCode === 0 ? 'off' : 'bad';
if (c.health === 'unhealthy') return 'bad';
if (c.health === 'starting') return 'warn';
return 'ok';
}
function shortName(n: string) {
return n.replace(/-1$/, '').replace(/^personal-website-app/, 'website');
}
const dueLabel = (d: string | null) =>
!d ? '' : d < data.today ? 'overdue' : d === data.today ? 'today' : d.slice(5);
const today = $derived(
new Intl.DateTimeFormat('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
timeZone: 'Europe/Amsterdam'
}).format(now)
);
const clock = $derived(
new Intl.DateTimeFormat('en-GB', {
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Amsterdam'
}).format(now)
);
</script>
<svelte:window onkeydown={onKey} />
<svelte:document onvisibilitychange={onVisible} />
<div class="dash">
<header class="top">
<div class="when">
<b>{clock}</b> <span>{today}</span>
</div>
<div class="search">
<!-- svelte-ignore a11y_autofocus -->
<input
bind:this={searchEl}
bind:value={query}
onkeydown={onSearchKey}
type="search"
placeholder="Search links, services, trackers… (/), Enter opens, no match = Google"
aria-label="Search"
autocomplete="off"
spellcheck="false"
autofocus
/>
{#if hits.length}
<ul class="hits">
{#each hits as h, i (h.kind + h.href + h.name)}
<li class={{ first: i === 0 }}>
<a href={h.href}><span>{h.name}</span><small>{h.kind}</small></a>
</li>
{/each}
</ul>
{/if}
</div>
<div class="chips">
{#if host}
<span class={['chip', cpuNow > 85 && 'warn']}>CPU {Math.round(cpuNow)}%</span>
<span class={['chip', memPct > 90 && 'warn']}>RAM {Math.round(memPct)}%</span>
{#if disk}<span class={['chip', disk.pct >= 85 && 'bad']}>Disk {Math.round(disk.pct)}%</span
>{/if}
<span class={['chip', servicesDown.length && 'bad']}
>{servicesDown.length ? `${servicesDown.length} down` : 'all up'}</span
>
{/if}
<a class="chip" href="/stats" title="Economy, energy and Gouda statistics">stats</a>
<a class="chip" href="/projects/worldwide-todo/queue" title="Worldwide todo list review queue"
>todo queue</a
>
{#if data.weight}<a class="chip" href="/trackers/health#body" title={data.weight.date}
>{data.weight.kg} kg</a
>{/if}
</div>
</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, wide: (t.children?.length ?? 0) > 6 }}>
{#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>{#if t.locked}<PrivateLock tight />{/if}
</a>
<span class="tchips">
{#each chipSections(t) as section (section.label)}
{#if section.label === 'non-active' && !filtering}
<button
type="button"
class="tlabel toggle"
aria-expanded={!!expandedGroups[t.name]}
onclick={() =>
(expandedGroups = {
...expandedGroups,
[t.name]: !expandedGroups[t.name]
})}
>non-active <span>{inactiveCount(t)}</span>{expandedGroups[t.name]
? ' ▾'
: ' ▸'}</button
>
{:else if section.label && section.items.length}
<span class="tlabel">{section.label} <span>{section.items.length}</span></span
>
{/if}
{#each section.items as c (c.name)}
<a
class={['tchip', c.inactive && 'inactive']}
href={c.href}
title={c.subtitle}
><TrackerIcon name={c.icon ?? c.name} class="t-icon size-5" />{c.name}</a
>
{/each}
{/each}
{#if !filtering && !splitGroup(t) && (hiddenChildren(t) > 0 || expandedGroups[t.name])}
<button
type="button"
class="tchip more"
aria-expanded={!!expandedGroups[t.name]}
onclick={() =>
(expandedGroups = { ...expandedGroups, [t.name]: !expandedGroups[t.name] })}
>{hiddenChildren(t) > 0 ? `+${hiddenChildren(t)} more` : 'less'}</button
>
{/if}
</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>{#if t.locked}<PrivateLock tight />{/if}
</a>
{/if}
</li>
{/each}
</ul>
</section>
{/if}
<div class="grid">
<div class="col">
<!-- Tasks -->
<section class="card tasks">
<h2>
<a href="/trackers/tasks">Tasks</a>
<small>{data.tasks.length} now · {data.openCount} open</small>
</h2>
<form method="POST" action="?/addTask" use:enhance class="add">
<input name="title" placeholder="Add a task for today… (! flags, !! / !!! priority)" />
<label title="No due date"><input type="checkbox" name="someday" /> later</label>
</form>
<ul class="list">
{#each data.tasks as t (t.id)}
<li class={{ flagged: t.flagged }}>
<form method="POST" action="?/task" use:enhance>
<input type="hidden" name="id" value={t.id} />
<button class="check" aria-label="Complete {t.title}" title="Complete"></button>
</form>
<span class="title">{t.title}</span>
{#if t.priority >= 2}<span class="pri p{t.priority}">{'!'.repeat(t.priority)}</span
>{/if}
{#if t.status === 'in_progress'}<span class="tag">doing</span>{/if}
{#if t.dueDate}<span class={['tag', t.dueDate < data.today && 'bad']}
>{dueLabel(t.dueDate)}{t.dueTime ? ` ${t.dueTime.slice(0, 5)}` : ''}</span
>{/if}
</li>
{:else}
<li class="empty">Nothing due. Nice.</li>
{/each}
</ul>
</section>
</div>
<div class="col">
<!-- Notifications -->
<section class="card">
<h2>
Notifications
<small
>{data.ntfy?.topics.join(' · ') ?? 'no collector yet'}{#if data.ntfy?.errors.length}
· <span class="bad">{data.ntfy.errors.join(', ')} unreachable</span>{/if}</small
>
</h2>
<ul class="list notes">
{#each data.ntfy?.messages ?? [] as m (m.id)}
<li class={['note', m.priority >= 4 && 'hi']}>
<div class="meta">
<span class="tag">{m.topic}</span>
<time>{ago(m.time * 1000, now)}</time>
</div>
{#if m.click}
<a href={m.click} class="body"
>{#if m.title}<b>{m.title}</b>{/if}
{m.message}</a
>
{:else}
<p class="body">
{#if m.title}<b>{m.title}</b>{/if}
{m.message}
</p>
{/if}
</li>
{:else}
<li class="empty">No notifications in the last 3 days.</li>
{/each}
</ul>
</section>
<!-- Timers & backups -->
<section class="card">
<h2>
Backups & timers
<small class={{ bad: timerProblems.length > 0 }}
>{timerProblems.length ? `${timerProblems.length} need a look` : 'all fine'}</small
>
</h2>
<ul class="list timers">
{#each timers as t (t.unit)}
<li title="{t.unit} ({t.scope}) · result {t.result ?? '?'} · exit {t.exitStatus}">
<i class="dot {t.status === 'ok' ? 'ok' : t.status === 'running' ? 'warn' : 'bad'}"
></i>
<span class="name">{t.label}</span>
<span class="num"
>{t.status === 'running'
? 'running'
: t.last
? ago(t.last * 1000, now)
: 'not since boot'}</span
>
<span class="num dim">{t.next ? ago(t.next * 1000, now) : ''}</span>
</li>
{/each}
</ul>
</section>
</div>
<div class="col">
<!-- Server -->
<section class="card">
<h2>
<a href="http://stan-ovh.tail665403.ts.net:19999">Server</a>
<small class={{ bad: stale }}
>{#if isLive}<span class="live" title="Live: updates every second"><i></i>live</span>
· {host?.hostname ?? ''}{:else}{host
? `${host.hostname} · ${ago(host.at, now)}`
: 'no snapshot yet'}{stale && host
? ' (stale)'
: ''}{#if liveState === 'connecting'}
· connecting…{/if}{/if}</small
>
</h2>
{#if host}
<div class="meters">
{@render meter(
'CPU',
cpuT.current,
`${cpuT.current.toFixed(1)}% · ${host.cpus} cores`,
[{ values: hist.map((h) => h.cpu), color: C.cpu, fill: true }],
100
)}
{@render meter(
'RAM',
(100 * memT.current) / memTotal,
`${bytes(memT.current)} / ${bytes(memTotal)}`,
[{ values: hist.map((h) => h.mem.used), color: C.ram, fill: true }],
memTotal
)}
{#if isLive}
{@render flow(
'Net',
`↓ ${rate(rxT.current)}`,
`↑ ${rate(txT.current)}`,
[
{ values: hist.map((h) => h.net.rx), color: C.rx, fill: true },
{ values: hist.map((h) => h.net.tx), color: C.tx }
],
[C.rx, C.tx]
)}
{@render flow(
'Disk IO',
`R ${bytes(rdT.current)}/s`,
`W ${bytes(wrT.current)}/s`,
[
{ values: hist.map((h) => h.io.read), color: C.read, fill: true },
{ values: hist.map((h) => h.io.write), color: C.write }
],
[C.read, C.write]
)}
{/if}
{@render meter(
'Swap',
swapPct,
host.swap.total ? `${bytes(host.swap.used)} / ${bytes(host.swap.total)}` : 'none'
)}
{#each host.disks as d (d.mount)}
{@render meter(
`Disk ${d.mount}`,
d.pct,
`${bytes(d.free)} free of ${bytes(d.total)}`
)}
{/each}
</div>
<p class="sub">
load {load.map((n) => n.toFixed(2)).join(' / ')} · up {duration(host.uptimeS)} · {containersUp}/{containers.length}
containers in {containerGroups.length} stacks
</p>
<div class="stacks">
{#each containerGroups as g (g.key)}
{#if g.items.length === 1 && g.key}
<!-- A one-container stack is a single row, not a group with one child. -->
{@const c = g.items[0]}
<div
class="stack solo"
title="{c.name} · {c.image} · {c.state}{c.health ? ` (${c.health})` : ''}"
>
<i class="dot {containerDot(c)}"></i>
<span class="sname"
>{g.name}{#if c.service && c.service !== g.name}<small>
· {c.service}</small
>{/if}</span
>
{#if c.restarts}<span class="tag warn">↻{c.restarts}</span>{/if}
<span class="num cpu">{pct(c.cpuN)}</span>
<span class="num mem">{c.memN ? bytes(c.memN) : ''}</span>
</div>
{:else}
<details
class="group stack"
open={!closedStacks[g.key]}
ontoggle={(e) => toggleStack(g.key, e.currentTarget.open)}
>
<summary
title={g.key
? `compose project ${g.key}`
: 'containers without a compose project'}
>
<i class="dot {g.dot}"></i>
<span class="sname">{g.name}</span>
<small class={{ bad: g.up < g.items.length && g.dot !== 'off' }}
>{g.up}/{g.items.length}</small
>
<span class="num cpu">{g.up ? pct(g.cpu) : ''}</span>
<span class="num mem">{g.mem ? bytes(g.mem) : ''}</span>
</summary>
<ul class="containers">
{#each g.items as c (c.name)}
<li
title="{c.name} · {c.image} · {c.state}{c.health ? ` (${c.health})` : ''}"
>
<i class="dot {containerDot(c)}"></i>
<span class="name">{label(c)}</span>
{#if c.restarts}<span class="tag warn">↻{c.restarts}</span>{/if}
<span class="num cpu">{pct(c.cpuN)}</span>
<span class="num mem">{c.memN ? bytes(c.memN) : ''}</span>
</li>
{/each}
</ul>
</details>
{/if}
{/each}
</div>
{:else}
<p class="empty">The host collector hasn't written a snapshot yet.</p>
{/if}
</section>
<!-- Services -->
<section class="card">
<h2>
Services <small
>{(host?.services.length ?? 0) - servicesDown.length}/{host?.services.length ?? 0} up</small
>
</h2>
<ul class="tiles">
{#each host?.services ?? [] as s (s.name)}
<li>
<a
href={s.url}
title="{host_(s.url)} · {s.code ?? '?'}{s.ms != null ? ` · ${s.ms} ms` : ''}"
>
{@render icon(s.url, s.name)}<span class="label">{s.name}</span>
<i class="dot {s.ok === false ? 'bad' : s.ok ? 'ok' : 'off'}"></i>
</a>
</li>
{/each}
</ul>
</section>
</div>
<div class="col">
<!-- Personal links -->
<section class="card links">
<h2>
Links <small class="count">{allLinks.length}</small>
<input
class="filter"
bind:this={linkFilterEl}
bind:value={linkFilter}
onkeydown={onFilterKey}
type="search"
placeholder="Filter links & trackers (f)"
aria-label="Filter links and trackers"
/>
<button class="ghost" onclick={() => ((editing = !editing), (editId = null))}
>{editing ? 'done' : 'edit'}</button
>
</h2>
{#if form?.message}<p class="bad sub" role="alert">{form.message}</p>{/if}
{#if dropError}<p class="bad sub" role="alert">{dropError}</p>{/if}
{#if !editing}
{#each shownSections as [section, links] (section)}
{@const open = filtering || isOpen(section)}
<details
class={['group', dropEnd(section) && 'drop-end']}
{open}
ontoggle={(e) => toggleGroup(section, e.currentTarget.open)}
ondragover={(e) => onLinkDragOver(e, section, null)}
ondrop={onLinkDrop}
>
<summary><span>{section}</span><small>{links.length}</small></summary>
{#if open}
<ul class="tiles icons">
{#each links as l (l.id)}
<li
class={[
dragId === l.id && 'dragging',
dropBefore(section, l.id) && 'drop-before'
]}
ondragstart={(e) => onLinkDragStart(e, l.id)}
ondragend={onLinkDragEnd}
ondragover={(e) => onLinkDragOver(e, section, l.id, 'x')}
ondrop={onLinkDrop}
>
<a href={l.url} title={l.url}
>{@render icon(l.url, l.title)}<span class="label">{l.title}</span></a
>
</li>
{/each}
</ul>
{/if}
</details>
{:else}
<p class="empty">No links match “{linkFilter}”.</p>
{/each}
{/if}
{#each editing ? sections : [] as [section, links] (section)}
{#if sections.length > 1}<h3>{section}</h3>{/if}
<ul
class={['list', dropEnd(section) && 'drop-end']}
ondragover={(e) => onLinkDragOver(e, section, null, 'y')}
ondrop={onLinkDrop}
>
{#each links as l (l.id)}
<li
class={[
dragId === l.id && 'dragging',
dropBefore(section, l.id) && 'drop-before-y'
]}
draggable={editId !== l.id}
ondragstart={(e) => onLinkDragStart(e, l.id)}
ondragend={onLinkDragEnd}
ondragover={(e) => onLinkDragOver(e, section, l.id, 'y')}
ondrop={onLinkDrop}
>
{#if editing && editId === l.id}
{@render linkForm(l)}
{:else if editing}
<span class="grip" aria-hidden="true" title="Drag to move">⠿</span>
<span class="name">{l.title} <small class="dim">{host_(l.url)}</small></span>
{@const up = move(l.id, -1)}
{@const down = move(l.id, 1)}
<form method="POST" action="?/orderLinks" use:enhance class="inline">
<input type="hidden" name="order" value={up} />
<button class="ghost" aria-label="Move up" disabled={!up}>↑</button>
</form>
<form method="POST" action="?/orderLinks" use:enhance class="inline">
<input type="hidden" name="order" value={down} />
<button class="ghost" aria-label="Move down" disabled={!down}>↓</button>
</form>
<button class="ghost" onclick={() => (editId = l.id)}>edit</button>
<form method="POST" action="?/deleteLink" use:enhance class="inline">
<input type="hidden" name="id" value={l.id} />
<button class="ghost bad" aria-label="Delete {l.title}">✕</button>
</form>
{/if}
</li>
{/each}
</ul>
{/each}
{#if editing}
<h3>Add a link</h3>
{@render linkForm(null)}
{/if}
</section>
</div>
</div>
</div>
{#snippet meter(
label: string,
pct: number,
detail: string,
spark?: { values: number[]; color: string; fill?: boolean }[],
max?: number
)}
<div class="meter">
<span class="label">{label}</span>
<span class="bar"
><i
class={[pct >= 90 ? 'bad' : pct >= 75 ? 'warn' : 'ok']}
style:width="{Math.min(100, pct)}%"
></i></span
>
<span class="num">{detail}</span>
<span class="sparkcell"
>{#if spark && hist.length > 1}<Sparkline
series={spark}
{max}
label="{label}, last minute"
/>{/if}</span
>
</div>
{/snippet}
{#snippet flow(
label: string,
a: string,
b: string,
spark: { values: number[]; color: string; fill?: boolean }[],
colors: string[]
)}
<div class="meter">
<span class="label">{label}</span>
<span class="num flow"
><span style:--c={colors[0]}>{a}</span><span style:--c={colors[1]}>{b}</span></span
>
<span class="sparkcell"
>{#if hist.length > 1}<Sparkline series={spark} label="{label}, last minute" />{/if}</span
>
</div>
{/snippet}
{#snippet icon(url: string, title: string)}
{@const ic = linkIcon(url, title)}
{#if ic.kind === 'svg'}
<span class="ico" style:color={ic.color} aria-hidden="true"
><!-- eslint-disable-next-line svelte/no-at-html-tags -- trimmed simple-icons paths from link-icons.json -->
{@html `<svg viewBox="0 0 24 24">${ic.body}</svg>`}</span
>
{:else if ic.kind === 'img'}
<img class="ico" src={ic.src} alt="" width="16" height="16" loading="lazy" decoding="async" />
{:else}
<span class="ico letter" style:background={ic.color} aria-hidden="true">{ic.letter}</span>
{/if}
{/snippet}
{#snippet linkForm(l: { id: number; title: string; url: string; section: string } | null)}
<form
method="POST"
action="?/saveLink"
class="linkform"
use:enhance={() =>
async ({ result, update }) => {
await update({ reset: !l });
if (result.type === 'success') editId = null;
}}
>
{#if l}<input type="hidden" name="id" value={l.id} />{/if}
<input name="title" placeholder="Title" value={l?.title ?? ''} required maxlength="80" />
<input name="url" placeholder="https://…" value={l?.url ?? ''} required />
<input name="section" placeholder="section" value={l?.section ?? 'links'} maxlength="40" />
<button>{l ? 'save' : 'add'}</button>
{#if l}<button type="button" class="ghost" onclick={() => (editId = null)}>cancel</button>{/if}
</form>
{/snippet}
<style>
.dash {
--line: var(--site-border);
--ok: #3fb950;
--warn: #d29922;
--bad: #f85149;
max-width: 1600px;
margin: 0 auto;
padding: 12px 16px 24px;
font-size: 13px;
line-height: 1.35;
}
a {
color: inherit;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.top {
display: grid;
grid-template-columns: auto minmax(240px, 1fr) auto;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
.when b {
font-size: 22px;
font-variant-numeric: tabular-nums;
}
.when span {
color: var(--site-muted);
margin-left: 6px;
}
.search {
position: relative;
}
input:not([type='checkbox']) {
width: 100%;
background: var(--site-surface);
color: var(--site-text);
border: 1px solid var(--line);
border-radius: 6px;
padding: 6px 9px;
font: inherit;
}
input:focus {
outline: 1px solid var(--site-muted);
}
.search input {
padding: 8px 12px;
font-size: 14px;
}
.hits {
position: absolute;
z-index: 10;
left: 0;
right: 0;
top: 100%;
margin-top: 4px;
background: var(--site-surface);
border: 1px solid var(--line);
border-radius: 6px;
padding: 4px;
list-style: none;
}
.hits a {
display: flex;
justify-content: space-between;
padding: 5px 8px;
border-radius: 4px;
}
.hits .first a,
.hits a:hover {
background: var(--site-hover);
text-decoration: none;
}
.hits small,
.dim,
h2 small,
.sub,
.empty,
time {
color: var(--site-muted);
}
.chips {
display: flex;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
}
.chip {
border: 1px solid var(--line);
border-radius: 999px;
padding: 2px 9px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
/* Four fixed columns (tasks | notifications + timers | server + services | links), two
on laptops, one on phones: no gaps from uneven card heights. */
.grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
align-items: start;
}
.col {
display: grid;
gap: 12px;
min-width: 0;
}
@media (max-width: 1280px) {
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.card {
background: var(--site-surface);
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px 12px;
min-width: 0;
}
h2 {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
margin: 0 0 8px;
}
h2 small {
font-weight: 400;
text-transform: none;
letter-spacing: 0;
margin-left: auto;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
h3 {
font-size: 11px;
text-transform: uppercase;
color: var(--site-muted);
margin: 10px 0 4px;
letter-spacing: 0.04em;
}
ul {
list-style: none;
margin: 0;
padding: 0;
}
.list li {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 0;
border-bottom: 1px solid var(--line);
min-width: 0;
}
.list li:last-child {
border-bottom: 0;
}
.list .title,
.list .name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.add {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 6px;
}
.add label {
color: var(--site-muted);
white-space: nowrap;
font-size: 12px;
}
.check {
width: 14px;
height: 14px;
border: 1.5px solid var(--site-muted);
border-radius: 50%;
background: none;
cursor: pointer;
padding: 0;
display: block;
}
.check:hover {
border-color: var(--ok);
background: color-mix(in srgb, var(--ok) 30%, transparent);
}
.flagged .title::before {
content: '⚑ ';
color: var(--warn);
}
.tag {
font-size: 11px;
border: 1px solid var(--line);
border-radius: 4px;
padding: 0 5px;
color: var(--site-muted);
white-space: nowrap;
}
.pri {
color: var(--warn);
font-weight: 700;
}
.pri.p3,
.bad,
.tag.bad {
color: var(--bad);
}
.warn,
.tag.warn {
color: var(--warn);
}
.notes {
max-height: 420px;
overflow-y: auto;
}
.note {
flex-direction: column;
align-items: stretch !important;
}
.note .meta {
display: flex;
justify-content: space-between;
}
.note .body {
margin: 2px 0 0;
white-space: pre-line;
overflow-wrap: anywhere;
}
.note.hi .body b {
color: var(--bad);
}
.meters {
display: grid;
/* one grid for all rows (subgrid), so bars, numbers and sparklines line up */
grid-template-columns: 56px minmax(40px, 1fr) auto 72px;
gap: 5px 8px;
align-items: center;
}
.meter {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
min-height: 20px;
}
.meter .label {
white-space: nowrap;
}
.meter .flow {
grid-column: 2 / 4;
display: flex;
justify-content: flex-end;
gap: 12px;
}
.meter .flow > span {
min-width: 11ch;
text-align: right;
}
.meter .flow > span::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
margin-right: 5px;
border-radius: 50%;
background: var(--c);
vertical-align: 1px;
}
.sparkcell {
height: 20px;
}
.sparkcell:has(:global(svg)) {
border-bottom: 1px solid var(--line);
}
.live {
color: var(--ok);
font-weight: 600;
}
.live i {
display: inline-block;
width: 6px;
height: 6px;
margin-right: 4px;
border-radius: 50%;
background: var(--ok);
vertical-align: 1px;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
50% {
opacity: 0.35;
}
}
@media (prefers-reduced-motion: reduce) {
.live i {
animation: none;
}
}
.bar {
height: 7px;
background: var(--site-hover);
border-radius: 4px;
overflow: hidden;
}
.bar i {
display: block;
height: 100%;
transition:
width 450ms ease,
background 300ms ease;
}
.bar .ok {
background: var(--ok);
}
.bar .warn {
background: var(--warn);
}
.bar .bad {
background: var(--bad);
}
.num {
font-variant-numeric: tabular-nums;
color: var(--site-muted);
white-space: nowrap;
font-size: 12px;
}
.sub {
margin: 6px 0;
font-size: 12px;
}
.containers {
display: grid;
gap: 0;
}
.stacks {
display: grid;
gap: 2px;
}
.stack.solo {
display: flex;
align-items: center;
gap: 5px;
/* line up with group headers, whose chevron sits where this padding is */
padding: 3px 6px 3px 18px;
border-radius: 6px;
border: 1px solid transparent;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--site-muted);
transition:
background 120ms ease,
border-color 120ms ease,
color 120ms ease;
}
.stack.solo:hover {
background: rgba(255, 255, 255, 0.04);
border-color: var(--line);
color: var(--site-text);
}
.stack.solo .dot {
margin-right: 0;
}
.stack.solo small {
opacity: 0.7;
text-transform: none;
}
.stack.solo .num {
font-size: 11px;
text-transform: none;
letter-spacing: 0;
}
.group.stack > summary {
align-items: center;
gap: 5px;
margin: 0;
padding: 3px 6px;
border-radius: 6px;
border: 1px solid transparent;
transition:
background 120ms ease,
border-color 120ms ease;
}
.group.stack > summary:hover {
background: rgba(255, 255, 255, 0.04);
border-color: var(--line);
}
.group.stack > summary .dot {
margin-right: 0;
}
.stack.solo .sname,
.group.stack .sname {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group.stack > summary small.bad {
color: var(--bad);
opacity: 1;
}
.group.stack > summary .num {
font-size: 11px;
text-transform: none;
letter-spacing: 0;
}
.group.stack .containers {
margin: 0 0 4px 18px;
padding-left: 8px;
border-left: 1px solid var(--line);
}
.containers li {
display: flex;
align-items: center;
gap: 5px;
padding: 1px 0;
min-width: 0;
}
.containers .name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.containers .num {
font-size: 11px;
}
/* fixed-width numbers: values change every second without moving the row */
.stacks .num.cpu {
min-width: 6ch;
text-align: right;
}
.stacks .num.mem {
min-width: 7.5ch;
text-align: right;
}
.dot {
display: inline-block;
flex: none;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--site-muted);
margin-right: 4px;
}
.dot.ok {
background: var(--ok);
}
.dot.warn {
background: var(--warn);
}
.dot.bad {
background: var(--bad);
}
.dot.off {
background: #555;
}
.tiles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 4px;
}
.tiles a {
display: flex;
align-items: center;
padding: 5px 8px;
border: 1px solid var(--line);
border-radius: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tiles a:hover {
background: var(--site-hover);
text-decoration: none;
}
/* Icon tiles (links + services): brand-coloured mark on the black/white card, lift on hover. */
.tiles a:has(.ico) {
gap: 7px;
transition:
transform 120ms ease,
border-color 120ms ease,
background 120ms ease;
}
.tiles a:has(.ico):hover {
transform: translateY(-1px);
border-color: var(--site-muted);
}
.links h2 .count {
margin-left: 0;
flex: none;
}
.links .filter {
flex: 1;
min-width: 0;
margin-left: auto;
font: inherit;
font-size: 12px;
text-transform: none;
letter-spacing: 0;
font-weight: 400;
color: var(--site-text);
background: var(--site-bg, transparent);
border: 1px solid var(--line);
border-radius: 6px;
padding: 2px 8px;
}
.links .filter:focus {
outline: none;
border-color: var(--site-muted);
}
.group > summary {
display: flex;
align-items: baseline;
gap: 6px;
cursor: pointer;
list-style: none;
user-select: none;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--site-muted);
margin: 10px 0 4px;
}
.group > summary::-webkit-details-marker {
display: none;
}
.group > summary::before {
content: '▸';
display: inline-block;
transition: transform 120ms ease;
}
.group[open] > summary::before {
transform: rotate(90deg);
}
.group > summary:hover {
color: var(--site-text);
}
.group > summary small {
font-variant-numeric: tabular-nums;
opacity: 0.7;
}
.tiles.icons {
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
}
.tiles .label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.tiles a .dot {
margin: 0 0 0 auto;
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(140px, 1fr));
grid-auto-flow: row dense;
gap: 6px;
}
.tgrid > li.group {
grid-column: span 4;
}
.tgrid > li.group.wide {
grid-column: span 6;
}
.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;
}
.tlabel {
flex-basis: 100%;
display: flex;
align-items: baseline;
gap: 0.35rem;
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--site-muted);
background: none;
border: 0;
padding: 0;
text-align: left;
}
.tlabel span {
letter-spacing: 0;
opacity: 0.7;
}
.tlabel:not(:first-child) {
margin-top: 0.3rem;
}
button.tlabel {
cursor: pointer;
}
button.tlabel:hover {
color: inherit;
}
.tchip.inactive :global(.t-icon) {
filter: grayscale(0.6);
opacity: 0.75;
}
.tchips {
position: relative;
z-index: 1;
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-left: auto;
min-width: 0;
}
.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;
}
button.tchip {
padding: 2px 9px;
color: var(--site-muted);
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 500;
}
button.tchip:hover {
color: var(--site-text);
}
.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: 1000px) {
.tgrid > li.group.wide {
grid-column: 1 / -1;
}
div.ttile {
flex-wrap: wrap;
}
}
@media (max-width: 760px) {
.tgrid > li.group {
grid-column: 1 / -1;
}
div.ttile {
flex-wrap: wrap;
}
.tchips {
margin-left: 0;
}
}
.ico {
flex: none;
width: 16px;
height: 16px;
display: inline-grid;
place-items: center;
border-radius: 4px;
object-fit: contain;
}
.ico :global(svg) {
width: 15px;
height: 15px;
display: block;
}
.ico.letter {
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 1;
}
button {
font: inherit;
color: var(--site-text);
background: var(--site-hover);
border: 1px solid var(--line);
border-radius: 6px;
padding: 4px 10px;
cursor: pointer;
}
button.ghost {
background: none;
border: 0;
padding: 0 4px;
color: var(--site-muted);
}
button.ghost:hover {
color: var(--site-text);
}
h2 button.ghost {
margin-left: auto;
text-transform: none;
font-weight: 400;
}
/* Drag-and-drop of links */
.dragging {
opacity: 0.4;
}
.tiles li.drop-before {
box-shadow: -3px 0 0 0 var(--site-accent, #58a6ff);
}
.list li.drop-before-y {
box-shadow: 0 -2px 0 0 var(--site-accent, #58a6ff);
}
.group.drop-end > summary,
.list.drop-end {
outline: 1px dashed var(--site-accent, #58a6ff);
outline-offset: 2px;
}
.grip {
cursor: grab;
opacity: 0.5;
user-select: none;
}
.inline {
display: contents;
}
.linkform {
display: grid;
grid-template-columns: 1fr 1.6fr 0.8fr auto auto;
gap: 4px;
width: 100%;
}
@media (max-width: 760px) {
.top {
grid-template-columns: 1fr;
}
.chips {
justify-content: flex-start;
}
.grid {
grid-template-columns: 1fr;
}
.linkform {
grid-template-columns: 1fr 1fr;
}
}
</style>