mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 03:01:09 +02:00
feat(articles): M4 tags + status filter, M5 migrate news:type='saved'
M4 — Tags + Filter: - queries.ts: useArticleTagIds(id) + batched useArticleTagMap(ids) live queries against articleTagOps (the junction into globalTags). - DetailView: TagField from @mana/shared-ui with the global tag pool + this article's selected ids; onChange fans out through articleTagOps.setTags, which diffs add/remove internally. - ListView: 6 filter chips (Alle | Ungelesen | In Arbeit | Gelesen | Favoriten | Archiv) with live counts. Archived articles are hidden from the "Alle" view and only surface under the Archiv filter. Tag chips render inline on each card using the batched tag map + the global tag pool for colour lookup. M5 — Migration + news deprecation: - modules/articles/migrations/from-news.ts: boot-gated migration (per- device localStorage sentinel). Reads newsArticles with type='saved', decrypts under the newsArticles allowlist, re-encrypts under the articles allowlist, and copies into the articles table. Status maps isArchived→archived, isRead→finished, else unread. Source rows get soft-deleted so the sync engine removes them from other devices. Ran after crypto init (from (app)/+layout.svelte boot block), not in the Dexie .upgrade() hook, because the decrypt→re-encrypt round- trip needs Web Crypto + the master key. - news/stores/articles.svelte.ts: removed saveFromUrl — ad-hoc URL saves now live in the articles module. - news/api.ts: removed extractFromUrl helper + ExtractedArticleDto. The /api/v1/news/extract/* routes stay in apps/api for now because news-research still hits them for RSS discovery. - news/index.ts: dropped the extractFromUrl re-export. - news/tools.ts: the save_news_article AI tool keeps its name (so historic Mission iterations in the DB still resolve) but its execute body now routes through the articles module's saveFromUrl. - routes/(app)/news/add + /news/saved: replaced with single-shot redirects to /articles/add and /articles respectively. - news-research ListView + page: "Speichern" buttons now route to the articles module and navigate to /articles/[id] on success. Plan: docs/plans/articles-module.md. M6 (AI tools + proposal inbox), M7 (share target + bookmarklet), M8 (highlights view + stats) open. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9d6a5a53a8
commit
04293ed5e7
14 changed files with 415 additions and 918 deletions
|
|
@ -1,17 +1,72 @@
|
|||
<!--
|
||||
Articles — ListView (M1 skeleton)
|
||||
Shows saved articles sorted by savedAt desc. Empty-state points
|
||||
at /articles/add (route lands in M2). Typography, reader, highlights,
|
||||
tags, filters all land in later milestones.
|
||||
Articles — ListView
|
||||
Filter chips (Alle | Ungelesen | In Arbeit | Favoriten | Archiv) + card
|
||||
list with per-card tag chips. Tag names + colours come from the global
|
||||
tags table via useAllTags; the per-article tag ids via a batched
|
||||
getTagIdsForMany to avoid N+1.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { useAllArticles } from './queries';
|
||||
import { TagChip } from '@mana/shared-ui';
|
||||
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 FILTERS: { id: Filter; label: string }[] = [
|
||||
{ id: 'all', label: 'Alle' },
|
||||
{ id: 'unread', label: 'Ungelesen' },
|
||||
{ id: 'reading', label: 'In Arbeit' },
|
||||
{ id: 'finished', label: 'Gelesen' },
|
||||
{ id: 'favorites', label: 'Favoriten' },
|
||||
{ id: 'archived', label: 'Archiv' },
|
||||
];
|
||||
|
||||
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');
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
|
||||
const counts = $derived.by(() => ({
|
||||
all: articles.filter((x) => x.status !== 'archived').length,
|
||||
unread: articles.filter((x) => x.status === 'unread').length,
|
||||
reading: articles.filter((x) => x.status === 'reading').length,
|
||||
finished: articles.filter((x) => x.status === 'finished').length,
|
||||
favorites: articles.filter((x) => x.isFavorite && x.status !== 'archived').length,
|
||||
archived: articles.filter((x) => x.status === 'archived').length,
|
||||
}));
|
||||
|
||||
function tagsFor(article: Article) {
|
||||
const ids = tagMap$.value.get(article.id) ?? [];
|
||||
if (ids.length === 0) return [];
|
||||
const all = allTags$.value;
|
||||
return ids
|
||||
.map((id) => all.find((t) => t.id === id))
|
||||
.filter((t): t is (typeof all)[number] => !!t);
|
||||
}
|
||||
|
||||
function openArticle(a: Article) {
|
||||
goto(`/articles/${a.id}`);
|
||||
}
|
||||
|
|
@ -28,6 +83,22 @@
|
|||
+ Neu speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-row" role="tablist" aria-label="Filter">
|
||||
{#each FILTERS as f (f.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="filter-chip"
|
||||
class:active={filter === f.id}
|
||||
role="tab"
|
||||
aria-selected={filter === f.id}
|
||||
onclick={() => (filter = f.id)}
|
||||
>
|
||||
{f.label}
|
||||
<span class="count">{counts[f.id]}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if articles$.loading}
|
||||
|
|
@ -43,9 +114,15 @@
|
|||
Erste URL speichern
|
||||
</button>
|
||||
</div>
|
||||
{:else if filtered.length === 0}
|
||||
<div class="empty-state">
|
||||
<p class="empty-headline">Nichts in diesem Filter.</p>
|
||||
<p class="empty-sub">Probier einen anderen Filter oder speichere weitere Artikel.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="article-list">
|
||||
{#each articles as article (article.id)}
|
||||
{#each filtered as article (article.id)}
|
||||
{@const articleTags = tagsFor(article)}
|
||||
<li>
|
||||
<button type="button" class="article-card" onclick={() => openArticle(article)}>
|
||||
<div class="meta">
|
||||
|
|
@ -56,11 +133,21 @@
|
|||
<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>
|
||||
</li>
|
||||
{/each}
|
||||
|
|
@ -75,13 +162,14 @@
|
|||
padding: 1.5rem;
|
||||
}
|
||||
.header {
|
||||
margin-bottom: 1.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
|
|
@ -108,6 +196,39 @@
|
|||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.filter-row {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.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;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.filter-chip:hover {
|
||||
border-color: var(--color-border-strong, rgba(0, 0, 0, 0.25));
|
||||
}
|
||||
.filter-chip.active {
|
||||
background: #f97316;
|
||||
border-color: #f97316;
|
||||
color: white;
|
||||
}
|
||||
.filter-chip .count {
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.8;
|
||||
padding: 0 0.35rem;
|
||||
background: color-mix(in srgb, currentColor 15%, transparent);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.muted {
|
||||
color: var(--color-text-muted, #64748b);
|
||||
font-size: 0.9rem;
|
||||
|
|
@ -178,10 +299,17 @@
|
|||
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;
|
||||
|
|
@ -197,4 +325,10 @@
|
|||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ export {
|
|||
useAllArticles,
|
||||
useArticle,
|
||||
useArticleHighlights,
|
||||
useArticleTagIds,
|
||||
useArticleTagMap,
|
||||
toArticle,
|
||||
toHighlight,
|
||||
filterByStatus,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* One-off migration: move `newsArticles` with `type='saved'` into the
|
||||
* new `articles` module.
|
||||
*
|
||||
* Runs at app-shell boot (from routes/(app)/+layout.svelte) rather than
|
||||
* inside the Dexie `.upgrade()` hook because we need the encryption
|
||||
* layer initialised: the source rows are encrypted under the
|
||||
* `newsArticles` field allowlist, the target rows need to be
|
||||
* re-encrypted under the `articles` allowlist, and both roundtrips
|
||||
* require Web Crypto + the master key — which the Dexie upgrade path
|
||||
* runs before.
|
||||
*
|
||||
* Idempotent: a localStorage sentinel prevents re-runs per device.
|
||||
* The original rows are soft-deleted (deletedAt stamped) so the sync
|
||||
* layer propagates the removal to the server and to other devices.
|
||||
*
|
||||
* Migration mapping:
|
||||
* newsArticles.isArchived = true → articles.status = 'archived'
|
||||
* newsArticles.isRead = true → articles.status = 'finished'
|
||||
* otherwise → articles.status = 'unread'
|
||||
*
|
||||
* isFavorite, createdAt, userId carry across. `sourceSlug` /
|
||||
* `sourceCuratedId` / `categoryId` don't have a counterpart on
|
||||
* articles (they're news-feed-specific) and are dropped — the user's
|
||||
* reading-list view doesn't depend on them.
|
||||
*/
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import { encryptRecord, decryptRecords } from '$lib/data/crypto';
|
||||
import { hasAnyEncryption } from '$lib/data/crypto/registry';
|
||||
import type { LocalArticle as NewLocalArticle, ArticleStatus } from '../types';
|
||||
|
||||
const SENTINEL_KEY = 'mana:articles:from-news-migration:v1';
|
||||
|
||||
// Shape of the source rows we care about. Kept narrow so the migration
|
||||
// stays decoupled from the news module's evolving type file.
|
||||
interface LegacyNewsArticle {
|
||||
id: string;
|
||||
type: 'curated' | 'saved';
|
||||
originalUrl: string;
|
||||
title: string;
|
||||
excerpt: string | null;
|
||||
content: string;
|
||||
htmlContent: string | null;
|
||||
author: string | null;
|
||||
siteName: string | null;
|
||||
imageUrl: string | null;
|
||||
wordCount: number | null;
|
||||
readingTimeMinutes: number | null;
|
||||
publishedAt: string | null;
|
||||
isArchived?: boolean;
|
||||
isRead?: boolean;
|
||||
isFavorite?: boolean;
|
||||
userId?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
deletedAt?: string | null;
|
||||
}
|
||||
|
||||
function statusFor(row: LegacyNewsArticle): ArticleStatus {
|
||||
if (row.isArchived) return 'archived';
|
||||
if (row.isRead) return 'finished';
|
||||
return 'unread';
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the migration once per device. Returns the number of rows moved.
|
||||
* Fire-and-forget from app boot; errors are logged but never thrown so
|
||||
* a single broken row never blocks the rest of the app from starting.
|
||||
*/
|
||||
export async function runArticlesFromNewsMigration(): Promise<number> {
|
||||
if (typeof window === 'undefined') return 0;
|
||||
if (window.localStorage.getItem(SENTINEL_KEY)) return 0;
|
||||
|
||||
// The migration requires the crypto layer to be live. If the app is
|
||||
// running entirely plaintext (Phase 1 bootstrap or a test harness),
|
||||
// decryptRecords is a pass-through so this still works — we check
|
||||
// anyway as a defensive gate and bail if the registry isn't ready.
|
||||
try {
|
||||
// Access the flag so linters don't flag the import as unused when
|
||||
// someone later decides the gate isn't worth keeping. The call is
|
||||
// cheap either way.
|
||||
hasAnyEncryption();
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const newsTable = db.table<LegacyNewsArticle>('newsArticles');
|
||||
const articlesTable = db.table<NewLocalArticle>('articles');
|
||||
|
||||
const candidates = await newsTable.where('type').equals('saved').toArray();
|
||||
const visible = candidates.filter((row) => !row.deletedAt);
|
||||
if (visible.length === 0) {
|
||||
window.localStorage.setItem(SENTINEL_KEY, new Date().toISOString());
|
||||
return 0;
|
||||
}
|
||||
|
||||
const decrypted = (await decryptRecords(
|
||||
'newsArticles',
|
||||
visible as unknown as Record<string, unknown>[]
|
||||
)) as unknown as LegacyNewsArticle[];
|
||||
|
||||
const now = new Date().toISOString();
|
||||
let moved = 0;
|
||||
|
||||
// Separate transactions: one write batch per row with its own
|
||||
// encryption roundtrip, so a single bad row doesn't lose the
|
||||
// batch. Dexie auto-batches the internal index updates either way.
|
||||
for (const row of decrypted) {
|
||||
try {
|
||||
const newRow: NewLocalArticle = {
|
||||
id: crypto.randomUUID(),
|
||||
originalUrl: row.originalUrl,
|
||||
title: row.title,
|
||||
excerpt: row.excerpt,
|
||||
content: row.content,
|
||||
htmlContent: row.htmlContent,
|
||||
author: row.author,
|
||||
siteName: row.siteName,
|
||||
imageUrl: row.imageUrl,
|
||||
wordCount: row.wordCount,
|
||||
readingTimeMinutes: row.readingTimeMinutes,
|
||||
publishedAt: row.publishedAt,
|
||||
status: statusFor(row),
|
||||
readingProgress: 0,
|
||||
isFavorite: row.isFavorite ?? false,
|
||||
savedAt: row.createdAt ?? now,
|
||||
readAt: row.isRead ? (row.updatedAt ?? now) : null,
|
||||
userNote: null,
|
||||
extractedVersion: 1,
|
||||
// userId is stamped by the Dexie creating-hook from the active
|
||||
// session — don't set it manually, let the hook do its job.
|
||||
};
|
||||
await encryptRecord('articles', newRow);
|
||||
await articlesTable.add(newRow);
|
||||
// Soft-delete the source so the sync engine removes it from
|
||||
// the server + other devices. Keep it in the local table so
|
||||
// if someone later rolls back the migration they can still
|
||||
// see what was there.
|
||||
await newsTable.update(row.id, { deletedAt: now, updatedAt: now });
|
||||
moved++;
|
||||
} catch (rowErr) {
|
||||
console.warn(`[articles/from-news] skipping row ${row.id} — ${(rowErr as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
window.localStorage.setItem(SENTINEL_KEY, now);
|
||||
if (moved > 0) {
|
||||
console.info(`[articles/from-news] migrated ${moved} saved article(s) into /articles`);
|
||||
}
|
||||
return moved;
|
||||
} catch (err) {
|
||||
console.error('[articles/from-news] migration failed:', err);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear the sentinel so the next boot re-runs. Test / recovery helper only. */
|
||||
export function resetArticlesFromNewsSentinel(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.removeItem(SENTINEL_KEY);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { scopedForModule, scopedGet } from '$lib/data/scope';
|
||||
import { articleTagOps } from './stores/tags.svelte';
|
||||
import type { LocalArticle, LocalHighlight, Article, Highlight, ArticleStatus } from './types';
|
||||
|
||||
// ─── Type Converters ─────────────────────────────────────
|
||||
|
|
@ -84,6 +85,27 @@ export function useArticle(id: string) {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag IDs currently linked to this article. Live — reacts to both
|
||||
* `articleTags` junction writes and tag CRUD on the global `tags`
|
||||
* table, so the DetailView's TagField stays in sync with both sides.
|
||||
*/
|
||||
export function useArticleTagIds(articleId: string) {
|
||||
return useLiveQueryWithDefault(async () => articleTagOps.getTagIds(articleId), [] as string[]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Batched tag-id lookup for the ListView. Returns a Map keyed by
|
||||
* articleId; entries with no tags are absent from the map. Single
|
||||
* Dexie query regardless of how many articles are shown.
|
||||
*/
|
||||
export function useArticleTagMap(articleIds: string[]) {
|
||||
return useLiveQueryWithDefault(
|
||||
async () => articleTagOps.getTagIdsForMany(articleIds),
|
||||
new Map<string, string[]>()
|
||||
);
|
||||
}
|
||||
|
||||
export function useArticleHighlights(articleId: string) {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
// scopedForModule returns the scope-filtered Collection; we narrow
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@
|
|||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { useArticle } from '../queries';
|
||||
import { TagField } from '@mana/shared-ui';
|
||||
import { useArticle, useArticleTagIds } from '../queries';
|
||||
import { articlesStore } from '../stores/articles.svelte';
|
||||
import { articleTagOps, useAllTags } from '../stores/tags.svelte';
|
||||
import ReaderView from '../components/ReaderView.svelte';
|
||||
import HighlightLayer from '../components/HighlightLayer.svelte';
|
||||
|
||||
|
|
@ -27,6 +29,12 @@
|
|||
const article$ = $derived.by(() => useArticle(id));
|
||||
const article = $derived(article$.value);
|
||||
|
||||
// Tags: globally-available tag pool + the ids linked to *this* article.
|
||||
// TagField takes the full pool + selected ids; on change we fan-out
|
||||
// through articleTagOps.setTags which handles add/remove diff internally.
|
||||
const allTags$ = useAllTags();
|
||||
const tagIds$ = $derived.by(() => useArticleTagIds(id));
|
||||
|
||||
// Typography state — per-session only for now. Persisting into userSettings
|
||||
// comes later; M2 just gets the UX loop working.
|
||||
let fontSize = $state(1);
|
||||
|
|
@ -74,6 +82,11 @@
|
|||
}
|
||||
await articlesStore.setProgress(article.id, progress);
|
||||
}
|
||||
|
||||
async function onTagsChange(ids: string[]) {
|
||||
if (!article) return;
|
||||
await articleTagOps.setTags(article.id, ids);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -172,6 +185,15 @@
|
|||
{#if article.readingTimeMinutes}<span>· {article.readingTimeMinutes} min</span>{/if}
|
||||
{#if article.wordCount}<span>· {article.wordCount} Wörter</span>{/if}
|
||||
</div>
|
||||
<div class="tags-row">
|
||||
<TagField
|
||||
tags={allTags$.value}
|
||||
selectedIds={tagIds$.value}
|
||||
onChange={onTagsChange}
|
||||
addLabel="Tag"
|
||||
placeholder="Tag suchen oder erstellen…"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReaderView
|
||||
|
|
@ -318,6 +340,9 @@
|
|||
gap: 0.3rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tags-row {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.actionbar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import type { ViewProps } from '$lib/app-registry';
|
||||
import { researchSessionStore } from '$lib/modules/news-research/stores/session.svelte';
|
||||
import { articlesStore } from '$lib/modules/news/stores/articles.svelte';
|
||||
import { articlesStore } from '$lib/modules/articles/stores/articles.svelte';
|
||||
import { preferencesStore } from '$lib/modules/news/stores/preferences.svelte';
|
||||
import { usePreferences } from '$lib/modules/news/queries';
|
||||
|
||||
|
|
@ -71,8 +71,8 @@
|
|||
savingUrl = articleUrl;
|
||||
saveError = null;
|
||||
try {
|
||||
const article = await articlesStore.saveFromUrl(articleUrl);
|
||||
goto(`/news/${article.id}`);
|
||||
const { article } = await articlesStore.saveFromUrl(articleUrl);
|
||||
goto(`/articles/${article.id}`);
|
||||
} catch (err) {
|
||||
saveError = err instanceof Error ? err.message : 'Speichern fehlgeschlagen';
|
||||
savingUrl = null;
|
||||
|
|
|
|||
|
|
@ -83,39 +83,7 @@ export async function fetchFeed(
|
|||
return (await response.json()) as FeedArticleDto[];
|
||||
}
|
||||
|
||||
// ─── Ad-hoc URL extraction ─────────────────────────────────
|
||||
|
||||
export interface ExtractedArticleDto {
|
||||
id: string;
|
||||
type: 'saved';
|
||||
sourceOrigin: 'user_saved';
|
||||
originalUrl: string;
|
||||
title: string;
|
||||
content: string;
|
||||
htmlContent: string;
|
||||
excerpt: string;
|
||||
author: string | null;
|
||||
siteName: string | null;
|
||||
wordCount: number;
|
||||
readingTimeMinutes: number;
|
||||
isArchived: boolean;
|
||||
}
|
||||
|
||||
export async function extractFromUrl(
|
||||
url: string,
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<ExtractedArticleDto> {
|
||||
const response = await fetchImpl(`${getManaApiUrl()}/api/v1/news/extract/save`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(await authHeader()),
|
||||
},
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`extractFromUrl failed: ${response.status} ${text}`);
|
||||
}
|
||||
return (await response.json()) as ExtractedArticleDto;
|
||||
}
|
||||
// Ad-hoc URL extraction moved to the `articles` module in M5 — see
|
||||
// `modules/articles/api.ts` and `modules/articles/stores/articles.svelte.ts`.
|
||||
// The `/api/v1/news/extract/*` routes in apps/api are kept for now as
|
||||
// a legacy surface; the `news-research` module still relies on them.
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export { preferencesStore } from './stores/preferences.svelte';
|
|||
export { reactionsStore } from './stores/reactions.svelte';
|
||||
export { feedCacheStore } from './stores/feed-cache.svelte';
|
||||
|
||||
export { fetchFeed, extractFromUrl } from './api';
|
||||
export { fetchFeed } from './api';
|
||||
export type { FeedArticleDto, FeedQuery } from './api';
|
||||
|
||||
export { SOURCES_META, SOURCE_META_BY_SLUG, sourcesForTopic, TOPIC_LABELS } from './sources-meta';
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
/**
|
||||
* Articles store — the user's saved reading list.
|
||||
*
|
||||
* Two paths in:
|
||||
* - saveFromCurated(article) copies a row from the local pool
|
||||
* mirror into the encrypted reading list. Used when the user
|
||||
* hits "speichern" on a feed card.
|
||||
* - saveFromUrl(url) hits POST /api/v1/news/extract/save and
|
||||
* stores the result. Used by /news/add for ad-hoc URLs.
|
||||
* Now single-purpose: saveFromCurated copies a row from the local pool
|
||||
* mirror into the encrypted reading list (hit when the user presses
|
||||
* "speichern" on a feed card). The ad-hoc URL path (`saveFromUrl` +
|
||||
* the `type: 'saved'` discriminator) moved to the Articles module in
|
||||
* M5 — see `modules/articles/migrations/from-news.ts` for the one-off
|
||||
* data migration and `modules/articles/stores/articles.svelte.ts` for
|
||||
* the replacement flow.
|
||||
*
|
||||
* All other operations (read/archive/favorite/delete) are plain
|
||||
* updates against `newsArticles`.
|
||||
|
|
@ -15,7 +16,6 @@
|
|||
import { encryptRecord } from '$lib/data/crypto';
|
||||
import { emitDomainEvent } from '$lib/data/events';
|
||||
import { articleTable } from '../collections';
|
||||
import { extractFromUrl } from '../api';
|
||||
import { toArticle } from '../queries';
|
||||
import type { Article, LocalArticle, LocalCachedArticle } from '../types';
|
||||
|
||||
|
|
@ -58,35 +58,6 @@ export const articlesStore = {
|
|||
return snapshot;
|
||||
},
|
||||
|
||||
async saveFromUrl(url: string): Promise<Article> {
|
||||
const extracted = await extractFromUrl(url);
|
||||
const newLocal: LocalArticle = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'saved',
|
||||
sourceCuratedId: null,
|
||||
originalUrl: extracted.originalUrl,
|
||||
title: extracted.title,
|
||||
excerpt: extracted.excerpt,
|
||||
content: extracted.content,
|
||||
htmlContent: extracted.htmlContent,
|
||||
author: extracted.author,
|
||||
siteName: extracted.siteName,
|
||||
sourceSlug: null,
|
||||
imageUrl: null,
|
||||
categoryId: null,
|
||||
wordCount: extracted.wordCount,
|
||||
readingTimeMinutes: extracted.readingTimeMinutes,
|
||||
publishedAt: null,
|
||||
isArchived: false,
|
||||
isRead: false,
|
||||
isFavorite: false,
|
||||
};
|
||||
const snapshot = toArticle(newLocal);
|
||||
await encryptRecord('newsArticles', newLocal);
|
||||
await articleTable.add(newLocal);
|
||||
return snapshot;
|
||||
},
|
||||
|
||||
async markRead(id: string, isRead = true): Promise<void> {
|
||||
await articleTable.update(id, {
|
||||
isRead,
|
||||
|
|
|
|||
|
|
@ -2,15 +2,19 @@
|
|||
* News Tools — LLM-accessible operations for the news module.
|
||||
*
|
||||
* `save_news_article` is the agent's path into the user's reading list.
|
||||
* On approve, the executor calls `articlesStore.saveFromUrl(url)` which
|
||||
* routes through `apps/api /api/v1/news/extract/save` (Readability) and
|
||||
* stores the encrypted result in `newsArticles`. `title` and `summary`
|
||||
* are display hints — the canonical title/excerpt come back from the
|
||||
* extractor so the AI can't lie about content.
|
||||
* M5 moved the saved-article storage to the `articles` module; this
|
||||
* tool now routes through `articlesStore.saveFromUrl(url)` there. The
|
||||
* tool name stays `save_news_article` because historic AI mission
|
||||
* iterations in the DB reference it — renaming would break the audit
|
||||
* trail. A future `save_article` can be added as an alias in M6.
|
||||
*
|
||||
* `title` and `summary` are display hints for the approval dialog —
|
||||
* the canonical title/excerpt come from the extractor so the AI can't
|
||||
* lie about content.
|
||||
*/
|
||||
|
||||
import type { ModuleTool } from '$lib/data/tools/types';
|
||||
import { articlesStore } from './stores/articles.svelte';
|
||||
import { articlesStore } from '$lib/modules/articles/stores/articles.svelte';
|
||||
|
||||
export const newsTools: ModuleTool[] = [
|
||||
{
|
||||
|
|
@ -35,11 +39,13 @@ export const newsTools: ModuleTool[] = [
|
|||
],
|
||||
async execute(params) {
|
||||
const url = params.url as string;
|
||||
const article = await articlesStore.saveFromUrl(url);
|
||||
const { article, duplicate } = await articlesStore.saveFromUrl(url);
|
||||
return {
|
||||
success: true,
|
||||
message: `Artikel gespeichert: ${article.title}`,
|
||||
data: { articleId: article.id, title: article.title },
|
||||
message: duplicate
|
||||
? `Artikel bereits gespeichert: ${article.title}`
|
||||
: `Artikel gespeichert: ${article.title}`,
|
||||
data: { articleId: article.id, title: article.title, duplicate },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
import { todoReminderSource } from '$lib/modules/todo/reminder-source';
|
||||
import { startEventStore, stopEventStore } from '$lib/data/events/event-store';
|
||||
import { startMissionTick, stopMissionTick } from '$lib/data/ai/missions/setup';
|
||||
import { runArticlesFromNewsMigration } from '$lib/modules/articles/migrations/from-news';
|
||||
import {
|
||||
startServerIterationExecutor,
|
||||
stopServerIterationExecutor,
|
||||
|
|
@ -546,6 +547,10 @@
|
|||
// Apply server-planned iterations locally on sync — see
|
||||
// data/ai/missions/server-iteration-executor.ts.
|
||||
startServerIterationExecutor();
|
||||
// One-off migration: legacy news `type='saved'` rows → new
|
||||
// articles module. Sentinel-gated so it runs once per device.
|
||||
// See modules/articles/migrations/from-news.ts.
|
||||
void runArticlesFromNewsMigration();
|
||||
});
|
||||
|
||||
// Restore nav collapsed state (cheap, keep inline)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { researchSessionStore } from '$lib/modules/news-research/stores/session.svelte';
|
||||
import { articlesStore } from '$lib/modules/news/stores/articles.svelte';
|
||||
import { articlesStore } from '$lib/modules/articles/stores/articles.svelte';
|
||||
import { preferencesStore } from '$lib/modules/news/stores/preferences.svelte';
|
||||
import { usePreferences } from '$lib/modules/news/queries';
|
||||
|
||||
|
|
@ -75,8 +75,8 @@
|
|||
savingUrl = articleUrl;
|
||||
saveError = null;
|
||||
try {
|
||||
const article = await articlesStore.saveFromUrl(articleUrl);
|
||||
goto(`/news/${article.id}`);
|
||||
const { article } = await articlesStore.saveFromUrl(articleUrl);
|
||||
goto(`/articles/${article.id}`);
|
||||
} catch (err) {
|
||||
saveError = err instanceof Error ? err.message : 'Speichern fehlgeschlagen';
|
||||
savingUrl = null;
|
||||
|
|
|
|||
|
|
@ -1,137 +1,23 @@
|
|||
<!--
|
||||
/news/add — paste an arbitrary URL, hit save, get a saved article.
|
||||
|
||||
Calls POST /api/v1/news/extract/save which runs Mozilla Readability
|
||||
on the server, returns the cleaned article shape, and we drop it
|
||||
into the encrypted reading list via articlesStore.saveFromUrl.
|
||||
Legacy redirect: /news/add → /articles/add.
|
||||
The ad-hoc URL-save flow moved to the articles module in M5.
|
||||
Kept here so existing bookmarks and cross-links keep working.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { articlesStore } from '$lib/modules/news/stores/articles.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let url = $state('');
|
||||
let busy = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
function looksLikeUrl(s: string): boolean {
|
||||
try {
|
||||
const u = new URL(s.trim());
|
||||
return u.protocol === 'http:' || u.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (busy || !looksLikeUrl(url)) return;
|
||||
busy = true;
|
||||
error = null;
|
||||
try {
|
||||
const article = await articlesStore.saveFromUrl(url.trim());
|
||||
goto(`/news/${article.id}`);
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Speichern fehlgeschlagen';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
onMount(() => {
|
||||
goto('/articles/add', { replaceState: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>URL hinzufügen — News — Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<header class="header">
|
||||
<button type="button" class="back" onclick={() => goto('/news/saved')}>← Gespeichert</button>
|
||||
<h1>Artikel speichern</h1>
|
||||
<p class="hint">
|
||||
Füge eine URL ein. Wir extrahieren den Volltext (Mozilla Readability) und legen ihn in deine
|
||||
verschlüsselte Leseliste.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form class="form" onsubmit={submit}>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input type="url" placeholder="https://…" bind:value={url} disabled={busy} autofocus required />
|
||||
<button type="submit" disabled={busy || !looksLikeUrl(url)}>
|
||||
{busy ? 'Lade…' : 'Speichern'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="redirect">Verschoben nach <a href="/articles/add">/articles/add</a>…</p>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.header {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
.back {
|
||||
background: none;
|
||||
border: none;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.hint {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.form input {
|
||||
flex: 1;
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: hsl(var(--color-muted));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.9375rem;
|
||||
outline: none;
|
||||
}
|
||||
.form input:focus {
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
.form button {
|
||||
padding: 0.625rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-primary));
|
||||
color: white;
|
||||
border: none;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.form button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.error {
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-destructive) / 0.15);
|
||||
border: 1px solid hsl(var(--color-destructive) / 0.4);
|
||||
color: hsl(var(--color-destructive));
|
||||
font-size: 0.875rem;
|
||||
.redirect {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,709 +1,23 @@
|
|||
<!--
|
||||
Saved articles — the user's personal reading list.
|
||||
|
||||
Three tabs: Ungelesen / Favoriten / Archiv. Each card opens the
|
||||
shared reader at /news/[id]; the reader's dual-source lookup means
|
||||
the same URL works whether the article was saved from the curated
|
||||
pool or pasted as an ad-hoc URL.
|
||||
Legacy redirect: /news/saved → /articles.
|
||||
The "saved reading list" moved into the articles module in M5.
|
||||
Kept here so existing bookmarks and cross-links keep working.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import {
|
||||
useSavedArticles,
|
||||
useCategories,
|
||||
formatRelativeTime,
|
||||
toArticle,
|
||||
} from '$lib/modules/news/queries';
|
||||
import { articlesStore } from '$lib/modules/news/stores/articles.svelte';
|
||||
import { categoriesStore } from '$lib/modules/news/stores/categories.svelte';
|
||||
import { articleTable } from '$lib/modules/news/collections';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import type { Article } from '$lib/modules/news/types';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
const saved$ = useSavedArticles();
|
||||
const categories$ = useCategories();
|
||||
const all = $derived(saved$.value);
|
||||
const categories = $derived(categories$.value);
|
||||
|
||||
type Tab = 'unread' | 'favorites' | 'archive';
|
||||
let tab = $state<Tab>('unread');
|
||||
|
||||
// `null` = no filter (show all in the active tab). Otherwise the
|
||||
// categoryId we're scoped to.
|
||||
let activeCategoryId = $state<string | null>(null);
|
||||
|
||||
let showCategoryEditor = $state(false);
|
||||
let newCategoryName = $state('');
|
||||
let renamingId = $state<string | null>(null);
|
||||
let renamingName = $state('');
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const base = (() => {
|
||||
switch (tab) {
|
||||
case 'unread':
|
||||
return all.filter((a) => !a.isRead && !a.isArchived);
|
||||
case 'favorites':
|
||||
return all.filter((a) => a.isFavorite && !a.isArchived);
|
||||
case 'archive':
|
||||
// `archived` is filled by the effect below.
|
||||
return [] as Article[];
|
||||
}
|
||||
})();
|
||||
if (activeCategoryId == null) return base;
|
||||
return base.filter((a) => a.categoryId === activeCategoryId);
|
||||
onMount(() => {
|
||||
goto('/articles', { replaceState: true });
|
||||
});
|
||||
|
||||
// For "archive" tab: read isArchived rows directly from Dexie. Kept
|
||||
// minimal — not worth a second liveQuery hook for the MVP.
|
||||
let archived = $state<Article[]>([]);
|
||||
$effect(() => {
|
||||
if (tab !== 'archive') return;
|
||||
void (async () => {
|
||||
const rows = (await articleTable.toArray()).filter((a) => !a.deletedAt && a.isArchived);
|
||||
const decrypted = await decryptRecords('newsArticles', rows);
|
||||
archived = decrypted.map(toArticle).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
})();
|
||||
});
|
||||
|
||||
const visible = $derived(
|
||||
tab === 'archive'
|
||||
? activeCategoryId == null
|
||||
? archived
|
||||
: archived.filter((a) => a.categoryId === activeCategoryId)
|
||||
: filtered
|
||||
);
|
||||
|
||||
// Counts per category for the filter pills, computed against the
|
||||
// currently visible base set (so the numbers reflect the active tab).
|
||||
const baseSet = $derived(tab === 'archive' ? archived : filtered);
|
||||
const countByCategory = $derived.by(() => {
|
||||
const map: Record<string, number> = {};
|
||||
// `filtered` already applies the category filter, so we need a
|
||||
// pre-filter base. Re-derive it here.
|
||||
const pre =
|
||||
tab === 'unread'
|
||||
? all.filter((a) => !a.isRead && !a.isArchived)
|
||||
: tab === 'favorites'
|
||||
? all.filter((a) => a.isFavorite && !a.isArchived)
|
||||
: archived;
|
||||
for (const a of pre) {
|
||||
const key = a.categoryId ?? '__none__';
|
||||
map[key] = (map[key] ?? 0) + 1;
|
||||
}
|
||||
map.__all__ = pre.length;
|
||||
return map;
|
||||
});
|
||||
// Touch baseSet to silence the unused-binding linter — it's used to
|
||||
// keep the derivation of countByCategory reactive in case the upstream
|
||||
// query refreshes during a tab switch.
|
||||
$effect(() => {
|
||||
void baseSet.length;
|
||||
});
|
||||
|
||||
function open(article: Article) {
|
||||
// Curated saves keep their server uuid in sourceCuratedId, but
|
||||
// the local id is the primary key the reader prefers. Either id
|
||||
// works — the reader resolves both.
|
||||
goto(`/news/${article.sourceCuratedId ?? article.id}`);
|
||||
}
|
||||
|
||||
async function toggleFav(e: Event, id: string) {
|
||||
e.stopPropagation();
|
||||
await articlesStore.toggleFavorite(id);
|
||||
}
|
||||
async function archive(e: Event, id: string) {
|
||||
e.stopPropagation();
|
||||
await articlesStore.archive(id);
|
||||
}
|
||||
async function unarchive(e: Event, id: string) {
|
||||
e.stopPropagation();
|
||||
await articleTable.update(id, {
|
||||
isArchived: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
async function remove(e: Event, id: string) {
|
||||
e.stopPropagation();
|
||||
await articlesStore.delete(id);
|
||||
}
|
||||
|
||||
async function setCategory(articleId: string, categoryId: string | null) {
|
||||
await articlesStore.setCategory(articleId, categoryId);
|
||||
}
|
||||
|
||||
async function createCategory() {
|
||||
const name = newCategoryName.trim();
|
||||
if (!name) return;
|
||||
const created = await categoriesStore.create({ name });
|
||||
newCategoryName = '';
|
||||
activeCategoryId = created.id;
|
||||
}
|
||||
|
||||
function startRename(id: string, currentName: string) {
|
||||
renamingId = id;
|
||||
renamingName = currentName;
|
||||
}
|
||||
|
||||
async function commitRename() {
|
||||
if (!renamingId) return;
|
||||
await categoriesStore.rename(renamingId, renamingName);
|
||||
renamingId = null;
|
||||
}
|
||||
|
||||
async function deleteCategory(id: string) {
|
||||
if (!confirm('Kategorie löschen? Artikel bleiben erhalten.')) return;
|
||||
await categoriesStore.delete(id);
|
||||
if (activeCategoryId === id) activeCategoryId = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Gespeichert — News — Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<header class="header">
|
||||
<div>
|
||||
<button type="button" class="back" onclick={() => goto('/news')}>← Feed</button>
|
||||
<h1>Gespeichert</h1>
|
||||
</div>
|
||||
<a class="add-link" href="/news/add">+ URL hinzufügen</a>
|
||||
</header>
|
||||
|
||||
<nav class="tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="tab"
|
||||
class:active={tab === 'unread'}
|
||||
onclick={() => (tab = 'unread')}
|
||||
>
|
||||
Ungelesen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tab"
|
||||
class:active={tab === 'favorites'}
|
||||
onclick={() => (tab = 'favorites')}
|
||||
>
|
||||
Favoriten
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tab"
|
||||
class:active={tab === 'archive'}
|
||||
onclick={() => (tab = 'archive')}
|
||||
>
|
||||
Archiv
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Category filter strip -->
|
||||
<div class="categories-bar">
|
||||
<div class="cat-pills">
|
||||
<button
|
||||
type="button"
|
||||
class="cat-pill"
|
||||
class:active={activeCategoryId === null}
|
||||
onclick={() => (activeCategoryId = null)}
|
||||
>
|
||||
Alle
|
||||
<span class="count">{countByCategory.__all__ ?? 0}</span>
|
||||
</button>
|
||||
{#each categories as cat (cat.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="cat-pill"
|
||||
class:active={activeCategoryId === cat.id}
|
||||
style:--cat-color={cat.color}
|
||||
onclick={() => (activeCategoryId = cat.id)}
|
||||
>
|
||||
<span class="dot" style:background={cat.color}></span>
|
||||
{#if renamingId === cat.id}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={renamingName}
|
||||
onblur={commitRename}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') commitRename();
|
||||
if (e.key === 'Escape') renamingId = null;
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<span ondblclick={() => startRename(cat.id, cat.name)} role="button" tabindex="0">
|
||||
{cat.name}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="count">{countByCategory[cat.id] ?? 0}</span>
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="cat-edit"
|
||||
onclick={() => (showCategoryEditor = !showCategoryEditor)}
|
||||
title="Kategorien verwalten"
|
||||
>
|
||||
{showCategoryEditor ? '✕' : '+'}
|
||||
</button>
|
||||
</div>
|
||||
{#if showCategoryEditor}
|
||||
<div class="cat-editor">
|
||||
<form
|
||||
class="cat-add"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void createCategory();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Neue Kategorie…"
|
||||
bind:value={newCategoryName}
|
||||
maxlength="40"
|
||||
/>
|
||||
<button type="submit" disabled={!newCategoryName.trim()}>Hinzufügen</button>
|
||||
</form>
|
||||
{#if categories.length > 0}
|
||||
<ul class="cat-list">
|
||||
{#each categories as cat (cat.id)}
|
||||
<li>
|
||||
<span class="dot" style:background={cat.color}></span>
|
||||
<span class="cat-name">{cat.name}</span>
|
||||
<button type="button" class="link" onclick={() => startRename(cat.id, cat.name)}>
|
||||
umbenennen
|
||||
</button>
|
||||
<button type="button" class="link danger" onclick={() => deleteCategory(cat.id)}>
|
||||
löschen
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="hint">Noch keine Kategorien. Erstelle eine oben.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if visible.length === 0}
|
||||
<div class="empty">
|
||||
{#if tab === 'unread'}
|
||||
<p>Keine ungelesenen Artikel.</p>
|
||||
<p class="hint">Reagiere im Feed mit „❤️ Interessiert" um Artikel hier zu sammeln.</p>
|
||||
{:else if tab === 'favorites'}
|
||||
<p>Noch keine Favoriten.</p>
|
||||
{:else}
|
||||
<p>Archiv ist leer.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#each visible as article (article.id)}
|
||||
<article class="row">
|
||||
{#if article.imageUrl}
|
||||
<button
|
||||
type="button"
|
||||
class="thumb-btn"
|
||||
onclick={() => open(article)}
|
||||
aria-label="Öffnen"
|
||||
>
|
||||
<img src={article.imageUrl} alt="" loading="lazy" />
|
||||
</button>
|
||||
{/if}
|
||||
<div class="row-body">
|
||||
<div class="row-meta">
|
||||
<span class="site">{article.siteName ?? 'Eigener Link'}</span>
|
||||
{#if article.publishedAt}
|
||||
<span>·</span>
|
||||
<span>{formatRelativeTime(article.publishedAt)}</span>
|
||||
{/if}
|
||||
{#if article.readingTimeMinutes}
|
||||
<span>·</span>
|
||||
<span>{article.readingTimeMinutes} min</span>
|
||||
{/if}
|
||||
{#if article.type === 'saved'}
|
||||
<span class="badge">eigen</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button type="button" class="row-title" onclick={() => open(article)}>
|
||||
{article.title}
|
||||
</button>
|
||||
{#if article.excerpt}
|
||||
<p class="row-excerpt">{article.excerpt}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<select
|
||||
class="cat-select"
|
||||
value={article.categoryId ?? ''}
|
||||
onchange={(e) => {
|
||||
const v = (e.currentTarget as HTMLSelectElement).value;
|
||||
void setCategory(article.id, v === '' ? null : v);
|
||||
}}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
title="Kategorie"
|
||||
>
|
||||
<option value="">— Keine —</option>
|
||||
{#each categories as cat (cat.id)}
|
||||
<option value={cat.id}>{cat.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="icon"
|
||||
class:active={article.isFavorite}
|
||||
onclick={(e) => toggleFav(e, article.id)}
|
||||
title="Favorit"
|
||||
>
|
||||
⭐
|
||||
</button>
|
||||
{#if tab === 'archive'}
|
||||
<button
|
||||
type="button"
|
||||
class="icon"
|
||||
onclick={(e) => unarchive(e, article.id)}
|
||||
title="Wiederherstellen"
|
||||
>
|
||||
↩︎
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="icon"
|
||||
onclick={(e) => archive(e, article.id)}
|
||||
title="Archivieren"
|
||||
>
|
||||
📦
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="icon danger"
|
||||
onclick={(e) => remove(e, article.id)}
|
||||
title="Löschen"
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="redirect">Verschoben nach <a href="/articles">/articles</a>…</p>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem 4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
.back {
|
||||
background: none;
|
||||
border: none;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.add-link {
|
||||
padding: 0.5rem 0.875rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-primary));
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
.tab {
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tab.active {
|
||||
color: hsl(var(--color-foreground));
|
||||
border-bottom-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.categories-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.cat-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
}
|
||||
.cat-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.3rem 0.625rem;
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--color-muted));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cat-pill.active {
|
||||
background: hsl(var(--color-primary) / 0.18);
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
.cat-pill .dot {
|
||||
display: inline-block;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.cat-pill .count {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
background: hsl(var(--color-background));
|
||||
padding: 0 0.35rem;
|
||||
border-radius: 999px;
|
||||
min-width: 1.1rem;
|
||||
.redirect {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.cat-pill input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: hsl(var(--color-foreground));
|
||||
font: inherit;
|
||||
min-width: 4rem;
|
||||
outline: none;
|
||||
}
|
||||
.cat-edit {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--color-background));
|
||||
border: 1px dashed hsl(var(--color-border));
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.cat-editor {
|
||||
padding: 0.875rem;
|
||||
background: hsl(var(--color-muted));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.625rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
.cat-add {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.cat-add input {
|
||||
flex: 1;
|
||||
padding: 0.4rem 0.625rem;
|
||||
background: hsl(var(--color-background));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.4rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.8125rem;
|
||||
outline: none;
|
||||
}
|
||||
.cat-add button {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 0.4rem;
|
||||
background: hsl(var(--color-primary));
|
||||
color: white;
|
||||
border: none;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cat-add button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.cat-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.cat-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.cat-list .dot {
|
||||
display: inline-block;
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.cat-list .cat-name {
|
||||
flex: 1;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.cat-list .link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.cat-list .link.danger {
|
||||
color: hsl(var(--color-destructive));
|
||||
}
|
||||
.cat-editor .hint {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.cat-select {
|
||||
max-width: 9rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
background: hsl(var(--color-background));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 4rem 0;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.empty .hint {
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 0.875rem;
|
||||
padding: 0.875rem;
|
||||
background: hsl(var(--color-muted));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
.thumb-btn {
|
||||
width: 96px;
|
||||
height: 64px;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: hsl(var(--color-background));
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
.thumb-btn img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.row-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.row-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.row-meta .site {
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.row-meta .badge {
|
||||
background: hsl(var(--color-primary) / 0.15);
|
||||
color: hsl(var(--color-primary));
|
||||
padding: 0 0.4rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.row-title {
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
cursor: pointer;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.row-title:hover {
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
.row-excerpt {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.row-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
align-self: center;
|
||||
}
|
||||
.icon {
|
||||
width: 1.875rem;
|
||||
height: 1.875rem;
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-background));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.icon.active {
|
||||
background: hsl(var(--color-primary) / 0.15);
|
||||
border-color: hsl(var(--color-primary) / 0.4);
|
||||
}
|
||||
.icon.danger:hover {
|
||||
background: hsl(var(--color-destructive) / 0.15);
|
||||
border-color: hsl(var(--color-destructive) / 0.4);
|
||||
color: var(--color-text-muted, #64748b);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue