mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
a36e543e41
commit
72a5995fa5
19 changed files with 1272 additions and 279 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
28
apps/mana/apps/web/src/lib/modules/articles/tab-context.ts
Normal file
28
apps/mana/apps/web/src/lib/modules/articles/tab-context.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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" />
|
||||
|
|
@ -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" />
|
||||
|
|
@ -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" />
|
||||
|
|
@ -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" />
|
||||
|
|
@ -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" />
|
||||
|
|
@ -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 />
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<script lang="ts">
|
||||
import HighlightsView from '$lib/modules/articles/views/HighlightsView.svelte';
|
||||
</script>
|
||||
|
||||
<HighlightsView />
|
||||
206
docs/plans/articles-homepage.md
Normal file
206
docs/plans/articles-homepage.md
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
# Articles — Workbench Homepage
|
||||
|
||||
## Status (2026-04-22)
|
||||
|
||||
Proposed. Follow-up auf die M1–M8-Umsetzung (siehe [articles-module.md](articles-module.md)).
|
||||
|
||||
## Ziel
|
||||
|
||||
Die `/articles`-Landing ist heute eine flache Filter-Chips + Karten-Liste. Für einen Pocket-Klon ist das funktional aber „langweilig" — der User sieht keinen kuratierten Einstieg, keinen Fortschritt, keine Wiederaufnahme-Empfehlung. Bei Readwise Reader / Matter / Omnivore ist die Landing eine reiche Übersicht mit mehreren Sektionen; die flache Liste steht als Fallback daneben.
|
||||
|
||||
Dieses Plan-Dokument beschreibt eine neue **HomeView** als Default-Einstieg — multi-sektional, reaktiv, leanes Data-Layer (alle Queries existieren schon). Die bestehende ListView bleibt 1:1 erhalten und ist via Tab oder direkter Route (`/articles/list`) weiter erreichbar.
|
||||
|
||||
## Abgrenzung
|
||||
|
||||
- **Kein Workbench-Cards-Rewrite.** Articles registriert sich als **eine** App mit **einer** `list`-View. Die HomeView ist der neue Body dieser View — intern mehrere Sektionen, extern ein einziger Einstieg. Siehe [workbench-cards-migration.md](workbench-cards-migration.md) — das Prinzip „eine Karte pro Domain, interne Tabs wenn nötig" passt hier.
|
||||
- **Kein Scene-Template.** Wir schaffen keinen neuen Template-Handler („Lese-Scene"); Articles landet als ganz normale Workbench-App.
|
||||
- **Kein Ersatz für das Dashboard-Widget.** `ArticlesUnreadWidget` bleibt auf dem globalen `/dashboard` — das ist ein anderer Surface (Dashboard-Tile) mit anderem Zweck (3-Zeilen-Schnappschuss im Widget-Grid).
|
||||
- **Keine neuen Queries/Stores.** Alles was die Sections brauchen ist bereits in `queries.ts` vorhanden: `useAllArticles`, `useStats`, `useAllHighlights`. Falls wir was fehlt, wird's dort ergänzt — nicht im HomeView.
|
||||
|
||||
## Warum jetzt
|
||||
|
||||
1. M1–M8 sind durch, das Modul funktioniert end-to-end — Polish-Phase.
|
||||
2. Nutzer kommt jetzt öfter vorbei (Bookmarklet + Share-Target → mehr Saves pro Woche). Eine Landing die sagt „hier ist was Neues, hier warst du stehengeblieben" lohnt sich plötzlich.
|
||||
3. Articles-as-Workbench-App-Registrierung fehlt noch komplett — das Modul lebt heute nur als Route, nicht als draggable Scene-App. Diese Homepage-Iteration ist der natürliche Moment das mitzunehmen.
|
||||
|
||||
## Entscheidungen vorab
|
||||
|
||||
- **Eine HomeView, keine Tabs.** Keine „Home vs. List"-Umschaltung in der UI. Die Liste-Sektionen DER Homepage ersetzen die alte ListView-Funktionalität. Wenn User wirklich nur die Chip-Filter-Liste will, geht er auf `/articles/list` (separate Route, rendert weiterhin `ListView.svelte`).
|
||||
- **Scroll-Layout, kein Bento-Grid.** Sections stacken vertikal (wie `myday/ListView.svelte`). Kein CSS-Grid-Layout mit Bento-Kacheln — das skaliert auf Mobile schlecht und ist optisch lauter. Scroll-Landing bleibt ruhig und funktional.
|
||||
- **Gleiche Theme-Tokens wie das restliche Mana-UI** (`bg-surface`, `text-muted-foreground` etc.). Kein Reader-Theme hier — die Homepage ist UI, nicht Content. Das unterscheidet sie von DetailView (Reader).
|
||||
- **Keine Persistenz der Sektions-Reihenfolge.** User kann Sektionen nicht umordnen/ausblenden. Wenn das Bedürfnis entsteht → separates Ticket. Startpunkt ist ein gut kuratiertes fixes Layout.
|
||||
|
||||
## View-Struktur
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ HEADER │
|
||||
│ "Artikel" [+ Neu] [✎ Highlights] [⚙ Settings]
|
||||
│ │
|
||||
│ Weiterlesen (wenn status='reading' existiert) │
|
||||
│ ┌─────┐ ┌─────┐ ┌─────┐ │
|
||||
│ │ │ │ │ │ │ horizontal scroll │
|
||||
│ │card │ │card │ │card │ mit Progress-Bar │
|
||||
│ └─────┘ └─────┘ └─────┘ │
|
||||
│ │
|
||||
│ Frisch in der Leseliste (status='unread') │
|
||||
│ ┌─────────────────────┐ │
|
||||
│ │ Artikel-Karte │ vertikal, max 8 │
|
||||
│ └─────────────────────┘ │
|
||||
│ ┌─────────────────────┐ │
|
||||
│ │ Artikel-Karte │ │
|
||||
│ └─────────────────────┘ │
|
||||
│ [ Alle ungelesenen →] │
|
||||
│ │
|
||||
│ Letzte Highlights (wenn vorhanden) │
|
||||
│ ┌─────────────────────┐ │
|
||||
│ │ "…quote text…" │ max 5 │
|
||||
│ │ Artikel-Titel │ │
|
||||
│ └─────────────────────┘ │
|
||||
│ [ Alle Highlights → ] │
|
||||
│ │
|
||||
│ Diese Woche │
|
||||
│ 12 gespeichert · 4 gelesen · 7 min pro Artikel │
|
||||
│ │
|
||||
│ Deine Quellen (top 5 siteName counts) │
|
||||
│ • spiegel.de 14 Artikel │
|
||||
│ • theverge.com 8 │
|
||||
│ • … │
|
||||
│ │
|
||||
│ Favoriten (wenn welche markiert) │
|
||||
│ ┌─────────────────────┐ │
|
||||
│ │ ★ Artikel-Karte │ │
|
||||
│ └─────────────────────┘ │
|
||||
│ │
|
||||
│ Archiv-Link │
|
||||
│ [ 37 archivierte Artikel → ] │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Sektionen im Detail
|
||||
|
||||
1. **Weiterlesen (Continue Reading)** — nur wenn mindestens ein Artikel mit `status='reading'` existiert. Horizontaler Karussell-Scroll, 3–5 sichtbar auf Desktop. Jede Karte zeigt Titel + Site + `readingProgress` als dünnen Progress-Bar unten. Click → Reader.
|
||||
|
||||
2. **Frisch in der Leseliste** — unread Artikel, sortiert `savedAt DESC`, max 8. Wenn mehr → Link „Alle ungelesenen →" der nach `/articles/list?filter=unread` geht.
|
||||
|
||||
3. **Letzte Highlights** — max 5 Highlights sortiert `createdAt DESC`, gruppiert per Artikel (ein Eintrag pro Artikel, mit erstem Highlight als Preview und Count falls mehrere). Click → Artikel im Reader. Link „Alle Highlights →" nach `/articles/highlights`.
|
||||
|
||||
4. **Diese Woche** (Stats-Strip) — eine Zeile, drei Zahlen: `savedThisWeek`, `finishedThisWeek`, durchschnittliche Lesezeit (`ø readingTimeMinutes` aller unread/reading Artikel). Keine Grafik — eine Text-Zeile.
|
||||
|
||||
5. **Deine Quellen** — top 5 `siteName` nach Count, mit Total-Count pro Site. Click auf eine Site → `/articles/list?filter=all&site=<siteName>` (Site-Filter in der ListView neu, siehe „Offene Fragen").
|
||||
|
||||
6. **Favoriten** — nur wenn `favorites > 0`. Max 4 Karten. Gleicher Card-Style wie „Frisch".
|
||||
|
||||
7. **Archiv-Link** — schlichte Zeile am Ende: „37 archivierte Artikel →". Kein Embedded-Inhalt, reiner Link in die gefilterte ListView.
|
||||
|
||||
**Empty-State**: wenn der User 0 Artikel hat → identisch zur aktuellen ListView-Empty-State („Noch nichts gespeichert" + CTA + Hinweis auf Bookmarklet).
|
||||
|
||||
## Daten-Queries (alle existieren bereits)
|
||||
|
||||
| Sektion | Query | Datei |
|
||||
|---|---|---|
|
||||
| Weiterlesen | `useAllArticles()` filtered `status==='reading'` | `queries.ts` |
|
||||
| Frisch | `useAllArticles()` filtered `status==='unread'`, slice(0,8) | `queries.ts` |
|
||||
| Highlights | `useAllHighlights()`, slice(0,5) | `queries.ts` |
|
||||
| Stats-Strip | `useStats()` — direkt `savedThisWeek`, `finishedThisWeek`, `topSites` | `queries.ts` |
|
||||
| Quellen | `useStats().topSites` | `queries.ts` |
|
||||
| Favoriten | `useAllArticles()` filtered `isFavorite`, slice(0,4) | `queries.ts` |
|
||||
| Archiv-Count | `useStats().archived` | `queries.ts` |
|
||||
|
||||
Keine neue Query notwendig, nur Views die `$derived` darüberlegen.
|
||||
|
||||
## Struktur
|
||||
|
||||
```
|
||||
apps/mana/apps/web/src/lib/modules/articles/
|
||||
├── HomeView.svelte ← NEU — Default-Einstieg
|
||||
├── ListView.svelte ← bleibt, Fallback für Power-User
|
||||
├── views/
|
||||
│ ├── DetailView.svelte (unverändert)
|
||||
│ └── HighlightsView.svelte (unverändert)
|
||||
└── components/
|
||||
├── HomeSectionWeiterlesen.svelte ← NEU
|
||||
├── HomeSectionFrisch.svelte ← NEU
|
||||
├── HomeSectionHighlights.svelte ← NEU
|
||||
├── HomeSectionStats.svelte ← NEU
|
||||
├── HomeSectionSources.svelte ← NEU
|
||||
├── HomeSectionFavorites.svelte ← NEU
|
||||
└── ArticleCard.svelte ← NEU — geteilte Karte, aus ListView extrahiert
|
||||
```
|
||||
|
||||
Die einzelnen Section-Components sind bewusst klein und isoliert. HomeView ist nur Layout + Section-Orchestration.
|
||||
|
||||
**Shared `ArticleCard.svelte`** wird aus der ListView extrahiert (aktuell inline), damit beide Views identisch aussehen.
|
||||
|
||||
## Routing
|
||||
|
||||
- `/articles` → mountet `HomeView` (war bisher `ListView`)
|
||||
- `/articles/list` (**neu**) → mountet `ListView` (Fallback-Route für User die die flache Liste wollen)
|
||||
- `/articles/[id]` (unverändert)
|
||||
- `/articles/add` (unverändert)
|
||||
- `/articles/highlights` (unverändert)
|
||||
- `/articles/settings` (unverändert)
|
||||
|
||||
`/articles/list` verlinkt man aus der HomeView via „Alle ungelesenen →" Buttons; direkte URL-Navigation ist der Backup.
|
||||
|
||||
## App-Registry-Integration
|
||||
|
||||
Neu in `apps/mana/apps/web/src/lib/app-registry/apps.ts` — Articles als draggable Scene-App registrieren:
|
||||
|
||||
```typescript
|
||||
registerApp({
|
||||
id: 'articles',
|
||||
name: 'Artikel',
|
||||
color: '#f97316',
|
||||
icon: BookOpen, // oder BookmarkSimple aus @mana/shared-icons
|
||||
views: {
|
||||
list: { load: () => import('$lib/modules/articles/HomeView.svelte') },
|
||||
detail: { load: () => import('$lib/modules/articles/views/DetailView.svelte') },
|
||||
},
|
||||
contextMenuActions: [
|
||||
{
|
||||
id: 'new-article',
|
||||
label: 'URL speichern',
|
||||
icon: Plus,
|
||||
action: () => goto('/articles/add'),
|
||||
},
|
||||
],
|
||||
collection: 'articles',
|
||||
paramKey: 'articleId',
|
||||
dragType: 'article',
|
||||
getDisplayData: (item) => ({
|
||||
title: (item.title as string) || 'Artikel',
|
||||
subtitle: item.siteName as string | undefined,
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
Danach ist Articles im Scene-App-Picker verfügbar und User kann „Articles" in jede Workbench-Scene ziehen — dort rendert dann die HomeView statt des Dashboard-Widgets. Das Dashboard-Widget bleibt für den separaten `/dashboard`-Surface.
|
||||
|
||||
## UI-Tokens
|
||||
|
||||
Alle Sektions-Chrome (Überschriften, Buttons, Cards) nutzt die globalen Theme-Tokens aus `@mana/shared-tailwind`:
|
||||
|
||||
- Überschriften: `text-foreground font-semibold text-sm uppercase tracking-wide`
|
||||
- Cards: `bg-card border-border hover:bg-surface-hover`
|
||||
- Muted text: `text-muted-foreground`
|
||||
- Primary-Akzente (Favoriten-Stern, Weiterlesen-Progress): weiterhin Articles-Orange `#f97316` — konsistent mit dem restlichen Modul-Branding
|
||||
|
||||
Keine Tailwind-Raw-Farben (`bg-gray-*`, `bg-white/*`). Der pre-existing `validate-theme-tokens.mjs`-Audit darf nicht neu anschlagen.
|
||||
|
||||
## Reihenfolge (Milestones)
|
||||
|
||||
1. **M9.1 — ArticleCard extrahieren** — bestehende ListView-Card-Logik in `components/ArticleCard.svelte` auslagern, ListView angepasst, keine Verhaltensänderung. *Ziel: keine Regression, nur Refactor.*
|
||||
2. **M9.2 — HomeView-Skelett + Sektionen** — alle 6 Section-Components neu, HomeView orchestriert. Leere Sektionen werden ausgeblendet (`{#if ...}`). Route `/articles` mountet neu die HomeView.
|
||||
3. **M9.3 — ListView-Route** — `/articles/list` angelegt, rendert weiterhin die bestehende `ListView.svelte`. „Alle ungelesenen →"-Links aus HomeView linken dorthin.
|
||||
4. **M9.4 — App-Registry-Eintrag** — Articles in `apps.ts` registriert, Icon ausgewählt, drag-type + getDisplayData. Articles erscheint im Scene-App-Picker.
|
||||
5. **M9.5 — Site-Filter in ListView** (klein) — ListView bekommt `?site=<siteName>`-Query-Param-Support damit „Deine Quellen"-Links funktionieren. Tag-Filter analog via `?tag=<tagId>` falls nicht schon da.
|
||||
|
||||
## Offene Fragen
|
||||
|
||||
- **Continue-Reading-Karten Horizontal vs. Vertical**: horizontal-scroll wirkt iOS-Reader-haft, vertical konsistent mit Rest. Ich neige zu horizontal für die Continue-Reading (weil's eine „Carousel of things you started"-Affordanz hat) und vertikal für alles andere. Wenn's zu bunt wird, alles vertikal — wenig Aufwand umzubauen.
|
||||
- **Sektions-Reihenfolge** — obige Liste ist mein Vorschlag. „Weiterlesen" zuerst fühlt sich richtig an (wie bei Netflix „Continue Watching"). „Frisch" danach. Highlights-Preview als drittes. Alternativ wäre „Frisch" primär wenn kein Weiterlesen da ist — das passiert aber schon durch `{#if}`-Gating.
|
||||
- **Max-Counts** pro Sektion (8 / 5 / 4) — Bauchgefühl. Bei User-Feedback nachjustieren. Dokumentiert als CONSTANTS-File falls's mal konfigurierbar werden soll.
|
||||
- **Icon für die App-Registry** — ich würde `BookOpen` oder `BookmarkSimple` aus `@mana/shared-icons` nehmen. Passt zu „Lesen / Später lesen". Abstimmung mit dem aktuellen `APP_ICONS.articles`-SVG (Lesezeichen-Dokument) — konsistent halten.
|
||||
- **„Site-Filter" URL-Schema**: ListView akzeptiert aktuell nur `?filter=<status>`. Neu brauche ich `?site=<name>` + `?tag=<id>`. Pure Query-Param-Erweiterung in der ListView. Kein neues Routing.
|
||||
- **Mobile-Layout**: max-width 700px wie DetailView-Meta-Bar? Oder volle Breite mit ggf. 2-Spalten-Grid auf Desktop? Erste Iteration: single-column, 800px max, vertikal. 2-Spalten nur wenn später Bedarf.
|
||||
Loading…
Add table
Add a link
Reference in a new issue