feat(articles): M9 workbench homepage — 4-tab shell + QuickAdd + StatsView

Articles ist jetzt als Workbench-App in apps.ts registriert
(icon BookOpen, collection 'articles', paramKey 'articleId') und
landet damit im Scene-App-Picker. HomeView/ListView/HighlightsView/
StatsView teilen sich eine neue ArticlesTabShell, die sowohl als
SvelteKit-Route als auch als Workbench-Karte rendert.

Shell (ArticlesTabShell.svelte):
 - Top-Bar mit QuickAddInput (URL einfügen + Enter = Save + goto
   Reader; kein Preview-Schritt) und Settings-Gear.
 - Tab-Leiste darunter: Leseliste | Highlights | Favoriten | Stats.
   Leseliste ist Default (initialTab='list').
 - Tab-Wechsel läuft intern via $state + Svelte-Context — kritisch
   für die Workbench-Karte, wo goto() den User aus der Karte kicken
   würde. getArticlesTabContext() aus tab-context.ts gibt tief
   verschachtelten Sektionen eine switchTo(tab)-API.
 - Padding 1rem 1.25rem auf der Shell selbst — PageShell.page-body
   hat null padding, sonst klebt QuickAdd am Card-Rand. Im Route-
   Kontext addiert's sich zum (app)-Layout-Padding ohne zu viel.

Tabs:
 - Leseliste (list): bestehende ListView mit optionalem
   initialFilter-Prop. Continue-Reading-Strip (HomeSectionWeiterlesen
   horizontal carousel) erscheint über den Filter-Chips wenn
   status='reading'-Artikel existieren und filter ∈ {all, reading}.
   Filter-Chips sind einzeilig + horizontal scrollbar mit
   scroll-snap-Einrast; inaktive Chips haben jetzt sichtbare
   Background-Füllung + Border via color-mix(currentColor) — adaptiv
   fürs Theme.
 - Highlights (highlights): HighlightsView unverändert (nur der
   eigene Header + Zurück-Button raus, liegt jetzt in der Shell).
 - Favoriten (favorites): ListView mit initialFilter='favorites' —
   Shell-Shortcut auf den Filter.
 - Stats (stats): neue StatsView mit Stats-Strip (savedThisWeek,
   finishedThisWeek, avg reading time), Highlight-Counter, Top-
   Sources und Archiv-Link.

Routes (unter (tabs)-Gruppe):
 - /articles                → initialTab="list"   (Default)
 - /articles/list           → initialTab="list"   (alias)
 - /articles/highlights     → initialTab="highlights"
 - /articles/favorites      → initialTab="favorites"
 - /articles/stats          → initialTab="stats"
 Detail/Add/Settings bleiben bewusst ausserhalb — die haben ihren
 eigenen Reader/Form-Chrome und sollen die Tab-Leiste nicht zeigen.

Neue Files:
 - ArticlesTabShell.svelte   (Tab-Host)
 - tab-context.ts            (Cross-Tab-Switch-Context)
 - components/ArticleCard.svelte (shared Card aus ListView extrahiert,
                                  row + compact Varianten)
 - components/QuickAddInput.svelte (URL-Input aus HomeView extrahiert)
 - components/HomeSectionSources.svelte
 - components/HomeSectionStats.svelte
 - components/HomeSectionWeiterlesen.svelte
 - views/StatsView.svelte
 - routes/(app)/articles/(tabs)/{+page,list,highlights,favorites,stats}

Gelöscht:
 - HomeView.svelte (Overview-Tab wurde rausgenommen auf User-Feedback)
 - HomeSectionFrisch/Highlights/Favorites (durch eigene Tabs ersetzt)

docs/plans/articles-homepage.md dokumentiert den Architektur-Plan,
inklusive der Entscheidung für "eine Card pro Domain, interne Tabs"
statt zwei separater App-Registrierungen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-22 17:50:38 +02:00
parent a36e543e41
commit 72a5995fa5
19 changed files with 1272 additions and 279 deletions

View file

@ -849,6 +849,43 @@ registerApp({
},
});
registerApp({
id: 'articles',
name: 'Artikel',
color: '#F97316',
icon: BookOpen,
views: {
// ArticlesTabShell enthält intern alle drei Tabs (Übersicht /
// Leseliste / Highlights). Im Workbench-Karten-Kontext lassen
// sich die Tabs ohne Page-Navigation wechseln. In den direkten
// SvelteKit-Routen (/articles, /articles/list, /articles/highlights)
// wird dieselbe Shell mit passendem initialTab gemountet.
list: { load: () => import('$lib/modules/articles/ArticlesTabShell.svelte') },
detail: { load: () => import('$lib/modules/articles/views/DetailView.svelte') },
},
contextMenuActions: [
{
id: 'new-article',
label: 'URL speichern',
icon: Plus,
action: () =>
window.dispatchEvent(
new CustomEvent('mana:quick-action', { detail: { app: 'articles', action: 'new' } })
),
},
],
collection: 'articles',
paramKey: 'articleId',
// dragType: 'article' absichtlich weggelassen — der DragType-Union in
// @mana/shared-ui/dnd kennt noch keinen 'article'-Slot. Wenn später
// Drag-to-calendar / Drag-to-todo gebraucht wird, erweitern wir den
// Union dort und hängen es hier ein.
getDisplayData: (item) => ({
title: (item.title as string) || 'Artikel',
subtitle: (item.siteName as string) || undefined,
}),
});
registerApp({
id: 'research-lab',
name: 'Research Lab',

View file

@ -0,0 +1,150 @@
<!--
ArticlesTabShell — Tab-Leiste + Settings + QuickAdd oben, vier Tabs
darunter: Leseliste / Highlights / Favoriten / Stats.
Tab-Wechsel läuft INTERN über $state (Admin-Tabbed-Card-Pattern), nicht
über URL-Navigation. Das ist kritisch wenn die Shell als Workbench-App-
Karte gemountet wird — goto() würde dort den User aus der Karte
rauskicken.
Bookmarkbarkeit kommt über die drei SvelteKit-Routen, die jeweils mit
`initialTab` den Startpunkt setzen. Innerhalb der Shell gewechselte
Tabs ändern die URL NICHT — das ist by design.
-->
<script lang="ts">
import { setContext } from 'svelte';
import { Gear } from '@mana/shared-icons';
import { goto } from '$app/navigation';
import ListView from './ListView.svelte';
import HighlightsView from './views/HighlightsView.svelte';
import StatsView from './views/StatsView.svelte';
import QuickAddInput from './components/QuickAddInput.svelte';
import { ARTICLES_TAB_CONTEXT, type ArticlesTabContext, type ArticlesTabId } from './tab-context';
interface Props {
initialTab?: ArticlesTabId;
}
let { initialTab = 'list' }: Props = $props();
// svelte-ignore state_referenced_locally
let activeTab = $state<ArticlesTabId>(initialTab);
const TABS: { id: ArticlesTabId; label: string }[] = [
{ id: 'list', label: 'Leseliste' },
{ id: 'highlights', label: 'Highlights' },
{ id: 'favorites', label: 'Favoriten' },
{ id: 'stats', label: 'Stats' },
];
setContext<ArticlesTabContext>(ARTICLES_TAB_CONTEXT, {
switchTo(tab: ArticlesTabId) {
activeTab = tab;
},
});
</script>
<div class="tab-shell">
<header class="top-bar">
<QuickAddInput />
<button
type="button"
class="icon-btn"
title="Einstellungen — Bookmarklet + Share-Target"
aria-label="Artikel-Einstellungen"
onclick={() => goto('/articles/settings')}
>
<Gear size={18} weight="regular" />
</button>
</header>
<nav class="tabs" aria-label="Artikel-Ansichten">
{#each TABS as t (t.id)}
<button
type="button"
class="tab"
class:active={activeTab === t.id}
aria-current={activeTab === t.id ? 'page' : undefined}
onclick={() => (activeTab = t.id)}
>
{t.label}
</button>
{/each}
</nav>
<div class="tab-body">
{#if activeTab === 'list'}
<ListView />
{:else if activeTab === 'highlights'}
<HighlightsView />
{:else if activeTab === 'favorites'}
<ListView initialFilter="favorites" />
{:else if activeTab === 'stats'}
<StatsView />
{/if}
</div>
</div>
<style>
.tab-shell {
display: flex;
flex-direction: column;
gap: 1rem;
/* Innen-Padding als Single-Source-of-Truth. In Workbench-Karten */
/* hat PageShell's `.page-body` null padding — ohne das hier würde */
/* der QuickAdd-Input direkt am Card-Rand kleben. Im Route-Kontext */
/* liegt dieses Padding innerhalb des (app)-Layout-Wrappers und */
/* ergibt insgesamt ein ruhig gespaciedes Bild. */
padding: 1rem 1.25rem;
}
.top-bar {
display: flex;
align-items: flex-start;
gap: 0.6rem;
}
.icon-btn {
padding: 0.45rem 0.55rem;
border-radius: 0.5rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.15));
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
height: 2.35rem;
}
.icon-btn:hover {
border-color: var(--color-border-strong, rgba(0, 0, 0, 0.3));
}
.tabs {
display: flex;
gap: 0.15rem;
flex-wrap: wrap;
border-bottom: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
}
.tab {
padding: 0.55rem 0.9rem;
color: var(--color-text-muted, #64748b);
background: transparent;
border: none;
font: inherit;
font-size: 0.92rem;
font-weight: 500;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
cursor: pointer;
transition:
color 120ms ease,
border-color 120ms ease;
}
.tab:hover {
color: inherit;
}
.tab.active {
color: #f97316;
border-bottom-color: #f97316;
}
</style>

View file

@ -6,13 +6,23 @@
getTagIdsForMany to avoid N+1.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { TagChip } from '@mana/shared-ui';
import { page } from '$app/stores';
import { onMount, untrack } from 'svelte';
import ArticleCard from './components/ArticleCard.svelte';
import HomeSectionWeiterlesen from './components/HomeSectionWeiterlesen.svelte';
import { useAllArticles, useArticleTagMap } from './queries';
import { useAllTags } from './stores/tags.svelte';
import type { Article } from './types';
type Filter = 'all' | 'unread' | 'reading' | 'finished' | 'favorites' | 'archived';
const ALLOWED_FILTERS: Filter[] = [
'all',
'unread',
'reading',
'finished',
'favorites',
'archived',
];
const FILTERS: { id: Filter; label: string }[] = [
{ id: 'all', label: 'Alle' },
@ -23,30 +33,83 @@
{ id: 'archived', label: 'Archiv' },
];
interface Props {
/** Pre-selected filter (Workbench / Tab-Shell context). Wenn gesetzt,
* überstimmt er den URL-Query-Param. */
initialFilter?: Filter;
}
let { initialFilter }: Props = $props();
const articles$ = useAllArticles();
const articles = $derived(articles$.value);
const tagMap$ = $derived.by(() => useArticleTagMap(articles.map((a) => a.id)));
const allTags$ = useAllTags();
let filter = $state<Filter>('all');
// initialFilter ist ein einmaliger Seed (Shell-Tabs mounten ListView
// immer frisch — es gibt keinen Case wo der Prop sich live ändert).
// untrack() sagt Svelte explizit, dass das kein state_referenced_locally-
// Unfall ist.
let filter = $state<Filter>(untrack(() => initialFilter ?? 'all'));
let siteFilter = $state<string | null>(null);
let tagFilter = $state<string | null>(null);
// Deep-link support via Query-Param — nur wenn KEIN initialFilter-Prop
// gesetzt wurde (sonst gewinnt die Shell). In der Shell wird die
// ListView ohne URL-Sync gerendert; die direkten /articles/list-
// Routen dagegen haben die Params.
onMount(() => {
if (initialFilter) {
untrack(() => {
siteFilter = null;
tagFilter = null;
});
return;
}
const params = $page.url.searchParams;
const f = params.get('filter');
if (f && (ALLOWED_FILTERS as string[]).includes(f)) {
filter = f as Filter;
}
siteFilter = params.get('site') || null;
tagFilter = params.get('tag') || null;
});
// Continue-Reading-Strip: erscheint nur wenn Filter 'all' oder 'reading'
// ist — auf anderen Filtern ist es verwirrend (ungelesen / archiv etc.
// haben nichts mit "weiterlesen" zu tun).
const readingArticles = $derived(articles.filter((a) => a.status === 'reading'));
const showContinueReading = $derived(
readingArticles.length > 0 && (filter === 'all' || filter === 'reading')
);
function matchesStatus(a: Article, f: Filter): boolean {
switch (f) {
case 'all':
return a.status !== 'archived';
case 'unread':
return a.status === 'unread';
case 'reading':
return a.status === 'reading';
case 'finished':
return a.status === 'finished';
case 'favorites':
return a.isFavorite && a.status !== 'archived';
case 'archived':
return a.status === 'archived';
}
}
const filtered = $derived.by(() => {
const a = articles;
switch (filter) {
case 'all':
return a.filter((x) => x.status !== 'archived');
case 'unread':
return a.filter((x) => x.status === 'unread');
case 'reading':
return a.filter((x) => x.status === 'reading');
case 'finished':
return a.filter((x) => x.status === 'finished');
case 'favorites':
return a.filter((x) => x.isFavorite && x.status !== 'archived');
case 'archived':
return a.filter((x) => x.status === 'archived');
let result = articles.filter((a) => matchesStatus(a, filter));
if (siteFilter) {
const needle = siteFilter.toLowerCase();
result = result.filter((a) => (a.siteName ?? '').toLowerCase() === needle);
}
if (tagFilter) {
result = result.filter((a) => (tagMap$.value.get(a.id) ?? []).includes(tagFilter!));
}
return result;
});
const counts = $derived.by(() => ({
@ -67,43 +130,23 @@
.filter((t): t is (typeof all)[number] => !!t);
}
function openArticle(a: Article) {
goto(`/articles/${a.id}`);
function clearSiteFilter() {
siteFilter = null;
}
function clearTagFilter() {
tagFilter = null;
}
const tagFilterLabel = $derived(
tagFilter ? (allTags$.value.find((t) => t.id === tagFilter)?.name ?? tagFilter) : null
);
</script>
<div class="articles-shell">
<header class="header">
<div class="header-row">
<div>
<h1>Artikel</h1>
<p class="subtitle">Später lesen — gespeicherte Web-Artikel, offline verfügbar.</p>
</div>
<div class="header-actions">
<button
type="button"
class="icon-btn"
title="Highlights — alle markierten Stellen"
aria-label="Highlights anzeigen"
onclick={() => goto('/articles/highlights')}
>
</button>
<button
type="button"
class="icon-btn"
title="Einstellungen — Bookmarklet + Share-Target"
aria-label="Artikel-Einstellungen"
onclick={() => goto('/articles/settings')}
>
</button>
<button type="button" class="add-btn" onclick={() => goto('/articles/add')}>
+ Neu speichern
</button>
</div>
</div>
<div class="list-view">
{#if showContinueReading}
<HomeSectionWeiterlesen articles={readingArticles} />
{/if}
<div class="filter-bar">
<div class="filter-row" role="tablist" aria-label="Filter">
{#each FILTERS as f (f.id)}
<button
@ -119,7 +162,22 @@
</button>
{/each}
</div>
</header>
{#if siteFilter || tagFilter}
<div class="sub-filters" aria-label="Zusatz-Filter">
{#if siteFilter}
<button type="button" class="sub-filter" onclick={clearSiteFilter}>
Quelle: {siteFilter} <span class="x">×</span>
</button>
{/if}
{#if tagFilter}
<button type="button" class="sub-filter" onclick={clearTagFilter}>
Tag: {tagFilterLabel} <span class="x">×</span>
</button>
{/if}
</div>
{/if}
</div>
{#if articles$.loading}
<p class="muted center">Lädt…</p>
@ -127,12 +185,9 @@
<div class="empty-state">
<p class="empty-headline">Noch nichts gespeichert.</p>
<p class="empty-sub">
URL einfügen, der Server extrahiert den Artikel mit Readability, alles bleibt verschlüsselt
offline verfügbar.
Geh auf die Übersicht und füge oben eine URL ein — der Server extrahiert den Artikel mit
Readability, alles bleibt verschlüsselt offline verfügbar.
</p>
<button type="button" class="add-btn" onclick={() => goto('/articles/add')}>
Erste URL speichern
</button>
</div>
{:else if filtered.length === 0}
<div class="empty-state">
@ -142,33 +197,8 @@
{:else}
<ul class="article-list">
{#each filtered as article (article.id)}
{@const articleTags = tagsFor(article)}
<li>
<button type="button" class="article-card" onclick={() => openArticle(article)}>
<div class="meta">
{#if article.siteName}
<span class="site">{article.siteName}</span>
{/if}
{#if article.readingTimeMinutes}
<span class="reading-time">{article.readingTimeMinutes} min</span>
{/if}
<span class="status status-{article.status}">{article.status}</span>
{#if article.isFavorite}
<span class="fav" title="Favorit"></span>
{/if}
</div>
<div class="title">{article.title}</div>
{#if article.excerpt}
<div class="excerpt">{article.excerpt}</div>
{/if}
{#if articleTags.length > 0}
<div class="tags">
{#each articleTags as tag (tag.id)}
<TagChip name={tag.name} color={tag.color} />
{/each}
</div>
{/if}
</button>
<ArticleCard {article} tags={tagsFor(article)} />
</li>
{/each}
</ul>
@ -176,86 +206,55 @@
</div>
<style>
.articles-shell {
max-width: 900px;
margin: 0 auto;
padding: 1.5rem;
}
.header {
margin-bottom: 1.25rem;
}
.header-row {
.list-view {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-direction: column;
gap: 1rem;
margin-bottom: 1rem;
}
.header h1 {
margin: 0 0 0.25rem 0;
font-size: 1.75rem;
}
.header-actions {
.filter-bar {
display: flex;
gap: 0.4rem;
align-items: center;
flex-shrink: 0;
}
.icon-btn {
padding: 0.5rem 0.65rem;
border-radius: 0.55rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.15));
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
font-size: 1rem;
line-height: 1;
}
.icon-btn:hover {
border-color: var(--color-border-strong, rgba(0, 0, 0, 0.3));
}
.add-btn {
padding: 0.5rem 1rem;
border-radius: 0.55rem;
border: 1px solid #f97316;
background: #f97316;
color: white;
font: inherit;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.add-btn:hover {
background: #ea580c;
border-color: #ea580c;
}
.subtitle {
color: var(--color-text-muted, #64748b);
margin: 0;
font-size: 0.95rem;
flex-direction: column;
gap: 0.5rem;
}
.filter-row {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
flex-wrap: nowrap;
overflow-x: auto;
/* Schmale Scrollbar, damit die Chips auch auf Mobile ohne Umbruch
* erreichbar bleiben. `scroll-snap-type` macht das Scroll-Gefühl
* snappy — Chip für Chip einrasten statt frei gleiten. */
scroll-snap-type: x proximity;
scrollbar-width: thin;
/* Ein kleiner Fade am rechten Rand wäre schön, verzichten wir */
/* drauf — spart Komplexität, Browser zeigt seine native overflow-*/
/* affordance. */
padding-bottom: 0.25rem;
margin-bottom: -0.25rem;
}
.filter-chip {
font: inherit;
font-size: 0.85rem;
padding: 0.35rem 0.75rem;
border-radius: 999px;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.12));
background: transparent;
/* Nicht-aktive Chips: leichte Hintergrund-Füllung + sichtbarer
* Border, damit sie klar als tappable Elements lesbar sind.
* currentColor-basierte Mixes adaptieren automatisch an Light/
* Sepia/Dark-Themes. */
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
background: color-mix(in srgb, currentColor 5%, transparent);
color: inherit;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
scroll-snap-align: start;
white-space: nowrap;
}
.filter-chip:hover {
border-color: var(--color-border-strong, rgba(0, 0, 0, 0.25));
border-color: color-mix(in srgb, currentColor 35%, transparent);
background: color-mix(in srgb, currentColor 9%, transparent);
}
.filter-chip.active {
background: #f97316;
@ -269,6 +268,32 @@
background: color-mix(in srgb, currentColor 15%, transparent);
border-radius: 999px;
}
.sub-filters {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
margin-top: 0.6rem;
}
.sub-filter {
font: inherit;
font-size: 0.78rem;
padding: 0.2rem 0.55rem;
border-radius: 999px;
border: 1px solid color-mix(in srgb, #f97316 40%, transparent);
background: color-mix(in srgb, #f97316 10%, transparent);
color: #ea580c;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.sub-filter .x {
opacity: 0.7;
font-weight: 600;
}
.sub-filter:hover .x {
opacity: 1;
}
.muted {
color: var(--color-text-muted, #64748b);
font-size: 0.9rem;
@ -301,74 +326,4 @@
flex-direction: column;
gap: 0.5rem;
}
.article-card {
width: 100%;
text-align: left;
padding: 0.85rem 1rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
border-radius: 0.6rem;
background: var(--color-surface, transparent);
color: inherit;
font: inherit;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.article-card:hover {
border-color: var(--color-border-strong, rgba(0, 0, 0, 0.2));
}
.meta {
display: flex;
gap: 0.6rem;
font-size: 0.75rem;
color: var(--color-text-muted, #64748b);
align-items: center;
}
.site {
font-weight: 500;
}
.status {
padding: 0.08rem 0.45rem;
border-radius: 999px;
font-size: 0.7rem;
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.status-finished {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.status-reading {
background: rgba(249, 115, 22, 0.12);
color: #f97316;
}
.status-archived {
background: rgba(100, 116, 139, 0.15);
color: #64748b;
}
.fav {
color: #f59e0b;
}
.title {
font-weight: 600;
font-size: 1rem;
line-height: 1.35;
}
.excerpt {
color: var(--color-text-muted, #64748b);
font-size: 0.88rem;
line-height: 1.4;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.tags {
display: flex;
gap: 0.3rem;
flex-wrap: wrap;
margin-top: 0.1rem;
}
</style>

View file

@ -0,0 +1,170 @@
<!--
ArticleCard — shared card used by ListView, HomeView sections, and
anywhere else an article needs a compact clickable preview.
Two layout variants:
- variant="row" (default): full-width card, meta + title +
excerpt + tags. Used in vertical lists.
- variant="compact" slimmer, no excerpt, shows reading-progress
bar underneath — meant for horizontal
carousels (Continue-Reading section).
Parent passes the article + an optional tag list; navigation is
inlined so callers don't need to wire onclick themselves. A parent
that wants a different destination (e.g. a list filter) can override
via `href`.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { TagChip } from '@mana/shared-ui';
import type { Article } from '../types';
type CardTag = { id: string; name: string; color?: string | null };
interface Props {
article: Article;
tags?: CardTag[];
variant?: 'row' | 'compact';
/** Override the default `/articles/<id>` navigation target. */
href?: string;
}
let { article, tags = [], variant = 'row', href }: Props = $props();
function openArticle() {
goto(href ?? `/articles/${article.id}`);
}
const progressPercent = $derived(Math.round((article.readingProgress ?? 0) * 100));
</script>
<button type="button" class="article-card variant-{variant}" onclick={openArticle}>
<div class="meta">
{#if article.siteName}
<span class="site">{article.siteName}</span>
{/if}
{#if article.readingTimeMinutes}
<span class="reading-time">{article.readingTimeMinutes} min</span>
{/if}
{#if variant === 'row'}
<span class="status status-{article.status}">{article.status}</span>
{/if}
{#if article.isFavorite}
<span class="fav" aria-label="Favorit"></span>
{/if}
</div>
<div class="title">{article.title}</div>
{#if variant === 'row' && article.excerpt}
<div class="excerpt">{article.excerpt}</div>
{/if}
{#if variant === 'row' && tags.length > 0}
<div class="tags">
{#each tags as tag (tag.id)}
<TagChip name={tag.name} color={tag.color} />
{/each}
</div>
{/if}
{#if variant === 'compact' && progressPercent > 0}
<div class="progress" aria-label="Lesefortschritt {progressPercent}%">
<div class="progress-bar" style:width="{progressPercent}%"></div>
</div>
{/if}
</button>
<style>
.article-card {
width: 100%;
text-align: left;
padding: 0.85rem 1rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
border-radius: 0.6rem;
background: var(--color-surface, transparent);
color: inherit;
font: inherit;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.article-card:hover {
border-color: var(--color-border-strong, rgba(0, 0, 0, 0.2));
}
.variant-compact {
gap: 0.3rem;
padding: 0.75rem 0.9rem;
}
.meta {
display: flex;
gap: 0.6rem;
font-size: 0.75rem;
color: var(--color-text-muted, #64748b);
align-items: center;
}
.site {
font-weight: 500;
}
.status {
padding: 0.08rem 0.45rem;
border-radius: 999px;
font-size: 0.7rem;
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.status-finished {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.status-reading {
background: rgba(249, 115, 22, 0.12);
color: #f97316;
}
.status-archived {
background: rgba(100, 116, 139, 0.15);
color: #64748b;
}
.fav {
color: #f59e0b;
}
.title {
font-weight: 600;
font-size: 1rem;
line-height: 1.35;
}
.variant-compact .title {
font-size: 0.95rem;
/* Limit to 3 lines so the card heights stay uniform in the carousel. */
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.excerpt {
color: var(--color-text-muted, #64748b);
font-size: 0.88rem;
line-height: 1.4;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.tags {
display: flex;
gap: 0.3rem;
flex-wrap: wrap;
margin-top: 0.1rem;
}
.progress {
margin-top: auto;
height: 3px;
border-radius: 999px;
background: color-mix(in srgb, currentColor 8%, transparent);
overflow: hidden;
}
.progress-bar {
height: 100%;
background: #f97316;
transition: width 200ms ease;
}
</style>

View file

@ -0,0 +1,97 @@
<!--
Top-Sources: die Top-5-Quellen nach Artikelanzahl. Klick filtert
die ListView nach siteName.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import type { SiteCount } from '../queries';
import { getArticlesTabContext } from '../tab-context';
interface Props {
sources: SiteCount[];
}
let { sources }: Props = $props();
const tabCtx = getArticlesTabContext();
function openSource(siteName: string) {
if (tabCtx) {
// Im Workbench-Kontext können wir nicht auf die Liste routen
// und einen Site-Filter per Query-Param setzen (die Shell
// mountet die ListView ohne URL-Sync). Als Kompromiss:
// Switch nur auf den Tab — der User sieht die ganze Liste
// und sortiert dort selbst. Nicht ideal; siehe Plan-TODO.
tabCtx.switchTo('list');
} else {
goto(`/articles/list?site=${encodeURIComponent(siteName)}`);
}
}
</script>
{#if sources.length > 0}
<section class="section">
<header class="section-header">
<h2>Deine Quellen</h2>
</header>
<ul class="list">
{#each sources as src (src.siteName)}
<li>
<button type="button" class="source-row" onclick={() => openSource(src.siteName)}>
<span class="name">{src.siteName}</span>
<span class="count">{src.count}</span>
</button>
</li>
{/each}
</ul>
</section>
{/if}
<style>
.section {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.section-header h2 {
margin: 0;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted, #64748b);
}
.list {
list-style: none;
padding: 0;
margin: 0;
border-radius: 0.55rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
background: var(--color-surface, transparent);
overflow: hidden;
}
.list li + li {
border-top: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
}
.source-row {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.6rem 0.9rem;
font: inherit;
background: transparent;
color: inherit;
border: none;
cursor: pointer;
}
.source-row:hover {
background: color-mix(in srgb, currentColor 4%, transparent);
}
.name {
font-weight: 500;
}
.count {
font-size: 0.82rem;
color: var(--color-text-muted, #64748b);
}
</style>

View file

@ -0,0 +1,63 @@
<!--
One-line stats strip — gespeichert diese Woche, gelesen diese
Woche, ø Lesezeit aller aktiven (unread + reading) Artikel.
-->
<script lang="ts">
import type { Article } from '../types';
interface Props {
savedThisWeek: number;
finishedThisWeek: number;
articles: Article[];
}
let { savedThisWeek, finishedThisWeek, articles }: Props = $props();
const avgReadMin = $derived.by(() => {
const active = articles.filter((a) => a.status === 'unread' || a.status === 'reading');
if (active.length === 0) return null;
const total = active.reduce((sum, a) => sum + (a.readingTimeMinutes ?? 0), 0);
return Math.round(total / active.length);
});
</script>
<section class="stats">
<div class="cell">
<strong>{savedThisWeek}</strong>
<span>diese Woche gespeichert</span>
</div>
<div class="cell">
<strong>{finishedThisWeek}</strong>
<span>diese Woche gelesen</span>
</div>
{#if avgReadMin !== null}
<div class="cell">
<strong>ø {avgReadMin} min</strong>
<span>pro Artikel in der Leseliste</span>
</div>
{/if}
</section>
<style>
.stats {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
padding: 0.85rem 1rem;
border-radius: 0.55rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
background: var(--color-surface, transparent);
}
.cell {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.cell strong {
font-size: 1.25rem;
font-weight: 600;
}
.cell span {
font-size: 0.78rem;
color: var(--color-text-muted, #64748b);
}
</style>

View file

@ -0,0 +1,71 @@
<!--
Continue-Reading-Section: horizontales Carousel mit Artikeln die
aktuell `status='reading'` haben. Nur gerendert wenn >0 Artikel.
-->
<script lang="ts">
import ArticleCard from './ArticleCard.svelte';
import type { Article } from '../types';
interface Props {
articles: Article[];
}
let { articles }: Props = $props();
</script>
{#if articles.length > 0}
<section class="section">
<header class="section-header">
<h2>Weiterlesen</h2>
<span class="count">{articles.length}</span>
</header>
<div class="carousel">
{#each articles as article (article.id)}
<div class="slot">
<ArticleCard {article} variant="compact" />
</div>
{/each}
</div>
</section>
{/if}
<style>
.section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.section-header {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.section-header h2 {
margin: 0;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted, #64748b);
}
.count {
font-size: 0.72rem;
color: var(--color-text-muted, #64748b);
opacity: 0.7;
}
.carousel {
display: flex;
gap: 0.65rem;
overflow-x: auto;
padding-bottom: 0.5rem;
scroll-snap-type: x proximity;
/* Stretches to the shell edges even when the parent has padding — */
/* lets cards scroll off the visible right edge rather than clipping */
/* awkwardly. */
scrollbar-width: thin;
}
.slot {
flex: 0 0 260px;
scroll-snap-align: start;
display: flex;
}
</style>

View file

@ -0,0 +1,133 @@
<!--
QuickAddInput — inline URL-Eingabe, liegt in der Shell-Header.
Auf Enter/Klick: saveFromUrl → Reader. Kein Preview, kein Dialog.
Consent-Wall-Fälle gehen durch zum /articles/add-Formular wenn der
Nutzer dort Preview + Warn-Karte braucht.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { LinkSimple } from '@mana/shared-icons';
import { articlesStore } from '../stores/articles.svelte';
let url = $state('');
let busy = $state(false);
let error = $state<string | null>(null);
async function handleSubmit() {
const trimmed = url.trim();
if (!trimmed || busy) return;
try {
new URL(trimmed);
} catch {
error = 'Das sieht nicht nach einer gültigen URL aus.';
return;
}
busy = true;
error = null;
try {
const { article } = await articlesStore.saveFromUrl(trimmed);
url = '';
goto(`/articles/${article.id}`);
} catch (e) {
error = e instanceof Error ? e.message : 'Speichern fehlgeschlagen.';
} finally {
busy = false;
}
}
</script>
<div class="quick-add-wrap">
<div class="quick-add" role="search">
<LinkSimple size={18} weight="regular" class="quick-add-icon" />
<input
type="url"
class="quick-input"
bind:value={url}
placeholder="URL einfügen und Enter drücken…"
disabled={busy}
onkeydown={(e) => {
if (e.key === 'Enter') handleSubmit();
}}
/>
<button
type="button"
class="quick-submit"
disabled={busy || !url.trim()}
onclick={handleSubmit}
aria-label="URL speichern"
>
{#if busy}Speichere…{:else}Speichern{/if}
</button>
</div>
{#if error}
<p class="quick-error" role="alert">{error}</p>
{/if}
</div>
<style>
.quick-add-wrap {
display: flex;
flex-direction: column;
gap: 0.35rem;
flex: 1;
min-width: 240px;
}
.quick-add {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.5rem 0.35rem 0.65rem;
border-radius: 0.55rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.15));
background: var(--color-surface, transparent);
}
.quick-add :global(.quick-add-icon) {
color: var(--color-text-muted, #64748b);
flex-shrink: 0;
}
.quick-add:focus-within {
border-color: #f97316;
}
.quick-input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
font: inherit;
color: inherit;
padding: 0.3rem 0;
}
.quick-input:disabled {
opacity: 0.6;
}
.quick-submit {
padding: 0.35rem 0.85rem;
border-radius: 0.4rem;
border: 1px solid #f97316;
background: #f97316;
color: white;
font: inherit;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.quick-submit:hover:not(:disabled) {
background: #ea580c;
border-color: #ea580c;
}
.quick-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.quick-error {
margin: 0;
padding: 0.35rem 0.65rem;
border-radius: 0.4rem;
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
font-size: 0.82rem;
}
</style>

View file

@ -0,0 +1,28 @@
/**
* Cross-tab context for the articles module.
*
* ArticlesTabShell provides this to let deeply-nested section components
* (HomeSectionFrisch's "Alle ungelesenen →" button etc.) switch the
* active tab without navigating away from the current URL critical
* when the articles module is rendered inside a Workbench card where a
* `goto(...)` would kick the user out of the card entirely.
*
* Consumers: call `getArticlesTabContext()` and, if non-null, use
* `.switchTo(tab)` in place of a `goto(/articles/...)`. Falling through
* to goto when no context exists is the explicit escape hatch for when
* the component is rendered standalone (e.g. old tests).
*/
import { getContext } from 'svelte';
export type ArticlesTabId = 'list' | 'highlights' | 'favorites' | 'stats';
export interface ArticlesTabContext {
switchTo(tab: ArticlesTabId): void;
}
export const ARTICLES_TAB_CONTEXT = Symbol('articles-tab-context');
export function getArticlesTabContext(): ArticlesTabContext | null {
return getContext<ArticlesTabContext | undefined>(ARTICLES_TAB_CONTEXT) ?? null;
}

View file

@ -73,23 +73,13 @@
<title>Highlights — Artikel — Mana</title>
</svelte:head>
<div class="shell">
<header class="header">
<div class="header-row">
<div>
<h1>Highlights</h1>
<p class="subtitle">Alle markierten Stellen aus deinen gespeicherten Artikeln.</p>
</div>
<button type="button" class="back" onclick={() => goto('/articles')}> Zurück</button>
<div class="highlights-view">
{#if rows.length > 0}
<div class="actions">
<button type="button" class="ghost" onclick={copyMarkdown}>{exportLabel}</button>
<button type="button" class="ghost" onclick={downloadMarkdown}>Als .md herunterladen</button>
</div>
{#if rows.length > 0}
<div class="actions">
<button type="button" class="ghost" onclick={copyMarkdown}>{exportLabel}</button>
<button type="button" class="ghost" onclick={downloadMarkdown}>Als .md herunterladen</button
>
</div>
{/if}
</header>
{/if}
{#if rows$.loading}
<p class="muted center">Lädt…</p>
@ -99,7 +89,6 @@
<p class="empty-sub">
Markier eine Textstelle in einem gespeicherten Artikel — sie erscheint hier automatisch.
</p>
<button type="button" class="cta" onclick={() => goto('/articles')}>Zur Leseliste</button>
</div>
{:else}
<div class="groups">
@ -142,61 +131,28 @@
</div>
<style>
.shell {
max-width: 800px;
margin: 0 auto;
padding: 1.5rem;
}
.header {
margin-bottom: 1.25rem;
}
.header-row {
.highlights-view {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
flex-direction: column;
gap: 1.25rem;
}
.header h1 {
margin: 0 0 0.25rem 0;
font-size: 1.75rem;
}
.subtitle {
margin: 0;
color: var(--color-text-muted, #64748b);
font-size: 0.95rem;
}
.back,
.ghost,
.cta {
.ghost {
font: inherit;
padding: 0.45rem 0.85rem;
border-radius: 0.5rem;
cursor: pointer;
}
.back,
.ghost {
background: transparent;
color: inherit;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.15));
}
.back:hover,
.ghost:hover {
border-color: var(--color-border-strong, rgba(0, 0, 0, 0.3));
}
.actions {
margin-top: 0.9rem;
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.cta {
border: 1px solid #f97316;
background: #f97316;
color: white;
}
.cta:hover {
background: #ea580c;
}
.muted {
color: var(--color-text-muted, #64748b);
font-size: 0.9rem;

View file

@ -0,0 +1,95 @@
<!--
Stats-Tab: Zahlen und Quellen-Aufstellung plus Link ins Archiv.
Verwendet die gleichen Section-Components die früher auf der
Home-Overview gruppiert waren.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { useAllArticles, useStats } from '../queries';
import HomeSectionStats from '../components/HomeSectionStats.svelte';
import HomeSectionSources from '../components/HomeSectionSources.svelte';
import { getArticlesTabContext } from '../tab-context';
const articles$ = useAllArticles();
const stats$ = useStats();
const articles = $derived(articles$.value);
const stats = $derived(stats$.value);
const tabCtx = getArticlesTabContext();
function openArchive() {
if (tabCtx) {
tabCtx.switchTo('list');
} else {
goto('/articles/list?filter=archived');
}
}
</script>
<div class="stats-view">
{#if articles$.loading}
<p class="muted">Lädt…</p>
{:else if articles.length === 0}
<p class="muted">Noch keine Artikel gespeichert — Statistiken erscheinen sobald du anfängst.</p>
{:else}
<HomeSectionStats
savedThisWeek={stats.savedThisWeek}
finishedThisWeek={stats.finishedThisWeek}
{articles}
/>
<section class="highlights-line">
<strong>{stats.totalHighlights}</strong>
<span>markierte Textstellen insgesamt</span>
</section>
<HomeSectionSources sources={stats.topSites} />
{#if stats.archived > 0}
<button type="button" class="archive-link" onclick={openArchive}>
{stats.archived} archivierte Artikel →
</button>
{/if}
{/if}
</div>
<style>
.stats-view {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.muted {
color: var(--color-text-muted, #64748b);
font-size: 0.9rem;
}
.highlights-line {
display: flex;
gap: 0.5rem;
align-items: baseline;
padding: 0.85rem 1rem;
border-radius: 0.55rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
background: var(--color-surface, transparent);
}
.highlights-line strong {
font-size: 1.25rem;
font-weight: 600;
}
.highlights-line span {
font-size: 0.85rem;
color: var(--color-text-muted, #64748b);
}
.archive-link {
align-self: flex-start;
font: inherit;
font-size: 0.9rem;
background: transparent;
border: none;
color: var(--color-text-muted, #64748b);
cursor: pointer;
padding: 0.4rem 0.1rem;
}
.archive-link:hover {
color: inherit;
text-decoration: underline;
}
</style>

View file

@ -0,0 +1,10 @@
<script lang="ts">
import ArticlesTabShell from '$lib/modules/articles/ArticlesTabShell.svelte';
</script>
<svelte:head>
<title>Artikel - Mana</title>
</svelte:head>
<!-- /articles-Root → Leseliste als Default-Tab -->
<ArticlesTabShell initialTab="list" />

View file

@ -0,0 +1,9 @@
<script lang="ts">
import ArticlesTabShell from '$lib/modules/articles/ArticlesTabShell.svelte';
</script>
<svelte:head>
<title>Favoriten — Artikel — Mana</title>
</svelte:head>
<ArticlesTabShell initialTab="favorites" />

View file

@ -0,0 +1,9 @@
<script lang="ts">
import ArticlesTabShell from '$lib/modules/articles/ArticlesTabShell.svelte';
</script>
<svelte:head>
<title>Highlights — Artikel — Mana</title>
</svelte:head>
<ArticlesTabShell initialTab="highlights" />

View file

@ -0,0 +1,9 @@
<script lang="ts">
import ArticlesTabShell from '$lib/modules/articles/ArticlesTabShell.svelte';
</script>
<svelte:head>
<title>Leseliste — Artikel — Mana</title>
</svelte:head>
<ArticlesTabShell initialTab="list" />

View file

@ -0,0 +1,9 @@
<script lang="ts">
import ArticlesTabShell from '$lib/modules/articles/ArticlesTabShell.svelte';
</script>
<svelte:head>
<title>Stats — Artikel — Mana</title>
</svelte:head>
<ArticlesTabShell initialTab="stats" />

View file

@ -1,9 +0,0 @@
<script lang="ts">
import ListView from '$lib/modules/articles/ListView.svelte';
</script>
<svelte:head>
<title>Artikel - Mana</title>
</svelte:head>
<ListView />

View file

@ -1,5 +0,0 @@
<script lang="ts">
import HighlightsView from '$lib/modules/articles/views/HighlightsView.svelte';
</script>
<HighlightsView />