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
599 lines
20 KiB
Svelte
599 lines
20 KiB
Svelte
<script lang="ts">
|
||
import { enhance } from '$app/forms';
|
||
import BodyChart from '#lib/health/BodyChart.svelte';
|
||
import {
|
||
healthTables,
|
||
healthTableNames,
|
||
scaleFields,
|
||
type Field,
|
||
type HealthTable
|
||
} from '#lib/health/tables.js';
|
||
import { Button } from '#lib/components/ui/button/index.js';
|
||
import { Input } from '#lib/components/ui/input/index.js';
|
||
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
||
import { Textarea } from '#lib/components/ui/textarea/index.js';
|
||
import * as Dialog from '#lib/components/ui/dialog/index.js';
|
||
import Plus from 'lucide-svelte/icons/plus';
|
||
import Pencil from 'lucide-svelte/icons/pencil';
|
||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||
import Lock from 'lucide-svelte/icons/lock';
|
||
import type { SubmitFunction } from '@sveltejs/kit';
|
||
import type { PageProps } from './$types';
|
||
|
||
let { data, form }: PageProps = $props();
|
||
|
||
type AnyRow = { id: string } & Record<string, unknown>;
|
||
let dialogOpen = $state(false);
|
||
let editor = $state<{ table: HealthTable; row: AnyRow | null; confirmDelete: boolean }>({
|
||
table: 'body_measurements',
|
||
row: null,
|
||
confirmDelete: false
|
||
});
|
||
let error = $state<string | null>(null);
|
||
|
||
const fields = $derived<readonly Field[]>(healthTables[editor.table].fields);
|
||
const latest = $derived(data.measurements.at(-1));
|
||
const latestWeight = $derived(data.measurements.findLast((m) => m.weightKg !== null));
|
||
const latestWaist = $derived(data.measurements.findLast((m) => m.waistCm !== null));
|
||
const todayNutrition = $derived(data.nutrition.find((n) => n.date === data.today));
|
||
const activeMeds = $derived(
|
||
data.medications.filter(
|
||
(m) =>
|
||
(!m.startDate || m.startDate <= data.today) && (!m.stopDate || m.stopDate >= data.today)
|
||
)
|
||
);
|
||
const otherMeds = $derived(data.medications.filter((m) => !activeMeds.includes(m)));
|
||
const sleepAverage = $derived.by(() => {
|
||
const hours = data.sleep.map((s) => s.hours).filter((h): h is number => h !== null);
|
||
return hours.length ? hours.reduce((a, b) => a + b, 0) / hours.length : null;
|
||
});
|
||
const workoutDays = $derived.by(() => {
|
||
const days: [string, typeof data.workouts][] = [];
|
||
for (const w of data.workouts) {
|
||
const last = days.at(-1);
|
||
if (last?.[0] === w.date) last[1].push(w);
|
||
else days.push([w.date, [w]]);
|
||
}
|
||
return days;
|
||
});
|
||
|
||
const camel = (key: string) => key.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
|
||
const localTime = new Intl.DateTimeFormat('sv-SE', {
|
||
timeZone: 'Europe/Amsterdam',
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
const clock = new Intl.DateTimeFormat('en-GB', {
|
||
timeZone: 'Europe/Amsterdam',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
const dayLabel = (day: string) =>
|
||
new Date(`${day}T12:00:00Z`).toLocaleDateString('en-GB', {
|
||
weekday: 'short',
|
||
day: 'numeric',
|
||
month: 'short',
|
||
year: day.slice(0, 4) === data.today.slice(0, 4) ? undefined : 'numeric'
|
||
});
|
||
const num = (value: number | null | undefined, digits = 1) =>
|
||
value === null || value === undefined ? '–' : value.toFixed(digits).replace(/\.0+$/, '');
|
||
|
||
function fieldValue(field: Field): string {
|
||
const value = editor.row?.[camel(field.key)];
|
||
if (value === null || value === undefined) {
|
||
if (!editor.row && field.type === 'date' && field.key === 'date') return data.today;
|
||
return '';
|
||
}
|
||
if (value instanceof Date) return localTime.format(value).replace(' ', 'T');
|
||
return String(value);
|
||
}
|
||
|
||
function open(table: HealthTable, row: AnyRow | null = null, confirmDelete = false) {
|
||
editor = { table, row, confirmDelete };
|
||
error = null;
|
||
dialogOpen = true;
|
||
}
|
||
|
||
// Quick-add for a scale reading: same ?/save action and dedupe as imports.
|
||
const nowLocal = () => localTime.format(new Date()).replace(' ', 'T');
|
||
let quickNow = $state(nowLocal());
|
||
let quickMessage = $state<{ text: string; error: boolean } | null>(null);
|
||
const quickSubmit: SubmitFunction =
|
||
() =>
|
||
async ({ result, update }) => {
|
||
if (result.type === 'success') {
|
||
quickNow = nowLocal();
|
||
await update();
|
||
quickMessage = {
|
||
text: result.data?.unchanged ? 'Already logged, nothing changed.' : 'Saved.',
|
||
error: false
|
||
};
|
||
} else {
|
||
await update({ reset: false });
|
||
quickMessage = {
|
||
text: String((result.type === 'failure' && result.data?.message) || 'Could not save'),
|
||
error: true
|
||
};
|
||
}
|
||
};
|
||
const scaleSummary = (m: (typeof data.measurements)[number]) =>
|
||
[
|
||
m.muscleMassKg !== null ? `muscle ${num(m.muscleMassKg, 2)} kg` : '',
|
||
m.visceralFat !== null ? `visceral ${num(m.visceralFat)}` : '',
|
||
m.bodyWaterPct !== null ? `water ${num(m.bodyWaterPct)}%` : '',
|
||
m.bmi !== null ? `BMI ${num(m.bmi)}` : ''
|
||
]
|
||
.filter(Boolean)
|
||
.join(' · ');
|
||
|
||
const submit: SubmitFunction =
|
||
() =>
|
||
async ({ result, update }) => {
|
||
await update();
|
||
if (result.type === 'success') dialogOpen = false;
|
||
else error = String((result.type === 'failure' && result.data?.message) || 'Could not save');
|
||
};
|
||
</script>
|
||
|
||
{#snippet rowActions(table: HealthTable, row: AnyRow)}
|
||
<span class="flex shrink-0 gap-1">
|
||
<button class="icon" type="button" aria-label="Edit" onclick={() => open(table, row)}
|
||
><Pencil size={14} /></button
|
||
>
|
||
<button class="icon" type="button" aria-label="Delete" onclick={() => open(table, row, true)}
|
||
><Trash2 size={14} /></button
|
||
>
|
||
</span>
|
||
{/snippet}
|
||
|
||
{#snippet sectionHeader(title: string, table: HealthTable)}
|
||
<header class="mb-3 flex items-center justify-between gap-2">
|
||
<h2 class="text-lg font-semibold tracking-tight">{title}</h2>
|
||
<button class="icon" type="button" aria-label={`Add to ${title}`} onclick={() => open(table)}
|
||
><Plus size={16} /></button
|
||
>
|
||
</header>
|
||
{/snippet}
|
||
|
||
{#snippet empty(text: string)}
|
||
<p class="text-sm text-[var(--site-muted)]">{text}</p>
|
||
{/snippet}
|
||
|
||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||
<div class="mb-8 flex flex-wrap items-end justify-between gap-4">
|
||
<div>
|
||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">health</h1>
|
||
<p class="mt-2 flex items-center gap-1.5 text-sm text-[var(--site-muted)]">
|
||
<Lock size={14} aria-hidden="true" /> Private. Only you can see this page.
|
||
</p>
|
||
</div>
|
||
<NativeSelect
|
||
aria-label="Add an entry"
|
||
value=""
|
||
onchange={(e: Event) => {
|
||
const select = e.currentTarget as HTMLSelectElement;
|
||
if (select.value) open(select.value as HealthTable);
|
||
select.value = '';
|
||
}}
|
||
>
|
||
<option value="">+ add entry…</option>
|
||
{#each healthTableNames as table (table)}<option value={table}
|
||
>{healthTables[table].label}</option
|
||
>{/each}
|
||
</NativeSelect>
|
||
</div>
|
||
|
||
<div class="grid gap-4 lg:grid-cols-3">
|
||
<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" />
|
||
<div class="flex flex-wrap items-end gap-2">
|
||
<label class="grid gap-1 text-xs text-[var(--site-muted)]">
|
||
When
|
||
<Input
|
||
type="datetime-local"
|
||
name="measured_at"
|
||
value={quickNow}
|
||
required
|
||
class="w-52"
|
||
/>
|
||
</label>
|
||
<label class="grid gap-1 text-xs text-[var(--site-muted)]">
|
||
Weight (kg)
|
||
<Input
|
||
type="number"
|
||
name="weight_kg"
|
||
min={20}
|
||
max={400}
|
||
step={0.01}
|
||
inputmode="decimal"
|
||
class="w-24"
|
||
/>
|
||
</label>
|
||
<label class="grid gap-1 text-xs text-[var(--site-muted)]">
|
||
Body fat (%)
|
||
<Input
|
||
type="number"
|
||
name="body_fat_pct"
|
||
min={1}
|
||
max={75}
|
||
step={0.1}
|
||
inputmode="decimal"
|
||
class="w-24"
|
||
/>
|
||
</label>
|
||
<label class="grid gap-1 text-xs text-[var(--site-muted)]">
|
||
Source
|
||
<Input name="source" value="Renpho" maxlength={100} class="w-28" />
|
||
</label>
|
||
<Button type="submit">Add</Button>
|
||
</div>
|
||
<details class="text-sm">
|
||
<summary class="cursor-pointer text-[var(--site-muted)]">More readings & notes</summary>
|
||
<div class="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||
{#each scaleFields as field (field.key)}
|
||
<label class="grid gap-1 text-xs text-[var(--site-muted)]">
|
||
{field.label}
|
||
<Input
|
||
type="number"
|
||
name={field.key}
|
||
min={field.min}
|
||
max={field.max}
|
||
step={field.type === 'integer' ? 1 : field.step}
|
||
inputmode="decimal"
|
||
/>
|
||
</label>
|
||
{/each}
|
||
</div>
|
||
<label class="mt-2 grid gap-1 text-xs text-[var(--site-muted)]">
|
||
Notes
|
||
<Textarea name="notes" rows={2} maxlength={4000} />
|
||
</label>
|
||
</details>
|
||
{#if quickMessage}
|
||
<p
|
||
class={['text-sm', quickMessage.error ? 'text-red-500' : 'text-[var(--site-muted)]']}
|
||
role={quickMessage.error ? 'alert' : 'status'}
|
||
>
|
||
{quickMessage.text}
|
||
</p>
|
||
{/if}
|
||
</form>
|
||
{#if latest}
|
||
<div class="mb-3 flex flex-wrap gap-6">
|
||
<p>
|
||
<span class="stat">{num(latestWeight?.weightKg, 2)}</span> kg
|
||
<small>{latestWeight ? dayLabel(latestWeight.date) : ''}</small>
|
||
</p>
|
||
<p>
|
||
<span class="stat">{num(latestWaist?.waistCm)}</span> cm waist
|
||
<small>{latestWaist ? dayLabel(latestWaist.date) : ''}</small>
|
||
</p>
|
||
{#if latest.bodyFatPct !== null}<p>
|
||
<span class="stat">{num(latest.bodyFatPct)}</span>% fat
|
||
</p>{/if}
|
||
{#if latest.muscleMassKg !== null}<p>
|
||
<span class="stat">{num(latest.muscleMassKg, 1)}</span> kg muscle
|
||
</p>{/if}
|
||
</div>
|
||
{#if data.measurements.length > 1}
|
||
<BodyChart points={data.measurements} />
|
||
{:else}
|
||
{@render empty('Add a second measurement to see a trend.')}
|
||
{/if}
|
||
<details class="mt-3 text-sm">
|
||
<summary class="cursor-pointer text-[var(--site-muted)]"
|
||
>All measurements ({data.measurements.length})</summary
|
||
>
|
||
<ul class="mt-2 divide-y divide-[var(--site-border)]">
|
||
{#each data.measurements.toReversed() as m (m.id)}
|
||
<li class="flex items-center justify-between py-1.5">
|
||
<span
|
||
>{dayLabel(m.date)} · {num(m.weightKg, 2)} kg · {num(m.waistCm)} cm{m.bodyFatPct !==
|
||
null
|
||
? ` · ${num(m.bodyFatPct)}%`
|
||
: ''}{scaleSummary(m) ? ` · ${scaleSummary(m)}` : ''}{m.source
|
||
? ` · ${m.source}`
|
||
: ''}{m.notes ? ` · ${m.notes}` : ''}</span
|
||
>
|
||
{@render rowActions('body_measurements', m)}
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
</details>
|
||
{:else}
|
||
{@render empty('No measurements yet.')}
|
||
{/if}
|
||
</section>
|
||
|
||
<section class="panel">
|
||
{@render sectionHeader('Nutrition today', 'nutrition_daily')}
|
||
{#if todayNutrition}
|
||
<p>
|
||
<span class="stat">{todayNutrition.kcal ?? '–'}</span> kcal ·
|
||
<span class="stat">{num(todayNutrition.proteinG)}</span> g protein
|
||
</p>
|
||
{:else}
|
||
{@render empty('Nothing logged today.')}
|
||
{/if}
|
||
{#if data.nutrition.length}
|
||
<ul class="mt-3 divide-y divide-[var(--site-border)] text-sm">
|
||
{#each data.nutrition as n (n.id)}
|
||
<li class="flex items-center justify-between py-1.5">
|
||
<span>{dayLabel(n.date)} · {n.kcal ?? '–'} kcal · {num(n.proteinG)} g</span>
|
||
{@render rowActions('nutrition_daily', n)}
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
</section>
|
||
|
||
<section class="panel">
|
||
{@render sectionHeader('Sleep, last 7 days', 'sleep')}
|
||
{#if data.sleep.length}
|
||
{#if sleepAverage !== null}<p class="mb-2">
|
||
<span class="stat">{num(sleepAverage, 1)}</span> h average
|
||
</p>{/if}
|
||
<ul class="divide-y divide-[var(--site-border)] text-sm">
|
||
{#each data.sleep as s (s.id)}
|
||
<li class="flex items-center justify-between py-1.5">
|
||
<span
|
||
>{dayLabel(s.date)} · {clock.format(s.bedtime)}–{clock.format(s.wakeTime)} · {num(
|
||
s.hours,
|
||
2
|
||
)} h</span
|
||
>
|
||
{@render rowActions('sleep', s)}
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{:else}
|
||
{@render empty('No sleep logged this week.')}
|
||
{/if}
|
||
</section>
|
||
|
||
<section class="panel">
|
||
{@render sectionHeader('Medications & supplements', 'medications')}
|
||
{#if activeMeds.length}
|
||
<ul class="divide-y divide-[var(--site-border)] text-sm">
|
||
{#each activeMeds as m (m.id)}
|
||
<li class="flex items-center justify-between gap-2 py-1.5">
|
||
<span
|
||
><strong>{m.name}</strong>{m.dose ? ` · ${m.dose}` : ''}{m.frequency
|
||
? ` · ${m.frequency}`
|
||
: ''}
|
||
<small>{m.kind}{m.startDate ? `, since ${dayLabel(m.startDate)}` : ''}</small></span
|
||
>
|
||
{@render rowActions('medications', m)}
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{:else}
|
||
{@render empty('No active medications or supplements.')}
|
||
{/if}
|
||
{#if otherMeds.length}
|
||
<details class="mt-2 text-sm">
|
||
<summary class="cursor-pointer text-[var(--site-muted)]"
|
||
>Stopped or not started ({otherMeds.length})</summary
|
||
>
|
||
<ul class="mt-1">
|
||
{#each otherMeds as m (m.id)}
|
||
<li class="flex items-center justify-between py-1">
|
||
<span>{m.name} <small>{m.startDate ?? '?'} → {m.stopDate ?? 'now'}</small></span>
|
||
{@render rowActions('medications', m)}
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
</details>
|
||
{/if}
|
||
</section>
|
||
|
||
<section class="panel">
|
||
{@render sectionHeader('Appointments', 'appointments')}
|
||
{#if data.appointments.upcoming.length}
|
||
<ul class="divide-y divide-[var(--site-border)] text-sm">
|
||
{#each data.appointments.upcoming as a (a.id)}
|
||
<li class="flex items-center justify-between gap-2 py-1.5">
|
||
<span
|
||
><strong>{dayLabel(a.date)}</strong> · {a.type}{a.provider
|
||
? ` · ${a.provider}`
|
||
: ''}{a.notes ? ` · ${a.notes}` : ''}</span
|
||
>
|
||
{@render rowActions('appointments', a)}
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{:else}
|
||
{@render empty('Nothing upcoming.')}
|
||
{/if}
|
||
{#if data.appointments.past.length}
|
||
<details class="mt-2 text-sm">
|
||
<summary class="cursor-pointer text-[var(--site-muted)]"
|
||
>Recent ({data.appointments.past.length})</summary
|
||
>
|
||
<ul class="mt-1">
|
||
{#each data.appointments.past as a (a.id)}
|
||
<li class="flex items-center justify-between py-1">
|
||
<span>{dayLabel(a.date)} · {a.type}{a.provider ? ` · ${a.provider}` : ''}</span>
|
||
{@render rowActions('appointments', a)}
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
</details>
|
||
{/if}
|
||
</section>
|
||
|
||
<section class="panel lg:col-span-2">
|
||
{@render sectionHeader('Recent workouts', 'workouts')}
|
||
{#if workoutDays.length}
|
||
{#each workoutDays as [day, entries] (day)}
|
||
<h3 class="mt-2 text-sm font-medium text-[var(--site-muted)]">{dayLabel(day)}</h3>
|
||
<ul class="divide-y divide-[var(--site-border)] text-sm">
|
||
{#each entries as w (w.id)}
|
||
<li class="flex items-center justify-between gap-2 py-1.5">
|
||
<span
|
||
><strong>{w.exercise}</strong>{w.sets
|
||
? ` · ${w.sets}×${w.reps ?? '?'}`
|
||
: ''}{w.loadKg !== null ? ` @ ${num(w.loadKg, 2)} kg` : ''}{w.rpe !== null
|
||
? ` · RPE ${num(w.rpe)}`
|
||
: ''}{w.notes ? ` · ${w.notes}` : ''}</span
|
||
>
|
||
{@render rowActions('workouts', w)}
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/each}
|
||
{:else}
|
||
{@render empty('No workouts yet.')}
|
||
{/if}
|
||
</section>
|
||
|
||
<section class="panel lg:col-span-3">
|
||
{@render sectionHeader('Vaccinations', 'vaccinations')}
|
||
{#if data.vaccinations.length}
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full text-left text-sm">
|
||
<thead class="text-[var(--site-muted)]">
|
||
<tr
|
||
><th>Date</th><th>Vaccine</th><th>Against</th><th>Dose</th><th>Batch</th><th
|
||
>Provider</th
|
||
><th>Country</th><th></th></tr
|
||
>
|
||
</thead>
|
||
<tbody>
|
||
{#each data.vaccinations as v (v.id)}
|
||
<tr class="border-t border-[var(--site-border)]">
|
||
<td>{v.date}</td><td>{v.vaccine}</td><td>{v.target ?? ''}</td><td
|
||
>{v.doseNo ?? ''}</td
|
||
>
|
||
<td>{v.batchLot ?? ''}</td><td>{v.provider ?? ''}</td><td>{v.country ?? ''}</td>
|
||
<td>{@render rowActions('vaccinations', v)}</td>
|
||
</tr>
|
||
{/each}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{:else}
|
||
{@render empty('No vaccinations yet. Import your Mijn RIVM record to fill this in.')}
|
||
{/if}
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
<Dialog.Root bind:open={dialogOpen}>
|
||
<Dialog.Content class="block max-h-[90dvh] overflow-y-auto sm:max-w-lg">
|
||
<Dialog.Header class="mb-4 pr-8">
|
||
<Dialog.Title>
|
||
{editor.confirmDelete ? 'Delete entry' : editor.row ? 'Edit' : 'Add'} · {healthTables[
|
||
editor.table
|
||
].label}
|
||
</Dialog.Title>
|
||
<Dialog.Description class="sr-only">Manage a private health record.</Dialog.Description>
|
||
</Dialog.Header>
|
||
{#if editor.confirmDelete && editor.row}
|
||
<form method="POST" action="?/delete" use:enhance={submit}>
|
||
<input type="hidden" name="table" value={editor.table} />
|
||
<input type="hidden" name="id" value={editor.row.id} />
|
||
<p class="mb-4 text-sm">Delete this entry? This cannot be undone.</p>
|
||
{#if error}<p class="mb-3 text-sm text-red-500" role="alert">{error}</p>{/if}
|
||
<div class="flex justify-end gap-2">
|
||
<Button type="button" variant="outline" onclick={() => (dialogOpen = false)}
|
||
>Cancel</Button
|
||
>
|
||
<Button type="submit" variant="destructive">Delete</Button>
|
||
</div>
|
||
</form>
|
||
{:else}
|
||
<form method="POST" action="?/save" use:enhance={submit} class="grid gap-3">
|
||
<input type="hidden" name="table" value={editor.table} />
|
||
{#if editor.row}<input type="hidden" name="id" value={editor.row.id} />{/if}
|
||
{#each fields as field (field.key)}
|
||
<label class="grid gap-1 text-sm">
|
||
<span>{field.label}{field.required ? ' *' : ''}</span>
|
||
{#if field.type === 'textarea'}
|
||
<Textarea
|
||
name={field.key}
|
||
value={fieldValue(field)}
|
||
maxlength={field.maxLength}
|
||
rows={2}
|
||
/>
|
||
{:else if field.type === 'select'}
|
||
<NativeSelect name={field.key} value={fieldValue(field) || field.options?.[0]}>
|
||
{#each field.options ?? [] as option (option)}<option value={option}
|
||
>{option}</option
|
||
>{/each}
|
||
</NativeSelect>
|
||
{:else}
|
||
<Input
|
||
name={field.key}
|
||
value={fieldValue(field)}
|
||
required={field.required}
|
||
type={field.type === 'date'
|
||
? 'date'
|
||
: field.type === 'datetime'
|
||
? 'datetime-local'
|
||
: field.type === 'integer' || field.type === 'number'
|
||
? 'number'
|
||
: 'text'}
|
||
min={field.min}
|
||
max={field.max}
|
||
step={field.type === 'integer' ? 1 : field.step}
|
||
maxlength={field.maxLength}
|
||
placeholder={field.placeholder}
|
||
inputmode={field.type === 'number' ? 'decimal' : undefined}
|
||
/>
|
||
{/if}
|
||
</label>
|
||
{/each}
|
||
{#if error}<p class="text-sm text-red-500" role="alert">{error}</p>{/if}
|
||
<div class="mt-2 flex justify-end gap-2">
|
||
<Button type="button" variant="outline" onclick={() => (dialogOpen = false)}
|
||
>Cancel</Button
|
||
>
|
||
<Button type="submit">Save</Button>
|
||
</div>
|
||
</form>
|
||
{/if}
|
||
</Dialog.Content>
|
||
</Dialog.Root>
|
||
|
||
{#if form?.message && !dialogOpen}<p class="sr-only" role="status">{form.message}</p>{/if}
|
||
|
||
<style>
|
||
.panel {
|
||
border: 1px solid var(--site-border);
|
||
border-radius: 0.75rem;
|
||
background: var(--site-surface);
|
||
padding: 1rem 1.25rem;
|
||
}
|
||
.stat {
|
||
font-size: 1.6rem;
|
||
font-weight: 600;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
small {
|
||
color: var(--site-muted);
|
||
}
|
||
.icon {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 0.375rem;
|
||
padding: 0.3rem;
|
||
color: var(--site-muted);
|
||
}
|
||
.icon:hover {
|
||
background: color-mix(in srgb, var(--site-muted) 15%, transparent);
|
||
color: var(--site-text);
|
||
}
|
||
th,
|
||
td {
|
||
padding: 0.4rem 0.5rem 0.4rem 0;
|
||
white-space: nowrap;
|
||
}
|
||
</style>
|