start: private new-tab dashboard at /start (tasks, notifications, backups/timers, server + containers, service status, trackers, editable links, / search)
All checks were successful
Deploy production / deploy (push) Successful in 2m39s

- home schema: home.links (seeded, owner-editable) and home.snapshots (written by a host collector)
- deploy/home-collector: stdlib Python + systemd user timer (60s) that snapshots /proc stats,
  docker containers, systemd timers, service HTTP checks and ntfy topics via a limited role
- extras/newtab-extension: MV3 new-tab override, zipped to static/newtab-extension.zip
- owner-only pages skip Google Analytics; /trackers/health#body anchor for weigh-ins
This commit is contained in:
2026-10-24 15:21:29 +00:00
parent 61ae72e50b
commit 2bd668bf5e
27 changed files with 18942 additions and 4 deletions

View File

@@ -31,7 +31,10 @@
let { data, children } = $props();
const seo = $derived(getSeo(page.url.pathname, page.data, data.environment));
// The book reader is a full-screen view; the site header and footer would sit on top of it.
const immersive = $derived(/^\/trackers\/books\/\d+\/read\/?$/.test(page.url.pathname));
const immersive = $derived(
/^\/trackers\/books\/\d+\/read\/?$/.test(page.url.pathname) ||
/^\/start\/?$/.test(page.url.pathname)
);
let menuOpen = $state(false);
// Which header dropdown is open (desktop): '/projects', '/trackers' or none.
let openMenu = $state<string | null>(null);

View File

@@ -0,0 +1,189 @@
import { db } from '#lib/server/db/index.js';
import {
healthBodyMeasurements,
homeLinks,
homeSnapshots,
todoActivity,
todoTasks
} from '#lib/server/db/schema/index.js';
import { isOwner, requireOwner } from '#lib/server/owner.js';
import { parseQuickTitle, statusTimestamps, todayIn } from '#lib/server/tasks.js';
import { linkInput, type HostSnapshot, type NtfySnapshot } from '#lib/home.js';
import { and, asc, desc, eq, inArray, isNotNull, lte, or, sql } from 'drizzle-orm';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
// /start: the owner's dashboard and browser new-tab page. Everything comes from Postgres in
// one round of parallel queries (server stats and ntfy messages are written there by the
// host-side collector, deploy/home-collector), so no slow upstream can block the page.
const OPEN = ['todo', 'in_progress'] as const;
export const load: PageServerLoad = async (event) => {
// Logged out: go log in and come back (it's a new-tab page). Logged in as anyone else: 404.
if (!event.locals.user) redirect(303, '/login?returnTo=/start');
requireOwner(event);
event.setHeaders({ 'cache-control': 'private, no-store' });
event.depends('home:live');
const today = todayIn();
const [tasks, openCount, links, snapshots, weight] = await Promise.all([
db
.select({
id: todoTasks.id,
title: todoTasks.title,
status: todoTasks.status,
priority: todoTasks.priority,
flagged: todoTasks.flagged,
dueDate: todoTasks.dueDate,
dueTime: todoTasks.dueTime
})
.from(todoTasks)
.where(
and(
inArray(todoTasks.status, OPEN),
sql`${todoTasks.parentId} IS NULL`,
or(
lte(todoTasks.dueDate, today),
eq(todoTasks.flagged, true),
eq(todoTasks.status, 'in_progress')
)
)
)
.orderBy(
sql`${todoTasks.dueDate} IS NULL`,
asc(todoTasks.dueDate),
desc(todoTasks.priority),
asc(todoTasks.sortOrder),
asc(todoTasks.id)
)
.limit(40),
db
.select({ n: sql<number>`count(*)::int` })
.from(todoTasks)
.where(inArray(todoTasks.status, OPEN)),
db
.select({
id: homeLinks.id,
title: homeLinks.title,
url: homeLinks.url,
section: homeLinks.section
})
.from(homeLinks)
.orderBy(asc(homeLinks.sortOrder), asc(homeLinks.id)),
db.select().from(homeSnapshots),
db
.select({ date: healthBodyMeasurements.date, kg: healthBodyMeasurements.weightKg })
.from(healthBodyMeasurements)
.where(isNotNull(healthBodyMeasurements.weightKg))
.orderBy(desc(healthBodyMeasurements.date), desc(healthBodyMeasurements.id))
.limit(1)
]);
const snap = (key: string) => snapshots.find((s) => s.key === key);
const host = snap('host');
const ntfy = snap('ntfy');
return {
today,
now: Date.now(),
tasks,
openCount: openCount[0]?.n ?? 0,
links,
host: host ? { at: host.collectedAt.getTime(), ...(host.data as HostSnapshot) } : null,
ntfy: ntfy ? { at: ntfy.collectedAt.getTime(), ...(ntfy.data as NtfySnapshot) } : null,
weight: weight[0] ?? null,
seo: { title: 'Start', description: 'Private start page.', index: false }
};
};
const intId = (v: FormDataEntryValue | null) => {
const n = Number(v);
return Number.isInteger(n) && n > 0 ? n : null;
};
function owner(event: Parameters<typeof requireOwner>[0]) {
if (!isOwner(event)) return false;
return true;
}
export const actions: Actions = {
/** One-click complete (or reopen with status=todo). */
task: async (event) => {
if (!owner(event)) return fail(404, { message: 'Not Found' });
const form = await event.request.formData();
const id = intId(form.get('id'));
const status = form.get('status') === 'todo' ? 'todo' : 'done';
if (!id) return fail(400, { message: 'Invalid task' });
const [prev] = await db.select().from(todoTasks).where(eq(todoTasks.id, id));
if (!prev) return fail(404, { message: 'Task not found' });
if (prev.status === status) return { ok: true };
await db.transaction(async (tx) => {
await tx
.update(todoTasks)
.set({ status, ...statusTimestamps(status, prev) })
.where(eq(todoTasks.id, id));
await tx
.insert(todoActivity)
.values({ taskId: id, kind: 'status', fromStatus: prev.status, toStatus: status });
});
return { ok: true };
},
/** Quick add: "!"/"!!"/"!!!" set flag/priority (as on /trackers/tasks); due today by default. */
addTask: async (event) => {
if (!owner(event)) return fail(404, { message: 'Not Found' });
const form = await event.request.formData();
const { title, flagged, priority } = parseQuickTitle(String(form.get('title') ?? ''));
if (!title || title.length > 500) return fail(400, { message: 'Give the task a title' });
const due = form.get('someday') === 'on' ? null : todayIn();
const [row] = await db
.insert(todoTasks)
.values({ title, flagged, priority, dueDate: due, source: 'manual' })
.returning({ id: todoTasks.id });
await db.insert(todoActivity).values({ taskId: row.id, kind: 'created', toStatus: 'todo' });
return { ok: true };
},
saveLink: async (event) => {
if (!owner(event)) return fail(404, { message: 'Not Found' });
const form = await event.request.formData();
const parsed = linkInput.safeParse(Object.fromEntries(form));
if (!parsed.success)
return fail(400, { message: parsed.error.issues[0]?.message ?? 'Invalid link' });
const id = intId(form.get('id'));
if (id) {
await db.update(homeLinks).set(parsed.data).where(eq(homeLinks.id, id));
} else {
const [m] = await db
.select({ max: sql<number>`coalesce(max(${homeLinks.sortOrder}), 0)::int` })
.from(homeLinks);
await db.insert(homeLinks).values({ ...parsed.data, sortOrder: (m?.max ?? 0) + 10 });
}
return { ok: true };
},
deleteLink: async (event) => {
if (!owner(event)) return fail(404, { message: 'Not Found' });
const id = intId((await event.request.formData()).get('id'));
if (!id) return fail(400, { message: 'Invalid link' });
await db.delete(homeLinks).where(eq(homeLinks.id, id));
return { ok: true };
},
/** Reorder: `order` is the full list of link ids in their new order. */
orderLinks: async (event) => {
if (!owner(event)) return fail(404, { message: 'Not Found' });
const raw = String((await event.request.formData()).get('order') ?? '');
const ids = raw
.split(',')
.map(Number)
.filter((n) => Number.isInteger(n) && n > 0);
if (!ids.length || ids.length > 500) return fail(400, { message: 'Invalid order' });
await db.transaction(async (tx) => {
for (const [i, id] of ids.entries())
await tx
.update(homeLinks)
.set({ sortOrder: (i + 1) * 10 })
.where(eq(homeLinks.id, id));
});
return { ok: true };
}
};

View File

@@ -0,0 +1,846 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { invalidate } from '$app/navigation';
import { ago, bytes, duration, timerStatus, type Container } from '#lib/home.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>();
const trackers = [
{ name: 'Tasks', href: '/trackers/tasks' },
{ name: 'Money', href: '/trackers/money' },
{ name: 'Health', href: '/trackers/health' },
{ 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' }
];
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;
});
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.map((t) => ({ name: t.name, href: t.href, 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();
}
}
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>
<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-vm.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` : ''}"
>
<i class="dot {s.ok === false ? 'bad' : s.ok ? 'ok' : 'off'}"></i>{s.name}
</a>
</li>
{/each}
</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>
</div>
<div class="col">
<!-- Personal links -->
<section class="card links">
<h2>
Links
<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}
{#each sections as [section, links] (section)}
{#if sections.length > 1}<h3>{section}</h3>{/if}
<ul class={editing ? 'list' : 'tiles'}>
{#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>
{:else}
<a href={l.url} title={l.url}>{l.title}</a>
{/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 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;
}
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>

View File

@@ -187,7 +187,7 @@
</div>
<div class="grid gap-4 lg:grid-cols-3">
<section class="panel lg:col-span-2">
<section id="body" class="panel lg:col-span-2">
{@render sectionHeader('Body', 'body_measurements')}
<form method="POST" action="?/save" use:enhance={quickSubmit} class="quick mb-4 grid gap-2">
<input type="hidden" name="table" value="body_measurements" />