feat(articles): bulk-import UI (Phase 5)

apps/mana/apps/web/src/lib/modules/articles/components/:
  - BulkImportForm.svelte: <textarea> + live-validating $derived parser,
    counter chips for valid/duplicate/invalid, expandable invalid-list,
    submit creates a job + navigates to /articles/import/[jobId].
  - JobsList.svelte: index of past + active jobs (newest first), status
    pill + progress + per-counter chips. Click row → detail.
  - JobDetailView.svelte: live header (status, progress bar, counters),
    action bar (pause/resume/cancel/retry-failed/delete), per-item rows
    with state pill + URL + open-link or error tooltip.

apps/mana/apps/web/src/routes/(app)/articles/import/:
  - +page.svelte: hosts BulkImportForm + JobsList.
  - [jobId]/+page.svelte: hosts JobDetailView.

AddUrlForm.svelte: small "Mehrere URLs auf einmal? → Bulk-Import" link
under the single-URL input so the existing flow surfaces the new path.

The whole UI is a pure liveQuery view — JobDetailView re-renders as
the server-worker writes counter updates and item-state transitions
through sync_changes. Worker tick + pickup-consumer (already shipped
in 5535f2da4 + a9bcd4183) close the loop end-to-end.

Phase 6 (Domain-Events + AI-Tool) and Phase 7 (Tests) follow.

Plan: docs/plans/articles-bulk-import.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-28 22:30:36 +02:00
parent 29cbaf30f5
commit 5f0a1b5053
6 changed files with 773 additions and 1 deletions

View file

@ -257,6 +257,10 @@
</button>
</div>
<p class="bulk-link">
Mehrere URLs auf einmal? <a href="/articles/import">Bulk-Import →</a>
</p>
{#if (loading || saving) && !error && !preview && !duplicate}
<div class="loading-block" role="status">
<span class="spinner" aria-hidden="true"></span>
@ -354,7 +358,19 @@
.input-row {
display: flex;
gap: 0.55rem;
margin-bottom: 0.9rem;
margin-bottom: 0.45rem;
}
.bulk-link {
margin: 0 0 0.9rem 0;
font-size: 0.85rem;
color: var(--color-text-muted, #64748b);
}
.bulk-link a {
color: #ea580c;
text-decoration: none;
}
.bulk-link a:hover {
text-decoration: underline;
}
.url-input {
flex: 1;

View file

@ -0,0 +1,206 @@
<!--
BulkImportForm — paste a list of URLs (one per line / whitespace /
comma separated), live-validates with `parseUrls`, kicks off an
import job + navigates to its detail view.
Plan: docs/plans/articles-bulk-import.md.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { articleImportsStore, parseUrls } from '../stores/imports.svelte';
let raw = $state('');
let busy = $state(false);
let error = $state<string | null>(null);
const parsed = $derived(parseUrls(raw));
async function handleSubmit() {
if (busy) return;
if (parsed.valid.length === 0) {
error = 'Mindestens eine gültige URL einfügen.';
return;
}
busy = true;
error = null;
try {
const jobId = await articleImportsStore.createJob(parsed.valid);
goto(`/articles/import/${jobId}`);
} catch (e) {
error = e instanceof Error ? e.message : 'Job konnte nicht erstellt werden.';
busy = false;
}
}
</script>
<div class="bulk-shell">
<header class="header">
<h1>Mehrere Artikel importieren</h1>
<p class="subtitle">
Eine URL pro Zeile (oder durch Leerzeichen / Komma getrennt). Mana extrahiert sie nacheinander
im Hintergrund.
</p>
</header>
<textarea
class="url-area"
bind:value={raw}
placeholder={'https://example.com/article-1\nhttps://example.com/article-2\n…'}
rows="10"
disabled={busy}
></textarea>
<div class="counter-row" aria-live="polite">
<span class="counter counter-valid">{parsed.valid.length} gültig</span>
{#if parsed.duplicates.length > 0}
<span class="counter counter-dup">{parsed.duplicates.length} doppelt (übersprungen)</span>
{/if}
{#if parsed.invalid.length > 0}
<span class="counter counter-invalid">{parsed.invalid.length} ungültig</span>
{/if}
</div>
{#if parsed.invalid.length > 0}
<details class="invalid-details">
<summary>Ungültige Zeilen anzeigen ({parsed.invalid.length})</summary>
<ul class="invalid-list">
{#each parsed.invalid as bad (bad)}
<li><code>{bad}</code></li>
{/each}
</ul>
</details>
{/if}
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
<div class="actions">
<button
type="button"
class="primary"
onclick={handleSubmit}
disabled={busy || parsed.valid.length === 0}
>
{#if busy}Erstelle Job…{:else}{parsed.valid.length} URLs importieren{/if}
</button>
</div>
<p class="hint">
Im Hintergrund — du kannst den Tab schließen und später zurückkommen. Bei 50 URLs dauert es grob
510 Minuten. Den Fortschritt siehst du auf der Detailseite.
</p>
</div>
<style>
.bulk-shell {
max-width: 760px;
margin: 0 auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.header h1 {
margin: 0 0 0.25rem 0;
font-size: 1.6rem;
}
.subtitle {
margin: 0;
color: var(--color-text-muted, #64748b);
font-size: 0.95rem;
}
.url-area {
width: 100%;
min-height: 11rem;
padding: 0.7rem 0.85rem;
border-radius: 0.6rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.15));
background: var(--color-surface, transparent);
font: inherit;
color: inherit;
font-family: 'SF Mono', Menlo, Consolas, monospace;
font-size: 0.88rem;
line-height: 1.45;
resize: vertical;
}
.url-area:focus {
outline: 2px solid #f97316;
outline-offset: 1px;
border-color: transparent;
}
.counter-row {
display: flex;
gap: 0.45rem;
flex-wrap: wrap;
font-size: 0.85rem;
}
.counter {
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-weight: 500;
}
.counter-valid {
background: color-mix(in srgb, #16a34a 12%, transparent);
color: #16a34a;
}
.counter-dup {
background: color-mix(in srgb, #f59e0b 12%, transparent);
color: #b45309;
}
.counter-invalid {
background: rgba(239, 68, 68, 0.12);
color: #ef4444;
}
.invalid-details {
font-size: 0.85rem;
}
.invalid-details summary {
cursor: pointer;
color: var(--color-text-muted, #64748b);
}
.invalid-list {
margin: 0.45rem 0 0 0.5rem;
padding-left: 0.85rem;
}
.invalid-list code {
font-size: 0.82rem;
word-break: break-all;
}
.error {
margin: 0;
padding: 0.55rem 0.85rem;
border-radius: 0.5rem;
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
font-size: 0.9rem;
}
.actions {
display: flex;
gap: 0.5rem;
}
.primary {
padding: 0.6rem 1.1rem;
border-radius: 0.55rem;
border: 1px solid #f97316;
background: #f97316;
color: white;
font: inherit;
font-weight: 500;
cursor: pointer;
}
.primary:hover:not(:disabled) {
background: #ea580c;
border-color: #ea580c;
}
.primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.hint {
margin: 0;
color: var(--color-text-muted, #64748b);
font-size: 0.82rem;
line-height: 1.45;
}
</style>

View file

@ -0,0 +1,376 @@
<!--
JobDetailView — live progress of a bulk-import job. Drives the
/articles/import/[jobId] route.
Header: status, total, counters, action bar (pause/resume/cancel/retry).
Body: per-item rows with state pill + URL + action link.
Plan: docs/plans/articles-bulk-import.md.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { articleImportsStore } from '../stores/imports.svelte';
import { useImportItems, useImportJob } from '../queries';
import type { ArticleImportItem, ArticleImportItemState } from '../types';
interface Props {
jobId: string;
}
let { jobId }: Props = $props();
const job$ = $derived(useImportJob(jobId));
const items$ = $derived(useImportItems(jobId));
const job = $derived(job$.value);
const items = $derived(items$.value);
let busyAction = $state<string | null>(null);
const totalDone = $derived(job ? job.savedCount + job.duplicateCount + job.errorCount : 0);
const progressPct = $derived(
job && job.totalUrls > 0 ? Math.round((totalDone / job.totalUrls) * 100) : 0
);
async function withBusy(name: string, fn: () => Promise<unknown>) {
busyAction = name;
try {
await fn();
} finally {
busyAction = null;
}
}
function statePill(state: ArticleImportItemState): { label: string; klass: string } {
switch (state) {
case 'pending':
return { label: 'Wartet', klass: 'pill-pending' };
case 'extracting':
return { label: 'Extrahiert…', klass: 'pill-extracting' };
case 'extracted':
return { label: 'Server fertig', klass: 'pill-extracted' };
case 'saved':
return { label: '✓ Gespeichert', klass: 'pill-saved' };
case 'duplicate':
return { label: '· Duplikat', klass: 'pill-dup' };
case 'consent-wall':
return { label: '⚠ Cookie-Wand', klass: 'pill-warn' };
case 'error':
return { label: '✗ Fehler', klass: 'pill-error' };
case 'cancelled':
return { label: 'Abgebrochen', klass: 'pill-cancelled' };
}
}
function shortUrl(item: ArticleImportItem): string {
try {
const u = new URL(item.url);
return u.host + u.pathname.replace(/\/$/, '');
} catch {
return item.url;
}
}
</script>
<div class="job-shell">
{#if !job}
<p class="empty">Job nicht gefunden.</p>
{:else}
{@const j = job}
<header class="header">
<div class="title-row">
<h1>Import-Job</h1>
<span class="status status-{j.status}">{j.status}</span>
</div>
<div class="progress-bar" aria-label="Fortschritt">
<div class="progress-fill" style="width: {progressPct}%"></div>
</div>
<div class="counters">
<span class="counter">
<strong>{totalDone}</strong> / {j.totalUrls} verarbeitet
</span>
{#if j.savedCount > 0}<span class="counter ok">{j.savedCount} gespeichert</span>{/if}
{#if j.duplicateCount > 0}
<span class="counter dup">{j.duplicateCount} Duplikate</span>
{/if}
{#if j.warningCount > 0}
<span class="counter warn">{j.warningCount} mit Cookie-Wand</span>
{/if}
{#if j.errorCount > 0}<span class="counter err">{j.errorCount} Fehler</span>{/if}
</div>
<div class="actions">
{#if j.status === 'running' || j.status === 'queued'}
<button
type="button"
class="secondary"
disabled={busyAction !== null}
onclick={() => withBusy('pause', () => articleImportsStore.pauseJob(jobId))}
>
Pause
</button>
{/if}
{#if j.status === 'paused'}
<button
type="button"
class="primary"
disabled={busyAction !== null}
onclick={() => withBusy('resume', () => articleImportsStore.resumeJob(jobId))}
>
Fortsetzen
</button>
{/if}
{#if j.status === 'running' || j.status === 'queued' || j.status === 'paused'}
<button
type="button"
class="danger"
disabled={busyAction !== null}
onclick={() => {
if (confirm('Job wirklich abbrechen? Bisherige Artikel bleiben gespeichert.'))
void withBusy('cancel', () => articleImportsStore.cancelJob(jobId));
}}
>
Abbrechen
</button>
{/if}
{#if j.errorCount > 0}
<button
type="button"
class="secondary"
disabled={busyAction !== null}
onclick={() => withBusy('retry', () => articleImportsStore.retryFailed(jobId))}
>
Fehler wiederholen
</button>
{/if}
{#if j.status === 'done' || j.status === 'cancelled'}
<button
type="button"
class="ghost"
disabled={busyAction !== null}
onclick={() => {
if (confirm('Job-Historie löschen? Artikel bleiben.')) {
void withBusy('delete', async () => {
await articleImportsStore.deleteJob(jobId);
goto('/articles/import');
});
}
}}
>
Löschen
</button>
{/if}
</div>
</header>
<ul class="items">
{#each items as item (item.id)}
{@const pill = statePill(item.state)}
<li class="item">
<span class="pill {pill.klass}">{pill.label}</span>
<span class="url" title={item.url}>{shortUrl(item)}</span>
{#if item.articleId && (item.state === 'saved' || item.state === 'consent-wall' || item.state === 'duplicate')}
<a class="action" href="/articles/{item.articleId}">Öffnen</a>
{:else if item.state === 'error' && item.error}
<span class="error-msg" title={item.error}>{item.error}</span>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
<style>
.job-shell {
max-width: 920px;
margin: 0 auto;
padding: 1.5rem;
}
.empty {
color: var(--color-text-muted, #64748b);
}
.header {
display: flex;
flex-direction: column;
gap: 0.65rem;
margin-bottom: 1.25rem;
}
.title-row {
display: flex;
gap: 0.6rem;
align-items: baseline;
}
.title-row h1 {
margin: 0;
font-size: 1.45rem;
}
.status {
font-size: 0.75rem;
padding: 0.12rem 0.55rem;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 600;
}
.status-queued,
.status-paused {
background: color-mix(in srgb, #64748b 14%, transparent);
color: #475569;
}
.status-running {
background: color-mix(in srgb, #f97316 16%, transparent);
color: #ea580c;
}
.status-done {
background: color-mix(in srgb, #16a34a 14%, transparent);
color: #16a34a;
}
.status-cancelled {
background: rgba(239, 68, 68, 0.14);
color: #ef4444;
}
.progress-bar {
height: 6px;
border-radius: 999px;
background: color-mix(in srgb, currentColor 8%, transparent);
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #f97316;
transition: width 220ms ease;
}
.counters {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
font-size: 0.85rem;
}
.counter {
padding: 0.12rem 0.55rem;
border-radius: 999px;
background: color-mix(in srgb, currentColor 6%, transparent);
}
.counter.ok {
color: #16a34a;
}
.counter.dup {
color: var(--color-text-muted, #64748b);
}
.counter.warn {
color: #b45309;
}
.counter.err {
color: #ef4444;
}
.actions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.actions button {
padding: 0.4rem 0.85rem;
border-radius: 0.45rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.15));
background: transparent;
color: inherit;
font: inherit;
font-size: 0.85rem;
cursor: pointer;
}
.actions .primary {
background: #f97316;
border-color: #f97316;
color: white;
}
.actions .primary:hover:not(:disabled) {
background: #ea580c;
}
.actions .danger:hover:not(:disabled) {
border-color: #ef4444;
color: #ef4444;
}
.actions .ghost {
opacity: 0.7;
}
.actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.items {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.item {
display: grid;
grid-template-columns: 9rem 1fr auto;
gap: 0.65rem;
align-items: center;
padding: 0.4rem 0.7rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
border-radius: 0.45rem;
font-size: 0.88rem;
}
.pill {
font-size: 0.76rem;
padding: 0.1rem 0.5rem;
border-radius: 999px;
text-align: center;
white-space: nowrap;
}
.pill-pending {
background: color-mix(in srgb, #64748b 10%, transparent);
color: #64748b;
}
.pill-extracting,
.pill-extracted {
background: color-mix(in srgb, #f97316 12%, transparent);
color: #ea580c;
}
.pill-saved {
background: color-mix(in srgb, #16a34a 14%, transparent);
color: #16a34a;
}
.pill-dup {
background: color-mix(in srgb, #64748b 12%, transparent);
color: #475569;
}
.pill-warn {
background: color-mix(in srgb, #f59e0b 14%, transparent);
color: #b45309;
}
.pill-error {
background: rgba(239, 68, 68, 0.14);
color: #ef4444;
}
.pill-cancelled {
background: color-mix(in srgb, #64748b 8%, transparent);
color: #64748b;
opacity: 0.7;
}
.url {
font-family: 'SF Mono', Menlo, Consolas, monospace;
font-size: 0.82rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.action {
font-size: 0.82rem;
color: #ea580c;
text-decoration: none;
}
.action:hover {
text-decoration: underline;
}
.error-msg {
font-size: 0.78rem;
color: #ef4444;
max-width: 18rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View file

@ -0,0 +1,143 @@
<!--
JobsList — index of all bulk-import jobs in the active space, newest
first. Click → /articles/import/[jobId].
Plan: docs/plans/articles-bulk-import.md.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { useImportJobs } from '../queries';
import type { ArticleImportJob } from '../types';
const jobs$ = useImportJobs();
const jobs = $derived(jobs$.value);
function progress(job: ArticleImportJob): string {
const done = job.savedCount + job.duplicateCount + job.errorCount;
return `${done} / ${job.totalUrls}`;
}
function statusLabel(s: ArticleImportJob['status']): string {
switch (s) {
case 'queued':
return 'Wartet';
case 'running':
return 'Läuft';
case 'paused':
return 'Pausiert';
case 'done':
return 'Fertig';
case 'cancelled':
return 'Abgebrochen';
}
}
</script>
{#if jobs.length > 0}
<section class="jobs-list">
<h2>Bisherige Imports</h2>
<ul>
{#each jobs as job (job.id)}
<button type="button" class="row" onclick={() => goto(`/articles/import/${job.id}`)}>
<span class="status status-{job.status}">{statusLabel(job.status)}</span>
<span class="progress">{progress(job)}</span>
<span class="meta">
{#if job.errorCount > 0}<span class="meta-err">{job.errorCount} Fehler</span>{/if}
{#if job.duplicateCount > 0}
<span class="meta-dup">{job.duplicateCount} Duplikate</span>
{/if}
{#if job.warningCount > 0}
<span class="meta-warn">{job.warningCount} Warnungen</span>
{/if}
</span>
<span class="when">{new Date(job.createdAt).toLocaleString('de-DE')}</span>
</button>
{/each}
</ul>
</section>
{/if}
<style>
.jobs-list {
max-width: 760px;
margin: 1.5rem auto 0;
padding: 0 1.5rem;
}
.jobs-list h2 {
margin: 0 0 0.65rem 0;
font-size: 1.05rem;
}
.jobs-list ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.row {
display: grid;
grid-template-columns: 6rem 5rem 1fr auto;
gap: 0.65rem;
align-items: center;
padding: 0.55rem 0.75rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
border-radius: 0.55rem;
background: var(--color-surface, transparent);
font: inherit;
text-align: left;
cursor: pointer;
}
.row:hover {
border-color: color-mix(in srgb, #f97316 60%, transparent);
}
.status {
font-size: 0.78rem;
font-weight: 500;
padding: 0.12rem 0.5rem;
border-radius: 999px;
text-align: center;
}
.status-queued,
.status-paused {
background: color-mix(in srgb, #64748b 12%, transparent);
color: #475569;
}
.status-running {
background: color-mix(in srgb, #f97316 14%, transparent);
color: #ea580c;
}
.status-done {
background: color-mix(in srgb, #16a34a 14%, transparent);
color: #16a34a;
}
.status-cancelled {
background: rgba(239, 68, 68, 0.14);
color: #ef4444;
}
.progress {
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.meta {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
font-size: 0.78rem;
color: var(--color-text-muted, #64748b);
}
.meta-err {
color: #ef4444;
}
.meta-warn {
color: #b45309;
}
.meta-dup {
color: var(--color-text-muted, #64748b);
}
.when {
font-size: 0.78rem;
color: var(--color-text-muted, #64748b);
white-space: nowrap;
}
</style>

View file

@ -0,0 +1,14 @@
<script lang="ts">
import BulkImportForm from '$lib/modules/articles/components/BulkImportForm.svelte';
import JobsList from '$lib/modules/articles/components/JobsList.svelte';
import { RoutePage } from '$lib/components/shell';
</script>
<svelte:head>
<title>Artikel-Import — Mana</title>
</svelte:head>
<RoutePage appId="articles" backHref="/articles">
<BulkImportForm />
<JobsList />
</RoutePage>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import { page } from '$app/stores';
import JobDetailView from '$lib/modules/articles/components/JobDetailView.svelte';
import { RoutePage } from '$lib/components/shell';
const jobId = $derived($page.params.jobId ?? '');
</script>
<svelte:head>
<title>Artikel-Import — Mana</title>
</svelte:head>
<RoutePage appId="articles" backHref="/articles/import">
{#if jobId}
<JobDetailView {jobId} />
{/if}
</RoutePage>