All checks were successful
Deploy production / deploy (push) Successful in 1m7s
779 lines
22 KiB
Svelte
779 lines
22 KiB
Svelte
<script lang="ts">
|
||
import { enhance } from '$app/forms';
|
||
import { goto } from '$app/navigation';
|
||
import { page } from '$app/state';
|
||
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 Lock from 'lucide-svelte/icons/lock';
|
||
import Unlock from 'lucide-svelte/icons/lock-open';
|
||
import Star from 'lucide-svelte/icons/star';
|
||
import ExternalLink from 'lucide-svelte/icons/external-link';
|
||
import Split from 'lucide-svelte/icons/split';
|
||
import MoveRight from 'lucide-svelte/icons/move-right';
|
||
import Merge from 'lucide-svelte/icons/merge';
|
||
import {
|
||
cheapest,
|
||
effectivePrice,
|
||
effectiveUnitPrice,
|
||
formatAmount,
|
||
formatCents,
|
||
formatUnitPrice,
|
||
sortByPrice
|
||
} from '#lib/groceries.js';
|
||
import type { SubmitFunction } from '@sveltejs/kit';
|
||
import type { PageProps } from './$types';
|
||
|
||
let { data }: PageProps = $props();
|
||
|
||
type Item = (typeof data.items)[number];
|
||
type Group = (typeof data.groups)[number];
|
||
type PickerHit = {
|
||
id: number;
|
||
name: string;
|
||
brand: string | null;
|
||
unit: string | null;
|
||
storeCount: number;
|
||
itemCount: number;
|
||
};
|
||
|
||
const MAX_ROWS = 6;
|
||
let mode = $state<'unit' | 'pack'>('unit');
|
||
let actionError = $state<string | null>(null);
|
||
let picker = $state<{ kind: 'merge' } | { kind: 'move'; itemId: number } | null>(null);
|
||
let pickerQuery = $state('');
|
||
let pickerHits = $state.raw<PickerHit[]>([]);
|
||
let pickerTimer: ReturnType<typeof setTimeout> | undefined;
|
||
|
||
const storeById = $derived(new Map(data.stores.map((s) => [s.id, s])));
|
||
const itemsByGroup = $derived.by(() => {
|
||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- rebuilt by $derived, never mutated later
|
||
const m = new Map<number, Item[]>();
|
||
for (const i of data.items) {
|
||
const list = m.get(i.groupId);
|
||
if (list) list.push(i);
|
||
else m.set(i.groupId, [i]);
|
||
}
|
||
return m;
|
||
});
|
||
const pct = (n: number, d: number) => (d ? `${Math.round((n / d) * 100)}%` : '–');
|
||
const pages = $derived(Math.max(1, Math.ceil(data.total / data.pageSize)));
|
||
const lastImport = $derived(
|
||
data.runs
|
||
.filter((r) => r.finishedAt)
|
||
.map((r) => new Date(r.finishedAt!).getTime())
|
||
.sort((a, b) => b - a)[0]
|
||
);
|
||
const historyByItem = $derived.by(() => {
|
||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- rebuilt by $derived, never mutated later
|
||
const m = new Map<number, NonNullable<typeof data.detail>['history']>();
|
||
for (const h of data.detail?.history ?? []) {
|
||
const list = m.get(h.itemId);
|
||
if (list) list.push(h);
|
||
else m.set(h.itemId, [h]);
|
||
}
|
||
return m;
|
||
});
|
||
|
||
const when = (d: Date | string | number) =>
|
||
new Date(d).toLocaleString('en-GB', {
|
||
day: 'numeric',
|
||
month: 'short',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
timeZone: 'Europe/Amsterdam'
|
||
});
|
||
|
||
/** URL with some search params changed (null removes). */
|
||
function withParams(changes: Record<string, string | null>) {
|
||
const u = new URL(page.url);
|
||
for (const [k, v] of Object.entries(changes)) {
|
||
if (v === null || v === '') u.searchParams.delete(k);
|
||
else u.searchParams.set(k, v);
|
||
}
|
||
return `${u.pathname}${u.search}`;
|
||
}
|
||
const detailOpen = $derived(!!data.detail);
|
||
function closeDetail() {
|
||
picker = null;
|
||
actionError = null;
|
||
goto(withParams({ g: null }), { noScroll: true, keepFocus: true });
|
||
}
|
||
|
||
function filterChange(e: Event) {
|
||
const form = (e.currentTarget as HTMLFormElement | null) ?? null;
|
||
form?.requestSubmit();
|
||
}
|
||
|
||
function openPicker(p: NonNullable<typeof picker>) {
|
||
picker = p;
|
||
pickerQuery = data.detail?.group.name ?? '';
|
||
searchGroups();
|
||
}
|
||
function searchGroups() {
|
||
clearTimeout(pickerTimer);
|
||
pickerTimer = setTimeout(async () => {
|
||
const q = pickerQuery.trim();
|
||
if (!q) {
|
||
pickerHits = [];
|
||
return;
|
||
}
|
||
const res = await fetch(`/trackers/groceries/search?q=${encodeURIComponent(q)}`);
|
||
pickerHits = res.ok
|
||
? ((await res.json()) as PickerHit[]).filter((h) => h.id !== data.detail?.group.id)
|
||
: [];
|
||
}, 200);
|
||
}
|
||
|
||
const submitAction: SubmitFunction =
|
||
() =>
|
||
async ({ result, update }) => {
|
||
if (result.type === 'success' && typeof result.data?.merged === 'number') {
|
||
picker = null;
|
||
actionError = null;
|
||
await goto(withParams({ g: String(result.data.merged) }), {
|
||
noScroll: true,
|
||
invalidateAll: true
|
||
});
|
||
return;
|
||
}
|
||
await update({ reset: false });
|
||
if (result.type === 'success') {
|
||
picker = null;
|
||
actionError = null;
|
||
} else
|
||
actionError = String(
|
||
(result.type === 'failure' && result.data?.message) || 'Could not save'
|
||
);
|
||
};
|
||
const submitInline: SubmitFunction =
|
||
() =>
|
||
async ({ update }) => {
|
||
await update({ reset: false });
|
||
};
|
||
</script>
|
||
|
||
{#snippet favoriteButton(g: Group)}
|
||
<form method="POST" action="?/favorite" use:enhance={submitInline} class="contents">
|
||
<input type="hidden" name="id" value={g.id} />
|
||
<input type="hidden" name="favorite" value={String(!g.favorite)} />
|
||
<button
|
||
type="submit"
|
||
class={['icon', g.favorite && 'fav']}
|
||
aria-pressed={g.favorite}
|
||
aria-label={g.favorite ? `Unfavorite ${g.name}` : `Favorite ${g.name}`}
|
||
title={g.favorite ? 'Favorite' : 'Mark as favorite'}
|
||
>
|
||
<Star size={16} fill={g.favorite ? 'currentColor' : 'none'} />
|
||
</button>
|
||
</form>
|
||
{/snippet}
|
||
|
||
{#snippet storeTag(storeId: number)}
|
||
{@const st = storeById.get(storeId)}
|
||
<span class="store" style:--store={st?.color ?? 'gray'}>{st?.name ?? '?'}</span>
|
||
{/snippet}
|
||
|
||
{#snippet priceCells(i: Item, best: { overall: number | null; perStore: Set<number> })}
|
||
{@const up = effectiveUnitPrice(i)}
|
||
{@const pp = effectivePrice(i)}
|
||
<td
|
||
class={[
|
||
'num',
|
||
mode === 'pack' && 'primary',
|
||
mode === 'pack' && best.overall === i.id && 'best',
|
||
mode === 'pack' && best.perStore.has(i.id) && 'store-best'
|
||
]}
|
||
>
|
||
{#if pp != null}
|
||
{formatCents(pp)}
|
||
{#if i.promoPriceCents != null && i.priceCents != null}<s class="meta"
|
||
>{formatCents(i.priceCents)}</s
|
||
>{/if}
|
||
{:else}–{/if}
|
||
</td>
|
||
<td
|
||
class={[
|
||
'num',
|
||
mode === 'unit' && 'primary',
|
||
mode === 'unit' && best.overall === i.id && 'best',
|
||
mode === 'unit' && best.perStore.has(i.id) && 'store-best'
|
||
]}
|
||
>
|
||
{formatUnitPrice(up, i.unit) ?? '–'}
|
||
</td>
|
||
{/snippet}
|
||
|
||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||
<div class="mb-6 flex flex-wrap items-end justify-between gap-4">
|
||
<div>
|
||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">groceries</h1>
|
||
<p class="mt-2 flex items-center gap-1.5 text-sm text-[var(--site-muted)]">
|
||
<Lock size={14} aria-hidden="true" /> Private. Supermarket prices compared per kg, litre or piece.
|
||
{#if lastImport}Updated {when(lastImport)}.{/if}
|
||
</p>
|
||
</div>
|
||
<dl class="stats">
|
||
{#each data.stores.filter((s) => s.items > 0) as s (s.id)}
|
||
<div>
|
||
<dt>{s.name}</dt>
|
||
<dd>{s.items.toLocaleString('nl-NL')}</dd>
|
||
</div>
|
||
{/each}
|
||
<div>
|
||
<dt>groups</dt>
|
||
<dd>{data.stats.groups.toLocaleString('nl-NL')}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>in 2+ stores</dt>
|
||
<dd title="Share of products whose group has products from at least two stores">
|
||
{pct(data.stats.grouped, data.stats.items)}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>unit priced</dt>
|
||
<dd>{pct(data.stats.unitPriced, data.stats.items)}</dd>
|
||
</div>
|
||
</dl>
|
||
</div>
|
||
|
||
<form
|
||
method="GET"
|
||
class="mb-4 flex flex-wrap items-center gap-2"
|
||
data-sveltekit-keepfocus
|
||
onchange={filterChange}
|
||
>
|
||
<Input
|
||
type="search"
|
||
name="q"
|
||
value={data.filters.q}
|
||
placeholder="Search (pindakaas, halfvolle melk, Calvé…)"
|
||
class="min-w-[14rem] flex-1"
|
||
aria-label="Search products"
|
||
/>
|
||
<NativeSelect name="store" value={data.filters.store} aria-label="Store">
|
||
<option value="">All stores</option>
|
||
{#each data.stores.filter((s) => s.items > 0) as s (s.id)}<option value={s.slug}
|
||
>{s.name}</option
|
||
>{/each}
|
||
</NativeSelect>
|
||
<NativeSelect name="cat" value={data.filters.category} aria-label="Category">
|
||
<option value="">All categories</option>
|
||
{#each data.categories as c (c)}<option value={c}>{c}</option>{/each}
|
||
</NativeSelect>
|
||
<label class="check"
|
||
><input type="checkbox" name="fav" value="1" checked={data.filters.favorites} /> Favorites</label
|
||
>
|
||
<label class="check"
|
||
><input type="checkbox" name="promo" value="1" checked={data.filters.promo} /> Promo</label
|
||
>
|
||
<label class="check"
|
||
><input type="checkbox" name="multi" value="1" checked={data.filters.multi} /> In 2+ stores</label
|
||
>
|
||
<label class="check" title="Also show homeware and other non-food (mostly Xenos)"
|
||
><input type="checkbox" name="nonfood" value="1" checked={data.filters.nonfood} /> Non-food</label
|
||
>
|
||
<Button type="submit" variant="outline">Search</Button>
|
||
<div class="segmented ml-auto" role="group" aria-label="Compare by">
|
||
<button type="button" aria-pressed={mode === 'unit'} onclick={() => (mode = 'unit')}
|
||
>Per kg / L / st</button
|
||
>
|
||
<button type="button" aria-pressed={mode === 'pack'} onclick={() => (mode = 'pack')}
|
||
>Pack price</button
|
||
>
|
||
</div>
|
||
</form>
|
||
|
||
<p class="mb-3 text-sm text-[var(--site-muted)]">
|
||
{data.total.toLocaleString('nl-NL')} groups{#if data.total > data.pageSize}, page {data.page} of {pages}{/if}.
|
||
<span class="legend best">cheapest</span>
|
||
<span class="legend store-best">cheapest at that store</span>
|
||
</p>
|
||
|
||
{#if data.stats.items === 0}
|
||
<div class="panel text-sm text-[var(--site-muted)]">
|
||
No store products yet. The nightly import (scripts/groceries-import.ts) fills this in.
|
||
</div>
|
||
{/if}
|
||
|
||
<div class="grid gap-3">
|
||
{#each data.groups as g (g.id)}
|
||
{@const all = sortByPrice(itemsByGroup.get(g.id) ?? [], mode)}
|
||
{@const best = cheapest(all, mode)}
|
||
<section class="panel group">
|
||
<header class="mb-2 flex items-start gap-3">
|
||
{#if g.imageUrl}
|
||
<img
|
||
src={g.imageUrl}
|
||
alt=""
|
||
loading="lazy"
|
||
referrerpolicy="no-referrer"
|
||
class="thumb"
|
||
width="48"
|
||
height="48"
|
||
/>
|
||
{/if}
|
||
<div class="min-w-0 flex-1">
|
||
<a href={withParams({ g: String(g.id) })} data-sveltekit-noscroll class="title"
|
||
>{g.name}</a
|
||
>
|
||
<div class="meta">
|
||
{[
|
||
g.brand,
|
||
g.category,
|
||
`${g.storeCount} store${g.storeCount === 1 ? '' : 's'}`,
|
||
`${g.itemCount} product${g.itemCount === 1 ? '' : 's'}`
|
||
]
|
||
.filter(Boolean)
|
||
.join(' · ')}
|
||
</div>
|
||
</div>
|
||
{@render favoriteButton(g)}
|
||
</header>
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full text-sm">
|
||
<thead>
|
||
<tr>
|
||
<th class="text-left">Store</th>
|
||
<th class="text-left">Product</th>
|
||
<th class="text-right">Size</th>
|
||
<th class="text-right">Price</th>
|
||
<th class="text-right">Per unit</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{#each all.slice(0, MAX_ROWS) as i (i.id)}
|
||
<tr>
|
||
<td>{@render storeTag(i.storeId)}</td>
|
||
<td class="name">
|
||
{#if i.url}<a href={i.url} target="_blank" rel="noopener noreferrer">{i.name}</a
|
||
>{:else}{i.name}{/if}
|
||
{#if i.promoLabel}<span class="promo">{i.promoLabel}</span>{/if}
|
||
</td>
|
||
<td class="num meta">{i.sizeText ?? formatAmount(i.unitAmount, i.unit) ?? ''}</td>
|
||
{@render priceCells(i, best)}
|
||
</tr>
|
||
{/each}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{#if all.length > MAX_ROWS}
|
||
<a href={withParams({ g: String(g.id) })} data-sveltekit-noscroll class="more"
|
||
>Show all {all.length}</a
|
||
>
|
||
{/if}
|
||
</section>
|
||
{:else}
|
||
{#if data.stats.items > 0}<div class="panel text-sm text-[var(--site-muted)]">
|
||
Nothing matches.
|
||
</div>{/if}
|
||
{/each}
|
||
</div>
|
||
|
||
{#if pages > 1}
|
||
<nav class="mt-6 flex items-center justify-center gap-3 text-sm" aria-label="Pages">
|
||
{#if data.page > 1}<a
|
||
class="pager"
|
||
href={withParams({ page: String(data.page - 1), g: null })}>← Previous</a
|
||
>{/if}
|
||
<span class="meta">{data.page} / {pages}</span>
|
||
{#if data.page < pages}<a
|
||
class="pager"
|
||
href={withParams({ page: String(data.page + 1), g: null })}>Next →</a
|
||
>{/if}
|
||
</nav>
|
||
{/if}
|
||
</div>
|
||
|
||
<Dialog.Root open={detailOpen} onOpenChange={(o) => !o && closeDetail()}>
|
||
<Dialog.Content class="block max-h-[90dvh] overflow-y-auto sm:max-w-3xl">
|
||
{#if data.detail}
|
||
{@const d = data.detail}
|
||
{@const sorted = sortByPrice(d.items, mode)}
|
||
{@const best = cheapest(d.items, mode)}
|
||
<Dialog.Header class="mb-3 pr-8">
|
||
<Dialog.Title>{d.group.name}</Dialog.Title>
|
||
<Dialog.Description>
|
||
{[d.group.brand, d.group.category, d.group.manual ? 'edited by hand' : 'auto-grouped']
|
||
.filter(Boolean)
|
||
.join(' · ')}
|
||
</Dialog.Description>
|
||
</Dialog.Header>
|
||
|
||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||
<div class="segmented" role="group" aria-label="Compare by">
|
||
<button type="button" aria-pressed={mode === 'unit'} onclick={() => (mode = 'unit')}
|
||
>Per unit</button
|
||
>
|
||
<button type="button" aria-pressed={mode === 'pack'} onclick={() => (mode = 'pack')}
|
||
>Pack</button
|
||
>
|
||
</div>
|
||
<Button size="sm" variant="outline" onclick={() => openPicker({ kind: 'merge' })}
|
||
><Merge size={14} /> Merge into…</Button
|
||
>
|
||
</div>
|
||
|
||
{#if picker}
|
||
<div class="panel mb-3">
|
||
<p class="mb-2 text-sm font-medium">
|
||
{picker.kind === 'merge' ? `Merge “${d.group.name}” into:` : 'Move this product to:'}
|
||
</p>
|
||
<Input
|
||
bind:value={pickerQuery}
|
||
oninput={searchGroups}
|
||
placeholder="Search groups…"
|
||
aria-label="Search groups"
|
||
/>
|
||
<ul class="mt-2 grid gap-1">
|
||
{#each pickerHits as h (h.id)}
|
||
<li>
|
||
<form
|
||
method="POST"
|
||
action={picker.kind === 'merge' ? '?/merge' : '?/moveItem'}
|
||
use:enhance={submitAction}
|
||
>
|
||
{#if picker.kind === 'merge'}
|
||
<input type="hidden" name="id" value={d.group.id} />
|
||
{:else}
|
||
<input type="hidden" name="itemId" value={picker.itemId} />
|
||
{/if}
|
||
<input type="hidden" name="into" value={h.id} />
|
||
<button type="submit" class="hit">
|
||
{h.name}
|
||
<span class="meta"
|
||
>{[h.brand, `${h.storeCount} stores`, `${h.itemCount} products`]
|
||
.filter(Boolean)
|
||
.join(' · ')}</span
|
||
>
|
||
</button>
|
||
</form>
|
||
</li>
|
||
{:else}
|
||
<li class="meta">No groups found.</li>
|
||
{/each}
|
||
</ul>
|
||
<Button size="sm" variant="ghost" class="mt-2" onclick={() => (picker = null)}
|
||
>Cancel</Button
|
||
>
|
||
</div>
|
||
{/if}
|
||
{#if actionError}<p class="mb-2 text-sm text-red-500" role="alert">{actionError}</p>{/if}
|
||
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full text-sm">
|
||
<thead>
|
||
<tr>
|
||
<th class="text-left">Store</th>
|
||
<th class="text-left">Product</th>
|
||
<th class="text-right">Price</th>
|
||
<th class="text-right">Per unit</th>
|
||
<th class="text-left">Match</th>
|
||
<th><span class="sr-only">Actions</span></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{#each sorted as i (i.id)}
|
||
{@const hist = historyByItem.get(i.id) ?? []}
|
||
<tr class={[!i.available && 'gone']}>
|
||
<td>{@render storeTag(i.storeId)}</td>
|
||
<td class="name">
|
||
{#if i.url}<a href={i.url} target="_blank" rel="noopener noreferrer"
|
||
>{i.name} <ExternalLink size={11} class="inline" /></a
|
||
>{:else}{i.name}{/if}
|
||
<div class="meta">
|
||
{[
|
||
i.sizeText,
|
||
i.unitSource === 'store'
|
||
? `${formatAmount(i.unitAmount, i.unit)} (from store unit price)`
|
||
: null,
|
||
i.brand,
|
||
i.gtin ? `EAN ${i.gtin}` : null,
|
||
!i.available ? 'not available' : null
|
||
]
|
||
.filter(Boolean)
|
||
.join(' · ')}
|
||
</div>
|
||
{#if i.promoLabel}<span class="promo">{i.promoLabel}</span>{/if}
|
||
{#if hist.length > 1}
|
||
<div class="meta">
|
||
History: {hist
|
||
.slice(0, 6)
|
||
.map(
|
||
(h) =>
|
||
`${h.observedOn.slice(5)} ${h.priceCents != null ? formatCents(h.promoPriceCents ?? h.priceCents) : '–'}`
|
||
)
|
||
.join(', ')}
|
||
</div>
|
||
{/if}
|
||
</td>
|
||
{@render priceCells(i, best)}
|
||
<td class="meta">
|
||
{i.matchMethod ?? '–'}{#if i.matchConfidence != null} {Math.round(
|
||
i.matchConfidence * 100
|
||
)}%{/if}
|
||
{#if i.matchLocked}<Lock size={11} class="inline" aria-label="locked" />{/if}
|
||
</td>
|
||
<td class="whitespace-nowrap text-right">
|
||
<button
|
||
type="button"
|
||
class="icon"
|
||
title="Move to another group"
|
||
aria-label="Move to another group"
|
||
onclick={() => openPicker({ kind: 'move', itemId: i.id })}
|
||
><MoveRight size={14} /></button
|
||
>
|
||
<form
|
||
method="POST"
|
||
action="?/moveItem"
|
||
use:enhance={submitAction}
|
||
class="contents"
|
||
>
|
||
<input type="hidden" name="itemId" value={i.id} />
|
||
<input type="hidden" name="into" value="new" />
|
||
<button
|
||
type="submit"
|
||
class="icon"
|
||
title="Split into its own group"
|
||
aria-label="Split into its own group"><Split size={14} /></button
|
||
>
|
||
</form>
|
||
{#if i.matchLocked}
|
||
<form
|
||
method="POST"
|
||
action="?/unlockItem"
|
||
use:enhance={submitAction}
|
||
class="contents"
|
||
>
|
||
<input type="hidden" name="itemId" value={i.id} />
|
||
<button
|
||
type="submit"
|
||
class="icon"
|
||
title="Let the nightly matcher place it again"
|
||
aria-label="Unlock"><Unlock size={14} /></button
|
||
>
|
||
</form>
|
||
{/if}
|
||
</td>
|
||
</tr>
|
||
{/each}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{#if d.similar.length}
|
||
<h3 class="mt-5 mb-2 text-sm font-medium">Similar (other brands)</h3>
|
||
<ul class="grid gap-1 text-sm">
|
||
{#each d.similar as s (s.id)}
|
||
<li class="flex justify-between gap-3">
|
||
<a href={withParams({ g: String(s.id) })} data-sveltekit-noscroll class="title"
|
||
>{s.brand ? `${s.brand} · ` : ''}{s.name}</a
|
||
>
|
||
<span class="num meta"
|
||
>{formatUnitPrice(s.minUnitPriceCents, s.unit) ?? ''} · {s.storeCount} stores</span
|
||
>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
|
||
<form
|
||
method="POST"
|
||
action="?/rename"
|
||
use:enhance={submitAction}
|
||
class="mt-5 grid gap-2 border-t pt-4"
|
||
>
|
||
<input type="hidden" name="id" value={d.group.id} />
|
||
<label class="field"
|
||
>Group name <Input name="name" value={d.group.name} maxlength={200} required /></label
|
||
>
|
||
<label class="field"
|
||
>Notes <Textarea
|
||
name="notes"
|
||
value={d.group.notes ?? ''}
|
||
rows={2}
|
||
maxlength={2000}
|
||
/></label
|
||
>
|
||
<div class="flex justify-end"><Button type="submit" size="sm">Save</Button></div>
|
||
</form>
|
||
{/if}
|
||
</Dialog.Content>
|
||
</Dialog.Root>
|
||
|
||
<style>
|
||
.panel {
|
||
border: 1px solid var(--site-border);
|
||
border-radius: 0.75rem;
|
||
background: var(--site-surface);
|
||
padding: 1rem 1.25rem;
|
||
}
|
||
.stats {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 1.25rem;
|
||
}
|
||
.stats dt {
|
||
font-size: 0.75rem;
|
||
color: var(--site-muted);
|
||
}
|
||
.stats dd {
|
||
font-size: 1.5rem;
|
||
font-weight: 600;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.thumb {
|
||
width: 48px;
|
||
height: 48px;
|
||
object-fit: contain;
|
||
border-radius: 0.375rem;
|
||
background: white;
|
||
}
|
||
table th {
|
||
font-size: 0.7rem;
|
||
font-weight: 500;
|
||
color: var(--site-muted);
|
||
padding: 0.35rem 0.5rem;
|
||
border-bottom: 1px solid var(--site-border);
|
||
white-space: nowrap;
|
||
}
|
||
table td {
|
||
padding: 0.35rem 0.5rem;
|
||
border-bottom: 1px solid color-mix(in srgb, var(--site-border) 50%, transparent);
|
||
vertical-align: top;
|
||
}
|
||
tbody tr:last-child td {
|
||
border-bottom: none;
|
||
}
|
||
tr.gone {
|
||
opacity: 0.5;
|
||
}
|
||
.name a:hover,
|
||
.title:hover {
|
||
text-decoration: underline;
|
||
text-underline-offset: 3px;
|
||
}
|
||
.title {
|
||
font-weight: 500;
|
||
}
|
||
.meta {
|
||
font-size: 0.75rem;
|
||
color: var(--site-muted);
|
||
}
|
||
.num {
|
||
text-align: right;
|
||
font-variant-numeric: tabular-nums;
|
||
white-space: nowrap;
|
||
}
|
||
.num s {
|
||
margin-left: 0.25rem;
|
||
}
|
||
.primary {
|
||
font-weight: 500;
|
||
}
|
||
.best,
|
||
.legend.best {
|
||
color: rgb(22 163 74);
|
||
font-weight: 600;
|
||
background: rgb(34 197 94 / 0.12);
|
||
}
|
||
.store-best,
|
||
.legend.store-best {
|
||
box-shadow: inset 2px 0 0 rgb(34 197 94 / 0.6);
|
||
}
|
||
.legend {
|
||
font-size: 0.7rem;
|
||
padding: 0.05rem 0.4rem;
|
||
border-radius: 0.25rem;
|
||
margin-left: 0.4rem;
|
||
}
|
||
.store {
|
||
display: inline-block;
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
padding: 0.05rem 0.4rem;
|
||
border-radius: 0.25rem;
|
||
white-space: nowrap;
|
||
color: var(--store);
|
||
background: color-mix(in srgb, var(--store) 14%, transparent);
|
||
}
|
||
.promo {
|
||
margin-left: 0.3rem;
|
||
font-size: 0.65rem;
|
||
padding: 0.05rem 0.35rem;
|
||
border-radius: 999px;
|
||
background: rgb(245 158 11 / 0.18);
|
||
color: rgb(217 119 6);
|
||
white-space: nowrap;
|
||
}
|
||
.more,
|
||
.pager {
|
||
display: inline-block;
|
||
margin-top: 0.4rem;
|
||
font-size: 0.8rem;
|
||
color: var(--site-muted);
|
||
}
|
||
.more:hover,
|
||
.pager:hover {
|
||
color: var(--site-text);
|
||
}
|
||
.check {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
font-size: 0.85rem;
|
||
}
|
||
.field {
|
||
display: grid;
|
||
gap: 0.25rem;
|
||
font-size: 0.8rem;
|
||
color: var(--site-muted);
|
||
}
|
||
.hit {
|
||
display: flex;
|
||
width: 100%;
|
||
justify-content: space-between;
|
||
gap: 0.75rem;
|
||
text-align: left;
|
||
padding: 0.3rem 0.5rem;
|
||
border-radius: 0.375rem;
|
||
font-size: 0.85rem;
|
||
}
|
||
.hit:hover {
|
||
background: color-mix(in srgb, var(--site-muted) 15%, transparent);
|
||
}
|
||
.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);
|
||
}
|
||
.icon.fav {
|
||
color: rgb(234 179 8);
|
||
}
|
||
.segmented {
|
||
display: inline-flex;
|
||
border: 1px solid var(--site-border);
|
||
border-radius: 0.5rem;
|
||
overflow: hidden;
|
||
}
|
||
.segmented button {
|
||
font-size: 0.8rem;
|
||
padding: 0.35rem 0.7rem;
|
||
color: var(--site-muted);
|
||
}
|
||
.segmented button[aria-pressed='true'] {
|
||
background: color-mix(in srgb, var(--site-text) 12%, transparent);
|
||
color: var(--site-text);
|
||
font-weight: 500;
|
||
}
|
||
</style>
|