All checks were successful
Deploy production / deploy (push) Successful in 1m11s
1224 lines
31 KiB
Svelte
1224 lines
31 KiB
Svelte
<script lang="ts">
|
|
import { enhance } from '$app/forms';
|
|
import { invalidate } from '$app/navigation';
|
|
import { ago, bytes, duration, timerStatus, type Container } from '#lib/home.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();
|
|
|
|
// 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));
|
|
// 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 host = $derived(data.host);
|
|
const stale = $derived(!host || now - host.at > 3 * 60_000);
|
|
const containers = $derived(
|
|
(host?.containers ?? []).filter((c) => !c.name.startsWith('GITEA-ACTIONS-'))
|
|
);
|
|
const containersUp = $derived(containers.filter((c) => c.state === 'running').length);
|
|
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 ? (100 * host.mem.used) / host.mem.total : 0);
|
|
const swapPct = $derived(host?.swap.total ? (100 * host.swap.used) / host.swap.total : 0);
|
|
|
|
const sections = $derived.by(() => {
|
|
const groups: [string, typeof data.links][] = [];
|
|
for (const l of data.links) {
|
|
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() !== '');
|
|
// 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 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' }))
|
|
]);
|
|
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 = data.links.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(',');
|
|
}
|
|
|
|
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', host.cpuPct > 85 && 'warn']}>CPU {Math.round(host.cpuPct)}%</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}
|
|
{#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 }}>
|
|
{#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="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 }}
|
|
>{host ? `${host.hostname} · ${ago(host.at, now)}` : 'no snapshot yet'}{stale && host
|
|
? ' (stale)'
|
|
: ''}</small
|
|
>
|
|
</h2>
|
|
{#if host}
|
|
<div class="meters">
|
|
{@render meter('CPU', host.cpuPct, `${Math.round(host.cpuPct)}% · ${host.cpus} cores`)}
|
|
{@render meter('RAM', memPct, `${bytes(host.mem.used)} / ${bytes(host.mem.total)}`)}
|
|
{@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 {host.load.join(' / ')} · up {duration(host.uptimeS)} · {containersUp}/{containers.length}
|
|
containers
|
|
</p>
|
|
<ul class="containers">
|
|
{#each containers as c (c.name)}
|
|
<li title="{c.image} · {c.state}{c.health ? ` (${c.health})` : ''}">
|
|
<i class="dot {containerDot(c)}"></i>
|
|
<span class="name">{shortName(c.name)}</span>
|
|
{#if c.restarts}<span class="tag warn">↻{c.restarts}</span>{/if}
|
|
<span class="num">{c.cpu ?? ''}</span>
|
|
<span class="num">{c.mem ?? ''}</span>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{: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">{data.links.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 !editing}
|
|
{#each shownSections as [section, links] (section)}
|
|
{@const open = filtering || isOpen(section)}
|
|
<details
|
|
class="group"
|
|
{open}
|
|
ontoggle={(e) => toggleGroup(section, e.currentTarget.open)}
|
|
>
|
|
<summary><span>{section}</span><small>{links.length}</small></summary>
|
|
{#if open}
|
|
<ul class="tiles icons">
|
|
{#each links as l (l.id)}
|
|
<li>
|
|
<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">
|
|
{#each links as l (l.id)}
|
|
<li>
|
|
{#if editing && editId === l.id}
|
|
{@render linkForm(l)}
|
|
{:else if editing}
|
|
<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)}
|
|
<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>
|
|
</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;
|
|
gap: 5px;
|
|
}
|
|
.meter {
|
|
display: grid;
|
|
grid-template-columns: 56px 1fr auto;
|
|
gap: 8px;
|
|
align-items: center;
|
|
}
|
|
.bar {
|
|
height: 7px;
|
|
background: var(--site-hover);
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
}
|
|
.bar i {
|
|
display: block;
|
|
height: 100%;
|
|
}
|
|
.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;
|
|
}
|
|
.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;
|
|
}
|
|
.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(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 {
|
|
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;
|
|
}
|
|
.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>
|