feat(web): polish discovery admin page and drawer
Discovery drawer
- Wrap each section in a rounded card so boundaries are visible without
parsing the uppercase headers.
- Header: N Quellen and enrichment_status become consistent pills,
matching the existing konfidenz pill treatment.
- Enrichment: replace the inline "(llm)"/"(crawl)" trailing text with a
color-coded badge on the label side (purple = llm, sky = crawl).
- Empty enrichment state now tells the operator how to trigger it.
- Audit timestamp uses a local-time helper so the displayed time
matches the browser timezone (was UTC-as-local).
- Quellen list: prefix each URL with its hostname for scannability;
long URLs truncated with full URL in the title attribute.
ContributionsPanel
- Amber border/background now only on conflict rows; every row
previously got border-amber-100 unconditionally, which diluted the
conflict signal. Rang 1 badge flipped to emerald so it reads as a
positive "winner" marker, not a warning.
Discovery page
- Remove dead dateInputValue() function and the stale
a11y_click_events_have_key_events suppression — both flagged by
eslint after earlier refactors.
- Render crawl/enrich timestamps in the browser's local timezone via a
new fmtLocalStamp helper; the previous .slice(0,16).replace('T',' ')
treated the ISO UTC string as if it were local time.
This commit is contained in:
@@ -111,9 +111,7 @@
|
||||
// Scroll into view so the highlighted row stays visible during
|
||||
// keyboard-driven scanning through a long list.
|
||||
requestAnimationFrame(() => {
|
||||
document
|
||||
.querySelector(`[data-row-id="${selectedId}"]`)
|
||||
?.scrollIntoView({ block: 'nearest' });
|
||||
document.querySelector(`[data-row-id="${selectedId}"]`)?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -269,11 +267,6 @@
|
||||
const showingFrom = $derived(data.offset + 1);
|
||||
const showingTo = $derived(Math.min(data.offset + data.limit, data.total ?? 0));
|
||||
|
||||
// 'YYYY-MM-DDTHH:mm:ssZ' → 'YYYY-MM-DD' for <input type="date">
|
||||
function dateInputValue(iso: string | null): string {
|
||||
return iso ? iso.slice(0, 10) : '';
|
||||
}
|
||||
|
||||
// 'YYYY-MM-DDTHH:mm:ssZ' → 'DD.MM.YYYY' (German) for display.
|
||||
function fmtDate(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
@@ -289,6 +282,13 @@
|
||||
return s || e || '';
|
||||
}
|
||||
|
||||
// Backend emits UTC; render in the browser's local timezone.
|
||||
function fmtLocalStamp(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function konfidenzClass(k: string): string {
|
||||
switch (k) {
|
||||
case 'hoch':
|
||||
@@ -578,9 +578,7 @@
|
||||
<div class="flex items-baseline justify-between">
|
||||
<h2 class="font-semibold">Crawl-Ergebnis</h2>
|
||||
<span class="font-mono text-xs text-stone-500"
|
||||
>{(s.duration_ms / 1000).toFixed(1)} s · {s.started_at
|
||||
.slice(0, 16)
|
||||
.replace('T', ' ')}</span
|
||||
>{(s.duration_ms / 1000).toFixed(1)} s · {fmtLocalStamp(s.started_at)}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -671,9 +669,7 @@
|
||||
<div class="flex items-baseline justify-between">
|
||||
<h2 class="font-semibold">Crawl-Enrich-Ergebnis</h2>
|
||||
<span class="font-mono text-xs text-stone-500"
|
||||
>{(es.duration_ms / 1000).toFixed(1)} s · {es.started_at
|
||||
.slice(0, 16)
|
||||
.replace('T', ' ')}</span
|
||||
>{(es.duration_ms / 1000).toFixed(1)} s · {fmtLocalStamp(es.started_at)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-3 gap-2">
|
||||
@@ -817,7 +813,6 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each queue as row (row.id)}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<tr
|
||||
data-row-id={row.id}
|
||||
class="cursor-pointer border-b border-stone-100 align-top hover:bg-stone-50 dark:border-stone-800 dark:hover:bg-stone-900/50 {drawerId ===
|
||||
@@ -981,7 +976,9 @@
|
||||
row={openRow}
|
||||
onClose={closeDrawer}
|
||||
llmEnriching={openRow ? llmEnrichingIds.has(openRow.id) : false}
|
||||
llmEnrichError={openRow && form?.llmEnrichErrorId === openRow.id ? (form.llmEnrichError ?? '') : ''}
|
||||
llmEnrichError={openRow && form?.llmEnrichErrorId === openRow.id
|
||||
? (form.llmEnrichError ?? '')
|
||||
: ''}
|
||||
/>
|
||||
|
||||
<!-- Keyboard shortcut help modal. Triggered by '?'. -->
|
||||
|
||||
@@ -44,14 +44,12 @@
|
||||
{ label: 'Veranstalter', key: 'organizer' }
|
||||
];
|
||||
|
||||
// Returns the display value for a contribution field.
|
||||
function cellValue(c: SourceContribution, field: FieldDef): string {
|
||||
const raw = c[field.key] as string | null | undefined;
|
||||
if (field.format) return field.format(raw);
|
||||
return raw ?? '';
|
||||
}
|
||||
|
||||
// A field has a conflict if at least two sources have non-empty, different values.
|
||||
function hasConflict(field: FieldDef): boolean {
|
||||
const values = contributions.map((c) => cellValue(c, field)).filter((v) => v !== '');
|
||||
if (values.length < 2) return false;
|
||||
@@ -63,26 +61,38 @@
|
||||
<p class="mt-2 text-xs text-stone-400">Keine Quelldaten vorhanden.</p>
|
||||
{:else}
|
||||
<div class="mt-2 overflow-x-auto">
|
||||
<p class="mb-2 text-xs text-stone-500">
|
||||
Rang 1 = höchste Priorität · Gewinnende Quelle:
|
||||
<span class="font-mono font-medium text-stone-700 dark:text-stone-300"
|
||||
>{contributions[0].source_name}</span
|
||||
>
|
||||
· Amber = abweichende Werte zwischen Quellen
|
||||
</p>
|
||||
<div class="mb-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span
|
||||
class="inline-block h-2 w-2 rounded-full bg-emerald-500 dark:bg-emerald-400"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
Gewinnende Quelle:
|
||||
<span class="font-mono font-medium text-stone-700 dark:text-stone-300"
|
||||
>{contributions[0].source_name}</span
|
||||
>
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span
|
||||
class="inline-block h-2 w-2 rounded-full bg-amber-500 dark:bg-amber-400"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
Abweichende Werte zwischen Quellen
|
||||
</span>
|
||||
</div>
|
||||
<table class="w-full text-left text-xs">
|
||||
<thead>
|
||||
<tr
|
||||
class="border-b border-amber-200 text-amber-700 dark:border-amber-800 dark:text-amber-400"
|
||||
class="border-b border-stone-200 text-stone-500 dark:border-stone-700 dark:text-stone-400"
|
||||
>
|
||||
<th class="pr-4 pb-1 font-medium">Feld</th>
|
||||
{#each contributions as c, i}
|
||||
<th class="pr-4 pb-1 font-medium">
|
||||
<span class="font-mono">{c.source_name}</span>
|
||||
<span class="font-mono text-stone-700 dark:text-stone-200">{c.source_name}</span>
|
||||
{#if i === 0}
|
||||
<span
|
||||
class="ml-1 rounded bg-amber-100 px-1 text-[10px] text-amber-700 dark:bg-amber-900/50 dark:text-amber-300"
|
||||
>Rang 1</span
|
||||
class="ml-1 rounded bg-emerald-100 px-1 text-[10px] font-medium text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300"
|
||||
title="Höchste Priorität — liefert die Gewinnerwerte">Rang 1</span
|
||||
>
|
||||
{/if}
|
||||
</th>
|
||||
@@ -93,18 +103,18 @@
|
||||
{#each fields as field}
|
||||
{@const conflict = hasConflict(field)}
|
||||
<tr
|
||||
class="border-b border-amber-100 dark:border-amber-900 {conflict
|
||||
? 'bg-amber-50 dark:bg-amber-950/30'
|
||||
: ''}"
|
||||
class={conflict
|
||||
? 'border-b border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30'
|
||||
: 'border-b border-stone-100 dark:border-stone-800'}
|
||||
>
|
||||
<td
|
||||
class="py-1 pr-4 font-medium text-stone-500 dark:text-stone-400 {conflict
|
||||
class="py-1 pr-4 font-medium {conflict
|
||||
? 'text-amber-700 dark:text-amber-400'
|
||||
: ''}"
|
||||
: 'text-stone-500 dark:text-stone-400'}"
|
||||
>
|
||||
{field.label}
|
||||
{#if conflict}
|
||||
<span title="Konflikt zwischen Quellen">⚠</span>
|
||||
<span title="Konflikt zwischen Quellen" aria-hidden="true">⚠</span>
|
||||
{/if}
|
||||
</td>
|
||||
{#each contributions as c}
|
||||
|
||||
@@ -177,6 +177,12 @@
|
||||
const [y, m, day] = d.split('-');
|
||||
return y && m && day ? `${day}.${m}.${y}` : d;
|
||||
}
|
||||
// Backend emits UTC; render in the browser's local timezone.
|
||||
function fmtLocalStamp(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
function konfidenzClass(k: string): string {
|
||||
switch (k) {
|
||||
case 'hoch':
|
||||
@@ -189,8 +195,58 @@
|
||||
return 'bg-stone-100 text-stone-600 dark:bg-stone-800 dark:text-stone-400';
|
||||
}
|
||||
}
|
||||
function provenanceClass(src: string | undefined): string {
|
||||
if (src === 'llm') {
|
||||
return 'bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300';
|
||||
}
|
||||
if (src === 'crawl') {
|
||||
return 'bg-sky-100 text-sky-700 dark:bg-sky-900/50 dark:text-sky-300';
|
||||
}
|
||||
return 'bg-stone-100 text-stone-500 dark:bg-stone-800 dark:text-stone-400';
|
||||
}
|
||||
function hostnameOf(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
function enrichmentStatusClass(s: string): string {
|
||||
if (s === 'done')
|
||||
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300';
|
||||
if (s === 'failed') return 'bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300';
|
||||
return 'bg-stone-100 text-stone-600 dark:bg-stone-800 dark:text-stone-300';
|
||||
}
|
||||
|
||||
// Truthy iff the row has at least one enrichment value worth rendering.
|
||||
const hasEnrichment = $derived.by(() => {
|
||||
const en = row?.enrichment;
|
||||
if (!en) return false;
|
||||
return !!(
|
||||
en.plz ||
|
||||
en.venue ||
|
||||
en.organizer ||
|
||||
en.category ||
|
||||
en.opening_hours ||
|
||||
en.description ||
|
||||
(en.lat != null && en.lng != null)
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet sourceBadge(src: string | undefined)}
|
||||
{#if src}
|
||||
<span
|
||||
class="inline-flex items-center rounded px-1 text-[9px] font-medium tracking-wide uppercase {provenanceClass(
|
||||
src
|
||||
)}"
|
||||
title="Datenquelle: {src}"
|
||||
>
|
||||
{src}
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if row}
|
||||
<!-- Backdrop: clicking outside closes. Keyboard events handled by parent. -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
@@ -203,7 +259,7 @@
|
||||
></div>
|
||||
|
||||
<aside
|
||||
class="fixed top-0 right-0 bottom-0 z-50 w-full overflow-y-auto border-l border-stone-200 bg-white shadow-xl sm:w-2/3 dark:border-stone-700 dark:bg-stone-900"
|
||||
class="fixed top-0 right-0 bottom-0 z-50 flex w-full flex-col overflow-y-auto border-l border-stone-200 bg-stone-50 shadow-xl sm:w-2/3 dark:border-stone-700 dark:bg-stone-950"
|
||||
aria-label="Marktdetails"
|
||||
>
|
||||
<!-- Header -->
|
||||
@@ -212,18 +268,30 @@
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="truncate text-lg font-semibold">{row.markt_name}</h2>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-stone-500">
|
||||
<span>{row.stadt}</span>
|
||||
<div class="mt-1.5 flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span class="text-stone-500 dark:text-stone-400">{row.stadt}</span>
|
||||
{#if row.bundesland}
|
||||
<span>· {row.bundesland}</span>
|
||||
<span class="text-stone-400 dark:text-stone-500">·</span>
|
||||
<span class="text-stone-500 dark:text-stone-400">{row.bundesland}</span>
|
||||
{/if}
|
||||
<span
|
||||
class="rounded px-1.5 py-0.5 {konfidenzClass(row.konfidenz)}"
|
||||
class="rounded px-1.5 py-0.5 font-medium {konfidenzClass(row.konfidenz)}"
|
||||
title="Konfidenz"
|
||||
>
|
||||
{row.konfidenz || '—'}
|
||||
</span>
|
||||
<span class="font-mono">{row.sources.length} Quelle{row.sources.length === 1 ? '' : 'n'}</span>
|
||||
<span
|
||||
class="rounded bg-stone-100 px-1.5 py-0.5 font-mono font-medium text-stone-600 dark:bg-stone-800 dark:text-stone-300"
|
||||
title="Anzahl Quellen"
|
||||
>
|
||||
{row.sources.length} Quelle{row.sources.length === 1 ? '' : 'n'}
|
||||
</span>
|
||||
<span
|
||||
class="rounded px-1.5 py-0.5 font-medium {enrichmentStatusClass(row.enrichment_status)}"
|
||||
title="Enrichment-Status"
|
||||
>
|
||||
{row.enrichment_status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -231,7 +299,7 @@
|
||||
<input type="hidden" name="id" value={row.id} />
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-emerald-600 px-3 py-1.5 text-xs text-white hover:bg-emerald-700"
|
||||
class="rounded bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-700"
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
@@ -240,7 +308,7 @@
|
||||
<input type="hidden" name="id" value={row.id} />
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-stone-200 px-3 py-1.5 text-xs text-stone-700 hover:bg-stone-300 dark:bg-stone-700 dark:text-stone-200 dark:hover:bg-stone-600"
|
||||
class="rounded bg-stone-200 px-3 py-1.5 text-xs font-medium text-stone-700 hover:bg-stone-300 dark:bg-stone-700 dark:text-stone-200 dark:hover:bg-stone-600"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
@@ -248,7 +316,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="rounded px-2 py-1.5 text-stone-500 hover:bg-stone-100 hover:text-stone-800 dark:hover:bg-stone-800 dark:hover:text-stone-100"
|
||||
class="ml-1 rounded px-2 py-1.5 text-stone-500 hover:bg-stone-100 hover:text-stone-800 dark:hover:bg-stone-800 dark:hover:text-stone-100"
|
||||
aria-label="Drawer schließen"
|
||||
>
|
||||
✕
|
||||
@@ -256,15 +324,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6 px-6 py-6">
|
||||
<div class="flex-1 space-y-4 px-6 py-6">
|
||||
<!-- Identity / editable form -->
|
||||
<section>
|
||||
<section
|
||||
class="rounded-lg border border-stone-200 bg-white p-5 dark:border-stone-700 dark:bg-stone-900"
|
||||
>
|
||||
<h3
|
||||
class="text-xs font-medium tracking-wider text-stone-500 uppercase dark:text-stone-400"
|
||||
class="mb-3 text-xs font-semibold tracking-wider text-stone-500 uppercase dark:text-stone-400"
|
||||
>
|
||||
Identität
|
||||
</h3>
|
||||
<form method="POST" action="?/update" use:enhance class="mt-2 space-y-3 text-sm">
|
||||
<form method="POST" action="?/update" use:enhance class="space-y-3 text-sm">
|
||||
<input type="hidden" name="id" value={row.id} />
|
||||
<div>
|
||||
<label class="block text-xs text-stone-500" for="dd-name-{row.id}">Markt</label>
|
||||
@@ -335,7 +405,7 @@
|
||||
<div class="pt-1">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-blue-600 px-3 py-1.5 text-xs text-white hover:bg-blue-700"
|
||||
class="rounded bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
@@ -344,29 +414,21 @@
|
||||
</section>
|
||||
|
||||
<!-- Enrichment payload -->
|
||||
<section>
|
||||
<div class="flex items-center justify-between">
|
||||
<section
|
||||
class="rounded-lg border border-stone-200 bg-white p-5 dark:border-stone-700 dark:bg-stone-900"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3
|
||||
class="text-xs font-medium tracking-wider text-stone-500 uppercase dark:text-stone-400"
|
||||
class="text-xs font-semibold tracking-wider text-stone-500 uppercase dark:text-stone-400"
|
||||
>
|
||||
Enrichment
|
||||
<span
|
||||
class="ml-1 inline-block rounded px-1.5 py-0.5 text-[10px] {row.enrichment_status ===
|
||||
'done'
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300'
|
||||
: row.enrichment_status === 'failed'
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300'
|
||||
: 'bg-stone-100 text-stone-600 dark:bg-stone-800 dark:text-stone-300'}"
|
||||
>
|
||||
{row.enrichment_status}
|
||||
</span>
|
||||
</h3>
|
||||
<form method="POST" action="?/llmEnrich" use:enhance class="inline">
|
||||
<input type="hidden" name="id" value={row.id} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={llmEnriching}
|
||||
class="rounded bg-purple-100 px-2 py-1 text-xs text-purple-700 hover:bg-purple-200 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-purple-900/50 dark:text-purple-300 dark:hover:bg-purple-900"
|
||||
class="rounded bg-purple-100 px-2.5 py-1 text-xs font-medium text-purple-700 hover:bg-purple-200 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-purple-900/50 dark:text-purple-300 dark:hover:bg-purple-900"
|
||||
>
|
||||
{llmEnriching ? 'AI…' : 'AI enrich'}
|
||||
</button>
|
||||
@@ -374,99 +436,99 @@
|
||||
</div>
|
||||
{#if llmEnrichError}
|
||||
<p
|
||||
class="mt-2 rounded border border-red-300 bg-red-50 px-2 py-1 text-xs text-red-700 dark:border-red-700 dark:bg-red-950 dark:text-red-300"
|
||||
class="mb-3 rounded border border-red-300 bg-red-50 px-2 py-1 text-xs text-red-700 dark:border-red-700 dark:bg-red-950 dark:text-red-300"
|
||||
>
|
||||
{llmEnrichError}
|
||||
</p>
|
||||
{/if}
|
||||
<dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
{#if row.enrichment.plz}
|
||||
<dt class="text-stone-500">PLZ</dt>
|
||||
<dd>
|
||||
{row.enrichment.plz}
|
||||
<span class="ml-1 text-[10px] text-stone-400"
|
||||
>({row.enrichment.sources?.plz ?? '—'})</span
|
||||
>
|
||||
</dd>
|
||||
{/if}
|
||||
{#if row.enrichment.venue}
|
||||
<dt class="text-stone-500">Venue</dt>
|
||||
<dd>
|
||||
{row.enrichment.venue}
|
||||
<span class="ml-1 text-[10px] text-stone-400"
|
||||
>({row.enrichment.sources?.venue ?? '—'})</span
|
||||
>
|
||||
</dd>
|
||||
{/if}
|
||||
{#if row.enrichment.organizer}
|
||||
<dt class="text-stone-500">Organizer</dt>
|
||||
<dd>
|
||||
{row.enrichment.organizer}
|
||||
<span class="ml-1 text-[10px] text-stone-400"
|
||||
>({row.enrichment.sources?.organizer ?? '—'})</span
|
||||
>
|
||||
</dd>
|
||||
{/if}
|
||||
{#if row.enrichment.lat != null && row.enrichment.lng != null}
|
||||
<dt class="text-stone-500">Lat/Lng</dt>
|
||||
<dd class="font-mono">
|
||||
{row.enrichment.lat.toFixed(4)}, {row.enrichment.lng.toFixed(4)}
|
||||
<span class="ml-1 text-[10px] text-stone-400"
|
||||
>({row.enrichment.sources?.lat ?? '—'})</span
|
||||
>
|
||||
</dd>
|
||||
{/if}
|
||||
{#if row.enrichment.category}
|
||||
<dt class="text-stone-500">Category</dt>
|
||||
<dd>
|
||||
{row.enrichment.category}
|
||||
<span class="ml-1 text-[10px] text-stone-400"
|
||||
>({row.enrichment.sources?.category ?? '—'})</span
|
||||
>
|
||||
</dd>
|
||||
{/if}
|
||||
{#if row.enrichment.opening_hours}
|
||||
<dt class="text-stone-500">Öffnungszeiten</dt>
|
||||
<dd>
|
||||
{row.enrichment.opening_hours}
|
||||
<span class="ml-1 text-[10px] text-stone-400"
|
||||
>({row.enrichment.sources?.opening_hours ?? '—'})</span
|
||||
>
|
||||
</dd>
|
||||
{/if}
|
||||
{#if row.enrichment.description}
|
||||
<dt class="text-stone-500">Beschreibung</dt>
|
||||
<dd class="text-stone-600 dark:text-stone-300">
|
||||
{row.enrichment.description}
|
||||
<span class="ml-1 text-[10px] text-stone-400"
|
||||
>({row.enrichment.sources?.description ?? '—'})</span
|
||||
>
|
||||
</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
{#if !row.enrichment.plz && !row.enrichment.venue && !row.enrichment.organizer && !row.enrichment.category}
|
||||
<p class="mt-2 text-xs text-stone-400">
|
||||
Noch keine Enrichment-Daten. Crawl-Enrich oder AI enrich triggern.
|
||||
{#if hasEnrichment}
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-xs">
|
||||
{#if row.enrichment.plz}
|
||||
<dt class="flex items-center gap-1.5 text-stone-500">
|
||||
{@render sourceBadge(row.enrichment.sources?.plz)}
|
||||
<span>PLZ</span>
|
||||
</dt>
|
||||
<dd class="text-stone-700 dark:text-stone-200">{row.enrichment.plz}</dd>
|
||||
{/if}
|
||||
{#if row.enrichment.venue}
|
||||
<dt class="flex items-center gap-1.5 text-stone-500">
|
||||
{@render sourceBadge(row.enrichment.sources?.venue)}
|
||||
<span>Venue</span>
|
||||
</dt>
|
||||
<dd class="text-stone-700 dark:text-stone-200">{row.enrichment.venue}</dd>
|
||||
{/if}
|
||||
{#if row.enrichment.organizer}
|
||||
<dt class="flex items-center gap-1.5 text-stone-500">
|
||||
{@render sourceBadge(row.enrichment.sources?.organizer)}
|
||||
<span>Organizer</span>
|
||||
</dt>
|
||||
<dd class="text-stone-700 dark:text-stone-200">{row.enrichment.organizer}</dd>
|
||||
{/if}
|
||||
{#if row.enrichment.lat != null && row.enrichment.lng != null}
|
||||
<dt class="flex items-center gap-1.5 text-stone-500">
|
||||
{@render sourceBadge(row.enrichment.sources?.lat)}
|
||||
<span>Lat/Lng</span>
|
||||
</dt>
|
||||
<dd class="font-mono text-stone-700 dark:text-stone-200">
|
||||
{row.enrichment.lat.toFixed(4)}, {row.enrichment.lng.toFixed(4)}
|
||||
</dd>
|
||||
{/if}
|
||||
{#if row.enrichment.category}
|
||||
<dt class="flex items-center gap-1.5 text-stone-500">
|
||||
{@render sourceBadge(row.enrichment.sources?.category)}
|
||||
<span>Category</span>
|
||||
</dt>
|
||||
<dd class="text-stone-700 dark:text-stone-200">{row.enrichment.category}</dd>
|
||||
{/if}
|
||||
{#if row.enrichment.opening_hours}
|
||||
<dt class="flex items-center gap-1.5 text-stone-500">
|
||||
{@render sourceBadge(row.enrichment.sources?.opening_hours)}
|
||||
<span>Öffnungszeiten</span>
|
||||
</dt>
|
||||
<dd class="text-stone-700 dark:text-stone-200">{row.enrichment.opening_hours}</dd>
|
||||
{/if}
|
||||
{#if row.enrichment.description}
|
||||
<dt class="flex items-center gap-1.5 self-start text-stone-500">
|
||||
{@render sourceBadge(row.enrichment.sources?.description)}
|
||||
<span>Beschreibung</span>
|
||||
</dt>
|
||||
<dd class="text-stone-600 dark:text-stone-300">{row.enrichment.description}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
{:else}
|
||||
<p class="text-xs text-stone-400 dark:text-stone-500">
|
||||
Noch keine Enrichment-Daten. <span class="text-stone-500 dark:text-stone-400"
|
||||
>Crawl-Enrich läuft im Hintergrund, oder „AI enrich" oben triggern.</span
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Quellen URLs -->
|
||||
<section>
|
||||
<section
|
||||
class="rounded-lg border border-stone-200 bg-white p-5 dark:border-stone-700 dark:bg-stone-900"
|
||||
>
|
||||
<h3
|
||||
class="text-xs font-medium tracking-wider text-stone-500 uppercase dark:text-stone-400"
|
||||
class="mb-3 text-xs font-semibold tracking-wider text-stone-500 uppercase dark:text-stone-400"
|
||||
>
|
||||
Quellen
|
||||
</h3>
|
||||
{#if row.quellen && row.quellen.length > 0}
|
||||
<ul class="mt-2 space-y-1 text-xs">
|
||||
<ul class="space-y-1.5 text-xs">
|
||||
{#each row.quellen as url, i (i)}
|
||||
<li class="break-all">
|
||||
<li class="flex items-baseline gap-2">
|
||||
<span
|
||||
class="inline-block w-20 shrink-0 truncate font-mono text-[10px] text-stone-400 dark:text-stone-500"
|
||||
title={hostnameOf(url)}
|
||||
>
|
||||
{hostnameOf(url)}
|
||||
</span>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
class="text-blue-600 underline hover:text-blue-500 dark:text-blue-400"
|
||||
class="truncate text-blue-600 hover:underline dark:text-blue-400"
|
||||
title={url}
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
@@ -474,15 +536,17 @@
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="mt-2 text-xs text-stone-400">—</p>
|
||||
<p class="text-xs text-stone-400">—</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Per-source contributions -->
|
||||
{#if row.source_contributions && row.source_contributions.length > 1}
|
||||
<section>
|
||||
<section
|
||||
class="rounded-lg border border-stone-200 bg-white p-5 dark:border-stone-700 dark:bg-stone-900"
|
||||
>
|
||||
<h3
|
||||
class="text-xs font-medium tracking-wider text-stone-500 uppercase dark:text-stone-400"
|
||||
class="mb-3 text-xs font-semibold tracking-wider text-stone-500 uppercase dark:text-stone-400"
|
||||
>
|
||||
Quellen-Vergleich ({row.sources.length})
|
||||
</h3>
|
||||
@@ -491,20 +555,24 @@
|
||||
{/if}
|
||||
|
||||
<!-- Similar candidates -->
|
||||
<section>
|
||||
<section
|
||||
class="rounded-lg border border-stone-200 bg-white p-5 dark:border-stone-700 dark:bg-stone-900"
|
||||
>
|
||||
<h3
|
||||
class="text-xs font-medium tracking-wider text-stone-500 uppercase dark:text-stone-400"
|
||||
class="mb-3 text-xs font-semibold tracking-wider text-stone-500 uppercase dark:text-stone-400"
|
||||
>
|
||||
Ähnliche Einträge
|
||||
</h3>
|
||||
{#if similarLoading}
|
||||
<p class="mt-2 text-xs text-stone-400">Laden…</p>
|
||||
<p class="text-xs text-stone-400">Laden…</p>
|
||||
{:else if similarEntries.length === 0}
|
||||
<p class="mt-2 text-xs text-stone-400">Keine ähnlichen Einträge gefunden.</p>
|
||||
<p class="text-xs text-stone-400">Keine ähnlichen Einträge gefunden.</p>
|
||||
{:else}
|
||||
<table class="mt-2 w-full text-left text-xs">
|
||||
<table class="w-full text-left text-xs">
|
||||
<thead>
|
||||
<tr class="border-b border-stone-200 text-stone-500 dark:border-stone-700">
|
||||
<tr
|
||||
class="border-b border-stone-200 text-stone-500 dark:border-stone-700 dark:text-stone-400"
|
||||
>
|
||||
<th class="pr-4 pb-1 font-medium">Score</th>
|
||||
<th class="pr-4 pb-1 font-medium">Markt</th>
|
||||
<th class="pr-4 pb-1 font-medium">Stadt</th>
|
||||
@@ -515,23 +583,21 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each similarEntries as m}
|
||||
<tr class="border-b border-stone-100 dark:border-stone-800">
|
||||
<td class="py-1 pr-4 text-stone-500 tabular-nums"
|
||||
>{(m.score * 100).toFixed(0)}%</td
|
||||
>
|
||||
<td class="py-1 pr-4 font-medium">{m.entry.markt_name}</td>
|
||||
<td class="py-1 pr-4">{m.entry.stadt}</td>
|
||||
<td class="py-1 pr-4 whitespace-nowrap">{fmtDate(m.entry.start_datum)}</td>
|
||||
<td class="py-1 pr-4">
|
||||
<tr class="border-b border-stone-100 last:border-b-0 dark:border-stone-800">
|
||||
<td class="py-1.5 pr-4 text-stone-500 tabular-nums">
|
||||
{(m.score * 100).toFixed(0)}%
|
||||
</td>
|
||||
<td class="py-1.5 pr-4 font-medium">{m.entry.markt_name}</td>
|
||||
<td class="py-1.5 pr-4">{m.entry.stadt}</td>
|
||||
<td class="py-1.5 pr-4 whitespace-nowrap">{fmtDate(m.entry.start_datum)}</td>
|
||||
<td class="py-1.5 pr-4">
|
||||
<span
|
||||
class="inline-block rounded px-1.5 py-0.5 {konfidenzClass(
|
||||
m.entry.konfidenz
|
||||
)}"
|
||||
class="inline-block rounded px-1.5 py-0.5 {konfidenzClass(m.entry.konfidenz)}"
|
||||
>
|
||||
{m.entry.konfidenz || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-1">
|
||||
<td class="py-1.5">
|
||||
{#if verdicts[m.entry.id]}
|
||||
{@const v = verdicts[m.entry.id]}
|
||||
<span
|
||||
@@ -540,7 +606,8 @@
|
||||
: 'bg-stone-200 text-stone-700 dark:bg-stone-700 dark:text-stone-200'}"
|
||||
title={v.reason}
|
||||
>
|
||||
{v.same ? '✓ same' : '✗ diff'} {(v.confidence * 100).toFixed(0)}%
|
||||
{v.same ? '✓ same' : '✗ diff'}
|
||||
{(v.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
{:else}
|
||||
<button
|
||||
@@ -561,15 +628,19 @@
|
||||
</section>
|
||||
|
||||
<!-- Audit metadata -->
|
||||
<section>
|
||||
<section
|
||||
class="rounded-lg border border-stone-200 bg-white p-5 dark:border-stone-700 dark:bg-stone-900"
|
||||
>
|
||||
<h3
|
||||
class="text-xs font-medium tracking-wider text-stone-500 uppercase dark:text-stone-400"
|
||||
class="mb-3 text-xs font-semibold tracking-wider text-stone-500 uppercase dark:text-stone-400"
|
||||
>
|
||||
Audit
|
||||
</h3>
|
||||
<dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-xs">
|
||||
<dt class="text-stone-500">Discovered</dt>
|
||||
<dd class="font-mono">{row.discovered_at.slice(0, 16).replace('T', ' ')}</dd>
|
||||
<dd class="font-mono text-stone-700 dark:text-stone-300">
|
||||
{fmtLocalStamp(row.discovered_at)}
|
||||
</dd>
|
||||
{#if row.agent_status && row.agent_status !== 'bestaetigt'}
|
||||
<dt class="text-stone-500">Agent-Status</dt>
|
||||
<dd>{row.agent_status}</dd>
|
||||
|
||||
Reference in New Issue
Block a user