AI tracker: /trackers/ai with public news, owner-only bots and usage; ai schema (news_items, bot_status, usage_entries); idempotent import scripts; preflight ai grants check
Some checks failed
Deploy production / deploy (push) Has been cancelled

This commit is contained in:
2026-10-25 23:58:39 +00:00
parent 9b21220280
commit cf0fec973c
24 changed files with 38866 additions and 3 deletions

View File

@@ -27,6 +27,7 @@ const staticPaths = [
'/stats/countries/USA',
'/stats/countries/KOR',
'/trackers/books',
'/trackers/ai',
'/trackers/spotting',
'/trackers/cars',
'/trackers/cars/brands',

View File

@@ -0,0 +1,10 @@
import { isOwner } from '#lib/server/owner.js';
import type { LayoutServerLoad } from './$types';
// /trackers/ai: the news tab is public; bots and usage are owner-only (their own loads call
// requireOwner, and hooks.server.ts 404s /trackers/ai/bots and /trackers/ai/usage as a backstop).
export const load: LayoutServerLoad = (event) => {
const owner = isOwner(event);
if (owner) event.setHeaders({ 'cache-control': 'private, no-store' });
return { owner };
};

View File

@@ -0,0 +1,121 @@
<script lang="ts">
import { page } from '$app/state';
import { resolve } from '$app/paths';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import PrivateLock from '#lib/components/PrivateLock.svelte';
import Newspaper from 'lucide-svelte/icons/newspaper';
import Bot from 'lucide-svelte/icons/bot';
import Gauge from 'lucide-svelte/icons/gauge';
let { data, children } = $props();
const tabs = $derived([
{ name: 'news', href: '/trackers/ai', icon: Newspaper, locked: false },
...(data.owner
? [
{ name: 'bots', href: '/trackers/ai/bots', icon: Bot, locked: true },
{ name: 'usage', href: '/trackers/ai/usage', icon: Gauge, locked: true }
]
: [])
]);
</script>
<div class="ai-page">
<header class="ai-head">
<TrackerIcon name="ai" class="size-10" />
<div>
<h1>ai</h1>
<p>AI and software news, plus what my bots are doing and how much I use each model.</p>
</div>
</header>
<nav class="ai-tabs" aria-label="AI tracker sections">
{#each tabs as tab (tab.href)}
{@const active = page.url.pathname === tab.href}
<a
href={resolve(tab.href as '/trackers/ai')}
class={{ active }}
aria-current={active ? 'page' : undefined}
><span class="tab-icon tab-{tab.name}"><tab.icon size={14} /></span
>{tab.name}{#if tab.locked}<PrivateLock size={11} />{/if}</a
>
{/each}
</nav>
{@render children()}
</div>
<style>
.ai-page {
max-width: 72rem;
margin: 0 auto;
padding: 2rem 1rem 3rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.ai-head {
display: flex;
align-items: center;
gap: 0.85rem;
}
.ai-head h1 {
font-size: 2rem;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.1;
}
.ai-head p {
color: var(--site-muted);
font-size: 0.9rem;
}
.ai-tabs {
display: flex;
gap: 0.35rem;
overflow-x: auto;
scrollbar-width: none;
border-bottom: 1px solid var(--site-border);
padding-bottom: 0.6rem;
}
.ai-tabs a {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.75rem 0.35rem 0.4rem;
border: 1px solid var(--site-border);
border-radius: 999px;
font-size: 0.85rem;
color: var(--site-muted);
white-space: nowrap;
text-decoration: none;
transition:
color 0.15s,
border-color 0.15s,
background 0.15s;
}
.ai-tabs a:hover {
color: var(--site-text);
border-color: var(--site-muted);
}
.ai-tabs a.active {
background: var(--site-text);
border-color: var(--site-text);
color: var(--site-bg);
}
.tab-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 999px;
color: #fff;
}
.tab-news {
background: linear-gradient(135deg, #38bdf8, #2563eb);
}
.tab-bots {
background: linear-gradient(135deg, #fb923c, #db2777);
}
.tab-usage {
background: linear-gradient(135deg, #34d399, #0d9488);
}
</style>

View File

@@ -0,0 +1,15 @@
import { loadNews } from '#lib/server/ai-tracker.js';
import type { PageServerLoad } from './$types';
// Public: the digest news from Ilya's weekday AI/software digest.
export const load: PageServerLoad = async () => {
const news = await loadNews(365);
return {
news,
seo: {
title: 'AI tracker',
description:
'A daily digest of AI and software news: model releases, research, tools and the software world, one line each.'
}
};
};

View File

@@ -0,0 +1,233 @@
<script lang="ts">
import ArrowUpRight from 'lucide-svelte/icons/arrow-up-right';
import Cpu from 'lucide-svelte/icons/cpu';
import Code from 'lucide-svelte/icons/code';
import Layers from 'lucide-svelte/icons/layers';
let { data } = $props();
type Filter = 'all' | 'ai' | 'software';
let filter = $state<Filter>('all');
const filters = [
{ key: 'all', label: 'all', icon: Layers },
{ key: 'ai', label: 'ai', icon: Cpu },
{ key: 'software', label: 'software', icon: Code }
] as const;
const counts = $derived({
all: data.news.length,
ai: data.news.filter((n) => n.section === 'ai').length,
software: data.news.filter((n) => n.section === 'software').length
});
const days = $derived.by(() => {
const groups: { date: string; items: typeof data.news }[] = [];
for (const item of data.news) {
if (filter !== 'all' && item.section !== filter) continue;
const last = groups.at(-1);
if (last?.date === item.date) last.items.push(item);
else groups.push({ date: item.date, items: [item] });
}
return groups;
});
const dayLabel = (iso: string) =>
new Date(`${iso}T12:00:00Z`).toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'short',
year: 'numeric',
timeZone: 'Europe/Amsterdam'
});
const host = (url: string) => {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return '';
}
};
</script>
<div class="filters" role="group" aria-label="Filter by section">
{#each filters as f (f.key)}
<button
type="button"
class={['chip', `chip-${f.key}`, { on: filter === f.key }]}
aria-pressed={filter === f.key}
onclick={() => (filter = f.key)}
><span class="dot"><f.icon size={12} /></span>{f.label}<span class="count"
>{counts[f.key]}</span
></button
>
{/each}
</div>
{#if !days.length}
<p class="empty">No news yet. The digest lands here every weekday.</p>
{:else}
<div class="days">
{#each days as day (day.date)}
<section class="day" aria-label={dayLabel(day.date)}>
<h2><time datetime={day.date}>{dayLabel(day.date)}</time></h2>
<ul>
{#each day.items as item (item.id)}
<li>
<a class="item" href={item.url} target="_blank" rel="noopener noreferrer">
<span class={['badge', `badge-${item.section}`]}
>{#if item.section === 'software'}<Code size={13} />{:else}<Cpu
size={13}
/>{/if}</span
>
<span class="body">
<span class="title">{item.title}</span>
{#if item.note}<span class="note">{item.note}</span>{/if}
<span class="host">{host(item.url)}</span>
</span>
<ArrowUpRight size={16} class="arrow" aria-hidden="true" />
</a>
</li>
{/each}
</ul>
</section>
{/each}
</div>
{/if}
<style>
.filters {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.6rem 0.25rem 0.3rem;
border: 1px solid var(--site-border);
border-radius: 999px;
font-size: 0.8rem;
color: var(--site-muted);
background: transparent;
cursor: pointer;
transition:
color 0.15s,
border-color 0.15s;
}
.chip:hover {
color: var(--site-text);
border-color: var(--site-muted);
}
.chip.on {
color: var(--site-text);
border-color: var(--site-text);
}
.dot {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 999px;
color: #fff;
}
.chip-all .dot {
background: linear-gradient(135deg, #a1a1aa, #52525b);
}
.chip-ai .dot,
.badge-ai {
background: linear-gradient(135deg, #fda4af, #8b5cf6);
}
.chip-software .dot,
.badge-software {
background: linear-gradient(135deg, #5eead4, #0284c7);
}
.count {
font-variant-numeric: tabular-nums;
opacity: 0.6;
}
.empty {
color: var(--site-muted);
padding: 2rem 0;
}
.days {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.day h2 {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--site-muted);
margin-bottom: 0.5rem;
}
.day ul {
display: grid;
gap: 0.5rem;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 22rem), 1fr));
}
.item {
display: flex;
align-items: flex-start;
gap: 0.7rem;
height: 100%;
padding: 0.7rem 0.8rem;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
color: var(--site-text);
text-decoration: none;
transition:
border-color 0.15s,
transform 0.15s,
box-shadow 0.15s;
}
.item:hover {
border-color: var(--site-muted);
transform: translateY(-1px);
box-shadow: 0 6px 20px -12px rgb(0 0 0 / 0.5);
}
.item :global(.arrow) {
flex-shrink: 0;
color: var(--site-muted);
opacity: 0;
transition: opacity 0.15s;
}
.item:hover :global(.arrow) {
opacity: 1;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.5rem;
color: #fff;
}
.body {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
flex: 1;
}
.title {
font-weight: 550;
font-size: 0.92rem;
line-height: 1.3;
}
.note {
font-size: 0.83rem;
color: var(--site-muted);
line-height: 1.35;
}
.host {
font-size: 0.72rem;
color: var(--site-muted);
opacity: 0.8;
}
</style>

View File

@@ -0,0 +1,14 @@
import { requireOwner } from '#lib/server/owner.js';
import { loadBots } from '#lib/server/ai-tracker.js';
import type { PageServerLoad } from './$types';
// Owner only: one row per Grok Bot agent, short summaries written by Daisy (no transcripts).
export const load: PageServerLoad = async (event) => {
requireOwner(event);
event.setHeaders({ 'cache-control': 'private, no-store' });
return {
bots: await loadBots(),
now: Date.now(),
seo: { title: 'AI tracker · bots', description: 'My bots.', index: false }
};
};

View File

@@ -0,0 +1,127 @@
<script lang="ts">
import { relativeTime } from '#lib/relative-time.js';
import Bot from 'lucide-svelte/icons/bot';
let { data } = $props();
// Stable accent per bot name, so each row gets its own colourful avatar.
const HUES = [
['#60a5fa', '#2563eb'],
['#f472b6', '#db2777'],
['#34d399', '#059669'],
['#fb923c', '#ea580c'],
['#a78bfa', '#7c3aed'],
['#22d3ee', '#0891b2'],
['#facc15', '#ca8a04'],
['#f87171', '#dc2626']
];
const hue = (name: string) => {
let h = 0;
for (const ch of name) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
return HUES[h % HUES.length];
};
const stamp = (iso: string) =>
new Date(iso).toLocaleString('en-GB', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Amsterdam'
});
</script>
{#if !data.bots.length}
<p class="empty">No bots yet. Daisy fills this from short daily summaries.</p>
{:else}
<p class="meta">{data.bots.length} bots · summaries only, never transcripts</p>
<ul class="bots">
{#each data.bots as bot (bot.name)}
{@const [from, to] = hue(bot.name)}
<li class="bot">
<span class="avatar" style:--from={from} style:--to={to}><Bot size={16} /></span>
<div class="body">
<div class="top">
<h2>{bot.name}</h2>
{#if bot.lastActive}
<time datetime={bot.lastActive} title={`${stamp(bot.lastActive)} (Amsterdam)`}
>{relativeTime(bot.lastActive, data.now)}</time
>
{/if}
</div>
{#if bot.focus}<p class="focus">{bot.focus}</p>{/if}
{#if bot.recent}<p class="recent">{bot.recent}</p>{/if}
</div>
</li>
{/each}
</ul>
{/if}
<style>
.empty,
.meta {
color: var(--site-muted);
font-size: 0.85rem;
}
.empty {
padding: 2rem 0;
}
.bots {
display: grid;
gap: 0.5rem;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 22rem), 1fr));
}
.bot {
display: flex;
gap: 0.7rem;
padding: 0.75rem 0.85rem;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
transition: border-color 0.15s;
}
.bot:hover {
border-color: var(--site-muted);
}
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2rem;
height: 2rem;
border-radius: 0.6rem;
color: #fff;
background: linear-gradient(135deg, var(--from), var(--to));
}
.body {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.top {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.5rem;
}
h2 {
font-weight: 600;
font-size: 0.95rem;
}
time {
font-size: 0.72rem;
color: var(--site-muted);
white-space: nowrap;
}
.focus {
font-size: 0.85rem;
line-height: 1.35;
}
.recent {
font-size: 0.8rem;
color: var(--site-muted);
line-height: 1.35;
}
</style>

View File

@@ -0,0 +1,28 @@
import { requireOwner } from '#lib/server/owner.js';
import { addUsage, deleteUsage, loadUsage } from '#lib/server/ai-tracker.js';
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
// Owner only: dated usage numbers per provider. Actions don't run the load, so each
// action checks the owner again (hooks.server.ts also 404s this path for everyone else).
export const load: PageServerLoad = async (event) => {
requireOwner(event);
event.setHeaders({ 'cache-control': 'private, no-store' });
return {
entries: await loadUsage(),
seo: { title: 'AI tracker · usage', description: 'My AI usage.', index: false }
};
};
export const actions: Actions = {
add: async (event) => {
requireOwner(event);
const r = await addUsage(await event.request.formData());
return r.ok ? { saved: true } : fail(400, { message: r.message });
},
delete: async (event) => {
requireOwner(event);
const r = await deleteUsage(await event.request.formData());
return r.ok ? { deleted: true } : fail(400, { message: r.message });
}
};

View File

@@ -0,0 +1,334 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Plus from 'lucide-svelte/icons/plus';
import Trash2 from 'lucide-svelte/icons/trash-2';
let { data, form } = $props();
const PROVIDERS = [
{ key: 'claude', label: 'Claude', from: '#fdba74', to: '#c2410c' },
{ key: 'chatgpt', label: 'ChatGPT', from: '#6ee7b7', to: '#047857' },
{ key: 'gemini', label: 'Gemini', from: '#93c5fd', to: '#6d28d9' },
{ key: 'api', label: 'API keys', from: '#d4d4d8', to: '#3f3f46' }
] as const;
const SOURCES = [
{ key: 'manual', label: 'manual' },
{ key: 'scheduled', label: 'scheduled task in the app' },
{ key: 'ccusage', label: 'ccusage on the server' },
{ key: 'export', label: 'data export' }
] as const;
const today = new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Amsterdam' });
let busy = $state(false);
type Entry = (typeof data.entries)[number];
/** provider → metric → entries (newest first, as loaded). */
const byProvider = $derived.by(() => {
const out: Record<string, Record<string, Entry[]>> = {};
for (const e of data.entries) ((out[e.provider] ??= {})[e.metric] ??= []).push(e);
return out;
});
const knownMetrics = $derived([...new Set(data.entries.map((e) => e.metric))].sort());
const W = 240;
const H = 56;
/** Oldest → newest points (summed per day across sources) for a small line chart. */
function chart(list: Entry[]) {
const perDay: Record<string, number> = {};
for (const e of list) perDay[e.date] = (perDay[e.date] ?? 0) + e.value;
const pts = Object.entries(perDay).sort(([a], [b]) => a.localeCompare(b));
const t = pts.map(([d]) => Date.parse(d));
const v = pts.map(([, n]) => n);
const t0 = Math.min(...t);
const t1 = Math.max(...t);
const lo = Math.min(0, ...v);
const hi = Math.max(...v, lo + 1);
const xy = pts.map((_, i) => [
t1 === t0 ? W / 2 : ((t[i] - t0) / (t1 - t0)) * (W - 8) + 4,
H - 4 - ((v[i] - lo) / (hi - lo)) * (H - 8)
]);
return {
line: xy.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(' '),
dots: xy,
first: pts[0]?.[0],
last: pts.at(-1)?.[0],
latest: v.at(-1) ?? 0,
max: Math.max(...v)
};
}
const fmt = (n: number) =>
n.toLocaleString('en-GB', { maximumFractionDigits: n < 10 ? 2 : n < 1000 ? 1 : 0 });
const short = (iso?: string) =>
iso
? new Date(`${iso}T12:00:00Z`).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
: '';
</script>
<form
class="add"
method="POST"
action="?/add"
use:enhance={() => {
busy = true;
return async ({ update }) => {
await update({ reset: false });
busy = false;
};
}}
>
<label
>provider<select name="provider" required>
{#each PROVIDERS as p (p.key)}<option value={p.key}>{p.label}</option>{/each}
</select></label
>
<label>date<input name="date" type="date" value={today} required /></label>
<label
>metric<input
name="metric"
list="ai-metrics"
placeholder="messages, tokens, cost…"
maxlength="60"
required
/></label
>
<datalist id="ai-metrics">
{#each knownMetrics as m (m)}<option value={m}></option>{/each}
</datalist>
<label>value<input name="value" type="number" step="any" required /></label>
<label>unit<input name="unit" placeholder="msgs, USD, M tokens" maxlength="30" /></label>
<label
>source<select name="source">
{#each SOURCES as s (s.key)}<option value={s.key}>{s.label}</option>{/each}
</select></label
>
<label class="wide">note<input name="note" maxlength="300" placeholder="optional" /></label>
<button type="submit" disabled={busy}><Plus size={15} />add</button>
{#if form?.message}<p class="error" role="alert">{form.message}</p>{/if}
</form>
<p class="hint">Same provider, metric, date and source overwrites the earlier value.</p>
<div class="providers">
{#each PROVIDERS as p (p.key)}
{@const metrics = byProvider[p.key]}
<section class="provider" style:--from={p.from} style:--to={p.to}>
<h2><span class="swatch"></span>{p.label}</h2>
{#if !metrics}
<p class="none">No entries yet.</p>
{:else}
{#each Object.entries(metrics) as [metric, list] (metric)}
{@const c = chart(list)}
<div class="metric">
<div class="mhead">
<span class="mname">{metric}</span>
<span class="mval">{fmt(c.latest)} <small>{list[0].unit ?? ''}</small></span>
</div>
<svg viewBox="0 0 {W} {H}" role="img" aria-label="{p.label} {metric} over time">
<polyline points={c.line} fill="none" stroke="url(#g-{p.key})" stroke-width="2" />
{#each c.dots as [x, y], i (i)}<circle cx={x} cy={y} r="2.2" fill={p.to} />{/each}
<defs>
<linearGradient id="g-{p.key}" x1="0" x2="1">
<stop offset="0" stop-color={p.from} /><stop offset="1" stop-color={p.to} />
</linearGradient>
</defs>
</svg>
<div class="axis">
<span>{short(c.first)}</span><span>max {fmt(c.max)}</span><span>{short(c.last)}</span>
</div>
<details>
<summary>{list.length} entries</summary>
<ul class="rows">
{#each list as e (e.id)}
<li>
<span class="d">{e.date}</span>
<span class="v">{fmt(e.value)} {e.unit ?? ''}</span>
<span class="s">{e.source}</span>
{#if e.note}<span class="n" title={e.note}>{e.note}</span>{/if}
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={e.id} />
<button type="submit" class="del" aria-label="delete entry" title="delete"
><Trash2 size={13} /></button
>
</form>
</li>
{/each}
</ul>
</details>
</div>
{/each}
{/if}
</section>
{/each}
</div>
<style>
.add {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
gap: 0.5rem;
align-items: end;
padding: 0.8rem;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
}
.add label {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--site-muted);
}
.add .wide {
grid-column: span 2;
}
.add input,
.add select {
font-size: 0.85rem;
text-transform: none;
letter-spacing: normal;
color: var(--site-text);
background: var(--site-bg);
border: 1px solid var(--site-border);
border-radius: 0.5rem;
padding: 0.35rem 0.5rem;
min-width: 0;
}
.add button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
padding: 0.4rem 0.8rem;
border-radius: 0.5rem;
background: var(--site-text);
color: var(--site-bg);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.add button:hover {
opacity: 0.85;
}
.add button:disabled {
opacity: 0.5;
}
.error {
grid-column: 1 / -1;
color: #f87171;
font-size: 0.8rem;
}
.hint {
font-size: 0.75rem;
color: var(--site-muted);
margin-top: -0.6rem;
}
.providers {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr));
}
.provider {
padding: 0.8rem;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
display: flex;
flex-direction: column;
gap: 0.75rem;
transition: border-color 0.15s;
}
.provider:hover {
border-color: var(--site-muted);
}
h2 {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
}
.swatch {
width: 1rem;
height: 1rem;
border-radius: 0.3rem;
background: linear-gradient(135deg, var(--from), var(--to));
}
.none {
font-size: 0.8rem;
color: var(--site-muted);
}
.metric svg {
width: 100%;
height: 56px;
overflow: visible;
}
.mhead {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
}
.mname {
color: var(--site-muted);
}
.mval {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.mval small {
font-weight: 400;
color: var(--site-muted);
}
.axis {
display: flex;
justify-content: space-between;
font-size: 0.68rem;
color: var(--site-muted);
}
details {
font-size: 0.78rem;
margin-top: 0.3rem;
}
summary {
cursor: pointer;
color: var(--site-muted);
}
.rows {
display: flex;
flex-direction: column;
gap: 0.2rem;
margin-top: 0.3rem;
}
.rows li {
display: flex;
align-items: center;
gap: 0.5rem;
font-variant-numeric: tabular-nums;
}
.rows .s,
.rows .n {
color: var(--site-muted);
}
.rows .n {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.rows form {
margin-left: auto;
}
.del {
display: inline-flex;
padding: 0.2rem;
border-radius: 0.35rem;
color: var(--site-muted);
cursor: pointer;
}
.del:hover {
color: #f87171;
background: rgb(248 113 113 / 0.1);
}
</style>