mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:01:09 +02:00
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:
parent
29cbaf30f5
commit
5f0a1b5053
6 changed files with 773 additions and 1 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
5–10 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue