mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
feat(articles): new read-it-later module — save / read / highlight
Pocket-style module for saving arbitrary web URLs, extracting readable content server-side via @mana/shared-rss (Readability + JSDOM), and storing it AES-GCM encrypted in IndexedDB for offline reading. M1 skeleton: Dexie v33 (articles, articleHighlights, articleTags), crypto registry entries, module registration, app-registry entry with orange icon, empty-state ListView. articleTags is a pure junction into the existing globalTags system (appId 'tags') — same pattern as noteTags, eventTags, placeTags. M2 URL save + reader: POST /api/v1/articles/extract (one endpoint, not two — client caches the preview payload to avoid a double server fetch). AddUrlForm with scope-aware dedupe, DetailView with ReaderView typography shell (serif/sans, light/sepia/dark, size slider), auto-tracked reading progress with scroll restore. M3 highlights: TreeWalker-based plain-text offset resolution (lib/offsets.ts), highlights store, floating HighlightMenu with create + edit modes, HighlightLayer orchestrator that wraps/unwraps highlight spans whenever highlights or htmlVersion changes. Four colours (yellow/green/blue/pink), optional notes, click-to-edit, dark-mode-aware overlay colours. Drive-by: removed stale 'pendingProposals' entry from the plaintext allowlist — the table was dropped in Dexie v29 and the allowlist audit was flagging it as a dead entry. Plan: docs/plans/articles-module.md. M4 (tags + filter + progress), M5 (news:type='saved' migration), M6 (AI tools), M7 (share target), M8 (highlights view + stats) still open. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8f6a4efddd
commit
3357e88a1c
28 changed files with 2819 additions and 1 deletions
|
|
@ -35,6 +35,7 @@ import { guidesRoutes } from './modules/guides/routes';
|
|||
import { moodlitRoutes } from './modules/moodlit/routes';
|
||||
import { newsRoutes } from './modules/news/routes';
|
||||
import { newsResearchRoutes } from './modules/news-research/routes';
|
||||
import { articlesRoutes } from './modules/articles/routes';
|
||||
import { tracesRoutes } from './modules/traces/routes';
|
||||
import { presiRoutes } from './modules/presi/routes';
|
||||
import { researchRoutes } from './modules/research/routes';
|
||||
|
|
@ -104,6 +105,7 @@ app.route('/api/v1/guides', guidesRoutes);
|
|||
app.route('/api/v1/moodlit', moodlitRoutes);
|
||||
app.route('/api/v1/news', newsRoutes);
|
||||
app.route('/api/v1/news-research', newsResearchRoutes);
|
||||
app.route('/api/v1/articles', articlesRoutes);
|
||||
app.route('/api/v1/traces', tracesRoutes);
|
||||
app.route('/api/v1/presi', presiRoutes);
|
||||
app.route('/api/v1/research', researchRoutes);
|
||||
|
|
|
|||
54
apps/api/src/modules/articles/routes.ts
Normal file
54
apps/api/src/modules/articles/routes.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* Articles module — server-side URL extraction.
|
||||
*
|
||||
* Thin wrapper around `@mana/shared-rss`'s Readability pipeline. The
|
||||
* extracted payload is returned to the client which then encrypts +
|
||||
* stores it locally (and syncs via mana-sync). The server keeps no
|
||||
* per-user article state — all reading-list data lives in the unified
|
||||
* Mana app's IndexedDB.
|
||||
*
|
||||
* One endpoint (`POST /extract`), not two. News has a `preview` + `save`
|
||||
* split for legacy reasons; here both UI paths (AddUrlForm preview + the
|
||||
* direct saveFromUrl path) use the same payload. The client caches the
|
||||
* response when the user confirms, avoiding a double server fetch.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { extractFromUrl } from '@mana/shared-rss';
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
routes.post('/extract', async (c) => {
|
||||
const body = await c.req.json<{ url?: string }>().catch(() => ({}) as { url?: string });
|
||||
const url = body.url;
|
||||
if (!url || typeof url !== 'string') {
|
||||
return c.json({ error: 'URL is required' }, 400);
|
||||
}
|
||||
|
||||
// Minimal URL shape check — extractFromUrl will no-op on a bad URL but
|
||||
// the caller deserves a clear 400 vs a generic 502.
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
return c.json({ error: 'Invalid URL' }, 400);
|
||||
}
|
||||
|
||||
const extracted = await extractFromUrl(url);
|
||||
if (!extracted) {
|
||||
return c.json({ error: 'Extraction failed' }, 502);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
originalUrl: url,
|
||||
title: extracted.title,
|
||||
excerpt: extracted.excerpt,
|
||||
content: extracted.content,
|
||||
htmlContent: extracted.htmlContent,
|
||||
author: extracted.byline,
|
||||
siteName: extracted.siteName,
|
||||
wordCount: extracted.wordCount,
|
||||
readingTimeMinutes: extracted.readingTimeMinutes,
|
||||
});
|
||||
});
|
||||
|
||||
export { routes as articlesRoutes };
|
||||
|
|
@ -22,6 +22,7 @@ export const PLAINTEXT_ALLOWLIST: readonly string[] = [
|
|||
'aiMissions', // TODO: audit
|
||||
'albumItems', // TODO: audit
|
||||
'albums', // TODO: audit
|
||||
'articleTags', // FK-only junction into globalTags (articleId, tagId). Tag names live in globalTags.
|
||||
'automations', // TODO: audit
|
||||
'boardViews', // TODO: audit
|
||||
'budgets', // TODO: audit
|
||||
|
|
@ -72,7 +73,6 @@ export const PLAINTEXT_ALLOWLIST: readonly string[] = [
|
|||
'mukkeProjects', // TODO: audit
|
||||
'newsCachedFeed', // TODO: audit
|
||||
'noteTags', // TODO: audit
|
||||
'pendingProposals', // TODO: audit
|
||||
'periodSymptoms', // TODO: audit
|
||||
'photoFavorites', // TODO: audit
|
||||
'photoMediaTags', // TODO: audit
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ import type {
|
|||
LocalBroadcastTemplate,
|
||||
LocalBroadcastSettings,
|
||||
} from '../../modules/broadcast/types';
|
||||
import type { LocalArticle, LocalHighlight } from '../../modules/articles/types';
|
||||
|
||||
export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
||||
// ─── Chat ────────────────────────────────────────────────
|
||||
|
|
@ -572,6 +573,44 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
|||
// structural fields.
|
||||
agents: { enabled: true, fields: ['systemPrompt', 'memory'] },
|
||||
|
||||
// ─── Articles (Pocket-style read-it-later) ──────────────
|
||||
// Reading-behaviour data — same sensitivity class as newsArticles.
|
||||
// Encrypted:
|
||||
// - title / excerpt / content / htmlContent / author: the Readability
|
||||
// extract body. Leaking this would be the same as leaking the user's
|
||||
// bookmark history + full article texts.
|
||||
// - userNote: the user's own note about the saved article.
|
||||
// Plaintext (intentional):
|
||||
// - originalUrl: dedupe key. Indexed + used by saveFromUrl to avoid
|
||||
// duplicate ingestion. Same rationale as newsArticles.originalUrl /
|
||||
// uLoad.links.originalUrl.
|
||||
// - siteName: powers the "group by source" view and stays cheap to
|
||||
// aggregate without decrypting every row. Not a secret — the site
|
||||
// name is recoverable from originalUrl anyway.
|
||||
// - imageUrl: opaque pointer; the bytes are already public at that URL.
|
||||
// - status / readingProgress / isFavorite / savedAt / readAt /
|
||||
// wordCount / readingTimeMinutes / publishedAt / extractedVersion:
|
||||
// all structural, needed for filtering/sorting/stats.
|
||||
//
|
||||
// Highlights carry the marked text + the surrounding context fragments
|
||||
// (re-anchor substrates). Both are fragments of the encrypted content
|
||||
// and are themselves encrypted. Offsets + color + articleId are
|
||||
// structural — the reader needs them for range scans and rendering.
|
||||
//
|
||||
// articleTags is intentionally NOT registered — pure FK junction
|
||||
// (articleId, tagId), zero user-typed content. Tag names live in
|
||||
// globalTags, which has its own encryption policy. Lives on the
|
||||
// plaintext-allowlist alongside noteTags / eventTags / placeTags.
|
||||
articles: entry<LocalArticle>([
|
||||
'title',
|
||||
'excerpt',
|
||||
'content',
|
||||
'htmlContent',
|
||||
'author',
|
||||
'userNote',
|
||||
]),
|
||||
articleHighlights: entry<LocalHighlight>(['text', 'note', 'contextBefore', 'contextAfter']),
|
||||
|
||||
// ─── Library ─────────────────────────────────────────────
|
||||
// Reading / watching log with a kind discriminator (book / movie /
|
||||
// series / comic) in one table. User-typed text (title, original
|
||||
|
|
|
|||
|
|
@ -738,6 +738,23 @@ db.version(32).stores({
|
|||
broadcastSettings: 'id',
|
||||
});
|
||||
|
||||
// v33 — Articles module: Pocket-style read-it-later.
|
||||
// See docs/plans/articles-module.md. Three tables:
|
||||
// - articles: saved URLs + extracted Readability content. `originalUrl`
|
||||
// indexed for O(1) dedupe at save time. `status` + `savedAt` drive
|
||||
// the ListView's main filter + sort. `isFavorite` + `siteName` are
|
||||
// indexed for the "favourites" + "group by source" views.
|
||||
// - articleHighlights: per-selection rows. [articleId+startOffset]
|
||||
// gives sorted range scans per article for the reader overlay.
|
||||
// - articleTags: pure junction into globalTags (appId 'tags').
|
||||
// [articleId+tagId] matches the pattern used by noteTags / eventTags
|
||||
// / contactTags / placeTags and is what `createTagLinkOps` expects.
|
||||
db.version(33).stores({
|
||||
articles: 'id, userId, status, savedAt, isFavorite, siteName, originalUrl',
|
||||
articleHighlights: 'id, userId, articleId, [articleId+startOffset]',
|
||||
articleTags: 'id, userId, articleId, tagId, [articleId+tagId]',
|
||||
});
|
||||
|
||||
// ─── Sync Routing ──────────────────────────────────────────
|
||||
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
|
||||
// toSyncName() and fromSyncName() are now derived from per-module
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ import { kontextModuleConfig } from '$lib/modules/kontext/module.config';
|
|||
import { quizModuleConfig } from '$lib/modules/quiz/module.config';
|
||||
import { profileModuleConfig } from '$lib/modules/profile/module.config';
|
||||
import { libraryModuleConfig } from '$lib/modules/library/module.config';
|
||||
import { articlesModuleConfig } from '$lib/modules/articles/module.config';
|
||||
import { invoicesModuleConfig } from '$lib/modules/invoices/module.config';
|
||||
import { broadcastModuleConfig } from '$lib/modules/broadcast/module.config';
|
||||
import { wetterModuleConfig } from '$lib/modules/wetter/module.config';
|
||||
|
|
@ -157,6 +158,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
|
|||
quizModuleConfig,
|
||||
profileModuleConfig,
|
||||
libraryModuleConfig,
|
||||
articlesModuleConfig,
|
||||
invoicesModuleConfig,
|
||||
broadcastModuleConfig,
|
||||
wetterModuleConfig,
|
||||
|
|
|
|||
200
apps/mana/apps/web/src/lib/modules/articles/ListView.svelte
Normal file
200
apps/mana/apps/web/src/lib/modules/articles/ListView.svelte
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
<!--
|
||||
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.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { useAllArticles } from './queries';
|
||||
import type { Article } from './types';
|
||||
|
||||
const articles$ = useAllArticles();
|
||||
const articles = $derived(articles$.value);
|
||||
|
||||
function openArticle(a: Article) {
|
||||
goto(`/articles/${a.id}`);
|
||||
}
|
||||
</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>
|
||||
<button type="button" class="add-btn" onclick={() => goto('/articles/add')}>
|
||||
+ Neu speichern
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if articles$.loading}
|
||||
<p class="muted center">Lädt…</p>
|
||||
{:else if articles.length === 0}
|
||||
<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.
|
||||
</p>
|
||||
<button type="button" class="add-btn" onclick={() => goto('/articles/add')}>
|
||||
Erste URL speichern
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="article-list">
|
||||
{#each articles as article (article.id)}
|
||||
<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>
|
||||
</div>
|
||||
<div class="title">{article.title}</div>
|
||||
{#if article.excerpt}
|
||||
<div class="excerpt">{article.excerpt}</div>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.articles-shell {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.muted {
|
||||
color: var(--color-text-muted, #64748b);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.muted.center {
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.empty-state {
|
||||
margin-top: 3rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--color-border, rgba(0, 0, 0, 0.15));
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
.empty-headline {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.empty-sub {
|
||||
margin: 0 0 1.25rem 0;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.article-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
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-archived {
|
||||
background: rgba(100, 116, 139, 0.15);
|
||||
color: #64748b;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
50
apps/mana/apps/web/src/lib/modules/articles/api.ts
Normal file
50
apps/mana/apps/web/src/lib/modules/articles/api.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Articles API client — talks to apps/api `/api/v1/articles/*`.
|
||||
*
|
||||
* One endpoint (`POST /extract`) with the Readability result. Both the
|
||||
* preview (AddUrlForm) and the direct save paths share the same call;
|
||||
* the client chooses whether to show the result or immediately persist.
|
||||
*
|
||||
* Auth + base-URL handling mirrors news/api.ts — see that file for the
|
||||
* full rationale on why we read `getManaApiUrl()` and `authStore.
|
||||
* getValidToken()` instead of the cookie/env shortcuts.
|
||||
*/
|
||||
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { getManaApiUrl } from '$lib/api/config';
|
||||
|
||||
async function authHeader(): Promise<Record<string, string>> {
|
||||
const token = await authStore.getValidToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
export interface ExtractedArticle {
|
||||
originalUrl: string;
|
||||
title: string;
|
||||
excerpt: string | null;
|
||||
content: string;
|
||||
htmlContent: string;
|
||||
author: string | null;
|
||||
siteName: string | null;
|
||||
wordCount: number;
|
||||
readingTimeMinutes: number;
|
||||
}
|
||||
|
||||
export async function extractArticle(
|
||||
url: string,
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<ExtractedArticle> {
|
||||
const response = await fetchImpl(`${getManaApiUrl()}/api/v1/articles/extract`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(await authHeader()),
|
||||
},
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`extractArticle failed: ${response.status} ${text}`);
|
||||
}
|
||||
return (await response.json()) as ExtractedArticle;
|
||||
}
|
||||
14
apps/mana/apps/web/src/lib/modules/articles/collections.ts
Normal file
14
apps/mana/apps/web/src/lib/modules/articles/collections.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* Articles module — Dexie accessors.
|
||||
*
|
||||
* No guest seed: articles are by definition URLs the user chose to save,
|
||||
* so an empty state is the honest first-run experience. The ListView's
|
||||
* empty-state hints the user toward /articles/add instead.
|
||||
*/
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import type { LocalArticle, LocalHighlight, LocalArticleTag } from './types';
|
||||
|
||||
export const articleTable = db.table<LocalArticle>('articles');
|
||||
export const articleHighlightTable = db.table<LocalHighlight>('articleHighlights');
|
||||
export const articleTagTable = db.table<LocalArticleTag>('articleTags');
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
<!--
|
||||
AddUrlForm — paste URL → preview → save.
|
||||
|
||||
Flow:
|
||||
1. User pastes (or types) a URL
|
||||
2. On "Vorschau abrufen": check scope-local dedupe first; if found,
|
||||
offer "öffnen" instead of re-extracting (saves one round-trip).
|
||||
Otherwise call /api/v1/articles/extract and render the preview.
|
||||
3. On "Speichern": the already-extracted payload is persisted via
|
||||
articlesStore.saveFromExtracted — no second server call.
|
||||
4. Navigate into the new article so the user lands directly in the
|
||||
reader view.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { articlesStore } from '../stores/articles.svelte';
|
||||
import { extractArticle, type ExtractedArticle } from '../api';
|
||||
import type { Article } from '../types';
|
||||
|
||||
let url = $state('');
|
||||
let preview = $state<ExtractedArticle | null>(null);
|
||||
let duplicate = $state<Article | null>(null);
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// a11y: don't use the `autofocus` attribute — route the focus through a
|
||||
// use:action so screen-readers announce the page first and the focus
|
||||
// happens deliberately after mount.
|
||||
function focusOnMount(node: HTMLInputElement) {
|
||||
node.focus();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
preview = null;
|
||||
duplicate = null;
|
||||
error = null;
|
||||
}
|
||||
|
||||
async function handlePreview() {
|
||||
reset();
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) {
|
||||
error = 'Bitte eine URL einfügen.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
new URL(trimmed);
|
||||
} catch {
|
||||
error = 'Das sieht nicht nach einer gültigen URL aus.';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const alreadySaved = await articlesStore.findByUrl(trimmed);
|
||||
if (alreadySaved) {
|
||||
duplicate = alreadySaved;
|
||||
return;
|
||||
}
|
||||
preview = await extractArticle(trimmed);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Extraktion fehlgeschlagen.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!preview) return;
|
||||
saving = true;
|
||||
try {
|
||||
const saved = await articlesStore.saveFromExtracted(preview);
|
||||
goto(`/articles/${saved.id}`);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Speichern fehlgeschlagen.';
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="add-shell">
|
||||
<header class="header">
|
||||
<h1>Artikel speichern</h1>
|
||||
<p class="subtitle">URL einfügen, Vorschau prüfen, speichern.</p>
|
||||
</header>
|
||||
|
||||
<div class="input-row">
|
||||
<input
|
||||
type="url"
|
||||
class="url-input"
|
||||
bind:value={url}
|
||||
placeholder="https://…"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') handlePreview();
|
||||
}}
|
||||
use:focusOnMount
|
||||
/>
|
||||
<button type="button" class="primary" disabled={loading} onclick={handlePreview}>
|
||||
{loading ? 'Lädt…' : 'Vorschau abrufen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if duplicate}
|
||||
<div class="duplicate">
|
||||
<p class="dup-headline">Den hast du schon gespeichert.</p>
|
||||
<p class="dup-title">{duplicate.title}</p>
|
||||
<div class="dup-actions">
|
||||
<button type="button" class="primary" onclick={() => goto(`/articles/${duplicate!.id}`)}>
|
||||
Zum gespeicherten Artikel
|
||||
</button>
|
||||
<button type="button" class="secondary" onclick={reset}>Andere URL</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if preview}
|
||||
<article class="preview">
|
||||
<h2 class="preview-title">{preview.title}</h2>
|
||||
<div class="preview-meta">
|
||||
{#if preview.siteName}<span>{preview.siteName}</span>{/if}
|
||||
{#if preview.author}<span>· {preview.author}</span>{/if}
|
||||
{#if preview.readingTimeMinutes}<span>· {preview.readingTimeMinutes} min</span>{/if}
|
||||
{#if preview.wordCount}<span>· {preview.wordCount} Wörter</span>{/if}
|
||||
</div>
|
||||
{#if preview.excerpt}
|
||||
<p class="preview-excerpt">{preview.excerpt}</p>
|
||||
{/if}
|
||||
<div class="preview-actions">
|
||||
<button type="button" class="primary" disabled={saving} onclick={handleSave}>
|
||||
{saving ? 'Speichere…' : 'In Leseliste speichern'}
|
||||
</button>
|
||||
<button type="button" class="secondary" onclick={reset} disabled={saving}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.add-shell {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.header {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
.subtitle {
|
||||
color: var(--color-text-muted, #64748b);
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
.url-input {
|
||||
flex: 1;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border-radius: 0.55rem;
|
||||
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.12));
|
||||
background: var(--color-surface, transparent);
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
.url-input:focus {
|
||||
outline: 2px solid #f97316;
|
||||
outline-offset: 1px;
|
||||
border-color: transparent;
|
||||
}
|
||||
button {
|
||||
padding: 0.55rem 1rem;
|
||||
border-radius: 0.55rem;
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
.primary {
|
||||
background: #f97316;
|
||||
color: white;
|
||||
border-color: #f97316;
|
||||
}
|
||||
.primary:hover:not(:disabled) {
|
||||
background: #ea580c;
|
||||
border-color: #ea580c;
|
||||
}
|
||||
.secondary {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-color: var(--color-border, rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
.secondary:hover:not(:disabled) {
|
||||
border-color: var(--color-border-strong, rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
.error {
|
||||
padding: 0.55rem 0.85rem;
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.preview,
|
||||
.duplicate {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
|
||||
border-radius: 0.75rem;
|
||||
background: color-mix(in srgb, #f97316 3%, transparent);
|
||||
}
|
||||
.preview-title {
|
||||
margin: 0 0 0.4rem 0;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.preview-meta {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
margin-bottom: 0.7rem;
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.preview-excerpt {
|
||||
margin: 0 0 1rem 0;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.preview-actions,
|
||||
.dup-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.dup-headline {
|
||||
margin: 0 0 0.3rem 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dup-title {
|
||||
margin: 0 0 0.9rem 0;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,285 @@
|
|||
<!--
|
||||
HighlightLayer — orchestrates highlight overlays + selection menu.
|
||||
|
||||
Pattern:
|
||||
- On every `highlights` change (or when the Reader re-renders because
|
||||
`html` changed) we unwrap all previously-applied highlight spans
|
||||
and re-apply fresh. The DOM is the source of truth for offset
|
||||
resolution, so we tolerate the "rebuild on change" cost.
|
||||
- `mouseup` on the scroller checks for a live selection; if found, we
|
||||
show the create-menu at the selection rect.
|
||||
- `click` on an existing highlight span (`span[data-hl-id]`) opens
|
||||
the edit-menu for that one.
|
||||
- `mousedown` elsewhere dismisses the menu.
|
||||
|
||||
Coordinates: the menu is positioned relative to `container` (the
|
||||
`detail-shell` from DetailView). We project viewport rects into the
|
||||
container's local frame by subtracting its bounding-rect origin.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { useArticleHighlights } from '../queries';
|
||||
import { highlightsStore } from '../stores/highlights.svelte';
|
||||
import {
|
||||
extractSelectionSnapshot,
|
||||
textOffsetsToSlices,
|
||||
type SelectionSnapshot,
|
||||
} from '../lib/offsets';
|
||||
import HighlightMenu from './HighlightMenu.svelte';
|
||||
import type { Highlight, HighlightColor } from '../types';
|
||||
|
||||
interface Props {
|
||||
articleId: string;
|
||||
/** The Reader's scrollable content root — where text lives. */
|
||||
scroller: HTMLElement | null;
|
||||
/** The positioning ancestor — menu coordinates are relative to this. */
|
||||
container: HTMLElement | null;
|
||||
/** Re-apply when the Reader's HTML changes (theme swap → re-render). */
|
||||
htmlVersion: unknown;
|
||||
}
|
||||
let { articleId, scroller, container, htmlVersion }: Props = $props();
|
||||
|
||||
const highlights$ = $derived.by(() => useArticleHighlights(articleId));
|
||||
const highlights = $derived(highlights$.value);
|
||||
|
||||
type MenuState =
|
||||
| { kind: 'create'; snapshot: SelectionSnapshot; top: number; left: number }
|
||||
| { kind: 'edit'; highlight: Highlight; top: number; left: number }
|
||||
| null;
|
||||
|
||||
let menu = $state<MenuState>(null);
|
||||
|
||||
// ─── Overlay application ──────────────────────────────
|
||||
//
|
||||
// Re-runs whenever `highlights` or `htmlVersion` changes. `htmlVersion`
|
||||
// is bumped by the parent whenever ReaderView replaces its DOM (e.g. a
|
||||
// new article loaded), so we know to re-wrap.
|
||||
$effect(() => {
|
||||
// Track dependencies. Without these reads Svelte wouldn't know to
|
||||
// re-run when highlights or htmlVersion changes.
|
||||
const list = highlights;
|
||||
void htmlVersion;
|
||||
if (!scroller) return;
|
||||
unwrapAll(scroller);
|
||||
for (const h of list) applyHighlight(scroller, h);
|
||||
});
|
||||
|
||||
function unwrapAll(root: HTMLElement) {
|
||||
const spans = root.querySelectorAll<HTMLSpanElement>('span[data-hl-id]');
|
||||
for (const span of Array.from(spans)) {
|
||||
const parent = span.parentNode;
|
||||
if (!parent) continue;
|
||||
while (span.firstChild) parent.insertBefore(span.firstChild, span);
|
||||
parent.removeChild(span);
|
||||
// Merge adjacent text nodes so future offset walks stay stable.
|
||||
parent.normalize();
|
||||
}
|
||||
}
|
||||
|
||||
function applyHighlight(root: HTMLElement, h: Highlight) {
|
||||
const slices = textOffsetsToSlices(root, h.startOffset, h.endOffset);
|
||||
for (const slice of slices) {
|
||||
const range = document.createRange();
|
||||
range.setStart(slice.node, slice.start);
|
||||
range.setEnd(slice.node, slice.end);
|
||||
const span = document.createElement('span');
|
||||
span.dataset.hlId = h.id;
|
||||
span.dataset.hlColor = h.color;
|
||||
span.className = `article-highlight article-highlight-${h.color}`;
|
||||
if (h.note) span.dataset.hlNote = h.note;
|
||||
try {
|
||||
range.surroundContents(span);
|
||||
} catch {
|
||||
// surroundContents throws when the range crosses element
|
||||
// boundaries — shouldn't happen here since textOffsetsToSlices
|
||||
// splits per text node, but we still guard so a single bad
|
||||
// highlight doesn't kill the whole overlay pass.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Selection → create menu ─────────────────────────
|
||||
|
||||
function onSelectionEnd(event: MouseEvent) {
|
||||
// Ignore mouseups that land on existing highlights — those open the
|
||||
// edit menu via the separate click handler.
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest('span[data-hl-id]')) return;
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||
return;
|
||||
}
|
||||
if (!scroller || !container) return;
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!scroller.contains(range.commonAncestorContainer)) return;
|
||||
|
||||
const snapshot = extractSelectionSnapshot(range, scroller);
|
||||
if (!snapshot) return;
|
||||
|
||||
const { top, left } = rectToLocal(range.getBoundingClientRect(), container);
|
||||
menu = { kind: 'create', snapshot, top, left };
|
||||
}
|
||||
|
||||
// ─── Click on existing highlight → edit menu ──────────
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const span = target?.closest('span[data-hl-id]') as HTMLSpanElement | null;
|
||||
if (!span) return;
|
||||
const id = span.dataset.hlId;
|
||||
if (!id) return;
|
||||
const existing = highlights.find((h) => h.id === id);
|
||||
if (!existing || !container) return;
|
||||
const { top, left } = rectToLocal(span.getBoundingClientRect(), container);
|
||||
menu = { kind: 'edit', highlight: existing, top, left };
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function onMousedown(event: MouseEvent) {
|
||||
if (!menu) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest('.article-highlight-menu-anchor')) return;
|
||||
if (target?.closest('span[data-hl-id]')) return;
|
||||
// Clicking into the menu itself is fine; it lives under
|
||||
// .article-highlight-menu-anchor too.
|
||||
menu = null;
|
||||
}
|
||||
|
||||
function rectToLocal(rect: DOMRect, anchor: HTMLElement) {
|
||||
const origin = anchor.getBoundingClientRect();
|
||||
return {
|
||||
top: rect.bottom - origin.top + 8,
|
||||
left: rect.left - origin.left,
|
||||
};
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!scroller) return;
|
||||
scroller.addEventListener('mouseup', onSelectionEnd);
|
||||
scroller.addEventListener('click', onClick);
|
||||
document.addEventListener('mousedown', onMousedown);
|
||||
return () => {
|
||||
scroller.removeEventListener('mouseup', onSelectionEnd);
|
||||
scroller.removeEventListener('click', onClick);
|
||||
document.removeEventListener('mousedown', onMousedown);
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Menu actions ─────────────────────────────────────
|
||||
|
||||
async function handleCreate(color: HighlightColor, note: string | null) {
|
||||
if (menu?.kind !== 'create') return;
|
||||
const s = menu.snapshot;
|
||||
menu = null;
|
||||
window.getSelection()?.removeAllRanges();
|
||||
await highlightsStore.addHighlight({
|
||||
articleId,
|
||||
text: s.text,
|
||||
color,
|
||||
note,
|
||||
startOffset: s.start,
|
||||
endOffset: s.end,
|
||||
contextBefore: s.contextBefore,
|
||||
contextAfter: s.contextAfter,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleUpdate(color: HighlightColor, note: string | null) {
|
||||
if (menu?.kind !== 'edit') return;
|
||||
const id = menu.highlight.id;
|
||||
menu = null;
|
||||
await highlightsStore.setColor(id, color);
|
||||
await highlightsStore.setNote(id, note);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (menu?.kind !== 'edit') return;
|
||||
const id = menu.highlight.id;
|
||||
menu = null;
|
||||
await highlightsStore.deleteHighlight(id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="article-highlight-menu-anchor">
|
||||
{#if menu?.kind === 'create'}
|
||||
<HighlightMenu
|
||||
mode="create"
|
||||
top={menu.top}
|
||||
left={menu.left}
|
||||
onsave={handleCreate}
|
||||
oncancel={() => (menu = null)}
|
||||
/>
|
||||
{:else if menu?.kind === 'edit'}
|
||||
<HighlightMenu
|
||||
mode="edit"
|
||||
top={menu.top}
|
||||
left={menu.left}
|
||||
initialColor={menu.highlight.color}
|
||||
initialNote={menu.highlight.note}
|
||||
onupdate={handleUpdate}
|
||||
ondelete={handleDelete}
|
||||
onclose={() => (menu = null)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.article-highlight-menu-anchor {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.article-highlight-menu-anchor :global(.menu) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Highlight-Spans werden programmatisch eingefügt; die Styles müssen */
|
||||
/* global greifen, weil die Spans nicht zum Markup dieser Komponente */
|
||||
/* gehören sondern im Reader-DOM leben. */
|
||||
:global(.article-highlight) {
|
||||
border-radius: 0.1rem;
|
||||
padding: 0 0.1em;
|
||||
cursor: pointer;
|
||||
transition: filter 120ms ease;
|
||||
}
|
||||
:global(.article-highlight:hover) {
|
||||
filter: brightness(0.94);
|
||||
}
|
||||
:global(.article-highlight-yellow) {
|
||||
background: #fde68a;
|
||||
color: #1e293b;
|
||||
}
|
||||
:global(.article-highlight-green) {
|
||||
background: #bbf7d0;
|
||||
color: #1e293b;
|
||||
}
|
||||
:global(.article-highlight-blue) {
|
||||
background: #bfdbfe;
|
||||
color: #1e293b;
|
||||
}
|
||||
:global(.article-highlight-pink) {
|
||||
background: #fbcfe8;
|
||||
color: #1e293b;
|
||||
}
|
||||
/* Dunkler Reader-Modus bekommt eigene Farben: weniger Saturation, Text */
|
||||
/* bleibt lesbar auf dunklem Hintergrund. */
|
||||
:global(.reader-dark .article-highlight-yellow) {
|
||||
background: rgba(253, 224, 71, 0.35);
|
||||
color: inherit;
|
||||
}
|
||||
:global(.reader-dark .article-highlight-green) {
|
||||
background: rgba(134, 239, 172, 0.3);
|
||||
color: inherit;
|
||||
}
|
||||
:global(.reader-dark .article-highlight-blue) {
|
||||
background: rgba(147, 197, 253, 0.3);
|
||||
color: inherit;
|
||||
}
|
||||
:global(.reader-dark .article-highlight-pink) {
|
||||
background: rgba(249, 168, 212, 0.3);
|
||||
color: inherit;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
<!--
|
||||
HighlightMenu — floating popover anchored near a selection or an
|
||||
existing highlight span.
|
||||
|
||||
Two modes:
|
||||
- mode="create" → shown right after the user makes a selection.
|
||||
Color swatches + optional note, "Speichern" / "Abbrechen".
|
||||
- mode="edit" → shown when the user clicks an existing highlight.
|
||||
Color change + note edit + delete.
|
||||
|
||||
The component is positioned absolutely inside a positioned parent;
|
||||
HighlightLayer computes the `top`/`left` props from the selection
|
||||
rect and passes them in.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import type { HighlightColor } from '../types';
|
||||
|
||||
const COLORS: HighlightColor[] = ['yellow', 'green', 'blue', 'pink'];
|
||||
|
||||
interface CreateProps {
|
||||
mode: 'create';
|
||||
top: number;
|
||||
left: number;
|
||||
initialColor?: HighlightColor;
|
||||
onsave: (color: HighlightColor, note: string | null) => void;
|
||||
oncancel: () => void;
|
||||
}
|
||||
|
||||
interface EditProps {
|
||||
mode: 'edit';
|
||||
top: number;
|
||||
left: number;
|
||||
initialColor: HighlightColor;
|
||||
initialNote: string | null;
|
||||
onupdate: (color: HighlightColor, note: string | null) => void;
|
||||
ondelete: () => void;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
type Props = CreateProps | EditProps;
|
||||
const props: Props = $props();
|
||||
|
||||
// The menu is destroyed + re-mounted whenever the parent switches
|
||||
// between create/edit branches, so reading props once at mount for
|
||||
// the initial local state is intentional. untrack() tells Svelte's
|
||||
// analyzer "I know this isn't reactive, that's the point."
|
||||
let color = $state<HighlightColor>(
|
||||
untrack(() => (props.mode === 'edit' ? props.initialColor : (props.initialColor ?? 'yellow')))
|
||||
);
|
||||
let note = $state<string>(
|
||||
untrack(() => (props.mode === 'edit' ? (props.initialNote ?? '') : ''))
|
||||
);
|
||||
let showNote = $state(untrack(() => props.mode === 'edit' && (props.initialNote ?? '') !== ''));
|
||||
|
||||
function submit() {
|
||||
const finalNote = note.trim() || null;
|
||||
if (props.mode === 'create') {
|
||||
props.onsave(color, finalNote);
|
||||
} else {
|
||||
props.onupdate(color, finalNote);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="menu" style:top="{props.top}px" style:left="{props.left}px" role="dialog">
|
||||
<div class="swatches" role="radiogroup" aria-label="Farbe">
|
||||
{#each COLORS as c (c)}
|
||||
<button
|
||||
type="button"
|
||||
class="swatch swatch-{c}"
|
||||
class:active={color === c}
|
||||
onclick={() => (color = c)}
|
||||
aria-label={c}
|
||||
aria-pressed={color === c}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if showNote}
|
||||
<textarea
|
||||
class="note"
|
||||
bind:value={note}
|
||||
placeholder="Notiz (optional)…"
|
||||
rows="2"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) submit();
|
||||
}}
|
||||
></textarea>
|
||||
{:else}
|
||||
<button type="button" class="add-note" onclick={() => (showNote = true)}>+ Notiz</button>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
{#if props.mode === 'create'}
|
||||
<button type="button" class="primary" onclick={submit}>Speichern</button>
|
||||
<button type="button" class="secondary" onclick={props.oncancel}>Abbrechen</button>
|
||||
{:else}
|
||||
<button type="button" class="primary" onclick={submit}>Übernehmen</button>
|
||||
<button type="button" class="danger" onclick={props.ondelete}>Löschen</button>
|
||||
<button type="button" class="secondary" onclick={props.onclose}>Schließen</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.menu {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
background: #ffffff;
|
||||
color: #1e293b;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 0.55rem;
|
||||
padding: 0.55rem;
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.08),
|
||||
0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
min-width: 220px;
|
||||
max-width: 320px;
|
||||
}
|
||||
.swatches {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.swatch {
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.swatch.active {
|
||||
border-color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.swatch-yellow {
|
||||
background: #fde68a;
|
||||
}
|
||||
.swatch-green {
|
||||
background: #bbf7d0;
|
||||
}
|
||||
.swatch-blue {
|
||||
background: #bfdbfe;
|
||||
}
|
||||
.swatch-pink {
|
||||
background: #fbcfe8;
|
||||
}
|
||||
.note {
|
||||
font: inherit;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: 0.4rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
background: white;
|
||||
color: inherit;
|
||||
resize: vertical;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
.add-note {
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px dashed rgba(0, 0, 0, 0.2);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-radius: 0.4rem;
|
||||
cursor: pointer;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.actions button {
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 0.4rem;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.primary {
|
||||
background: #f97316;
|
||||
color: white;
|
||||
border-color: #f97316;
|
||||
}
|
||||
.primary:hover {
|
||||
background: #ea580c;
|
||||
}
|
||||
.secondary {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-color: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.secondary:hover {
|
||||
border-color: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.danger {
|
||||
background: transparent;
|
||||
color: #ef4444;
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
.danger:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
<!--
|
||||
ReaderView — pure typography shell.
|
||||
|
||||
Renders the sanitised htmlContent that came back from Readability. We
|
||||
DON'T sanitise client-side: Readability already emits a clean subset
|
||||
(no <script>, no inline handlers) and the content landed in IndexedDB
|
||||
only after an authenticated server call to our own extraction route.
|
||||
Same approach as the news reader at /news/[id].
|
||||
|
||||
The shell is completely dumb — parent passes html + typography props
|
||||
and listens for progress updates on scroll. No mutation happens here.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
html: string | null;
|
||||
plainFallback: string;
|
||||
theme?: 'light' | 'dark' | 'sepia';
|
||||
fontSize?: number;
|
||||
fontFamily?: 'serif' | 'sans';
|
||||
initialProgress?: number;
|
||||
onprogress?: (progress: number) => void;
|
||||
/** Callback fires once the scroller div is mounted — the HighlightLayer
|
||||
* needs this ref to attach selection listeners and wrap text-node
|
||||
* ranges. Fires with null on unmount for cleanup.
|
||||
*/
|
||||
onscroller?: (el: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
html,
|
||||
plainFallback,
|
||||
theme = 'light',
|
||||
fontSize = 1,
|
||||
fontFamily = 'serif',
|
||||
initialProgress = 0,
|
||||
onprogress,
|
||||
onscroller,
|
||||
}: Props = $props();
|
||||
|
||||
let scroller: HTMLDivElement | undefined = $state();
|
||||
let lastReported = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
onscroller?.(scroller ?? null);
|
||||
return () => onscroller?.(null);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!scroller) return;
|
||||
// Restore last-read position ONCE when the scroller mounts. Reading
|
||||
// `initialProgress` inside `untrack` stops our own progress updates
|
||||
// — which flow back as new initialProgress values — from kicking the
|
||||
// scroll back every time the user moves.
|
||||
untrack(() => {
|
||||
if (!scroller) return;
|
||||
const target = initialProgress * (scroller.scrollHeight - scroller.clientHeight);
|
||||
if (target > 0 && Number.isFinite(target)) scroller.scrollTop = target;
|
||||
lastReported = initialProgress;
|
||||
});
|
||||
});
|
||||
|
||||
let scrollRaf = 0;
|
||||
function onScroll() {
|
||||
if (!scroller || !onprogress) return;
|
||||
// Coalesce scroll events — reporting every pixel would hammer Dexie.
|
||||
if (scrollRaf) cancelAnimationFrame(scrollRaf);
|
||||
scrollRaf = requestAnimationFrame(() => {
|
||||
if (!scroller) return;
|
||||
const max = scroller.scrollHeight - scroller.clientHeight;
|
||||
const progress = max > 0 ? scroller.scrollTop / max : 0;
|
||||
// Only emit on meaningful deltas (>1%) to spare the DB.
|
||||
if (Math.abs(progress - lastReported) > 0.01) {
|
||||
lastReported = progress;
|
||||
onprogress?.(progress);
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={scroller}
|
||||
class="reader reader-{theme} reader-{fontFamily}"
|
||||
style:--reader-font-size="{fontSize}rem"
|
||||
onscroll={onScroll}
|
||||
>
|
||||
{#if html}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html html}
|
||||
{:else}
|
||||
<pre class="plain">{plainFallback}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.reader {
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem clamp(1rem, 5vw, 3rem) 4rem;
|
||||
font-size: var(--reader-font-size);
|
||||
line-height: 1.65;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
.reader-serif {
|
||||
font-family: 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, serif;
|
||||
}
|
||||
.reader-sans {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
.reader-light {
|
||||
color: #1e293b;
|
||||
background: #ffffff;
|
||||
}
|
||||
.reader-dark {
|
||||
color: #e2e8f0;
|
||||
background: #0f172a;
|
||||
}
|
||||
.reader-sepia {
|
||||
color: #433422;
|
||||
background: #f4ecd8;
|
||||
}
|
||||
.reader :global(h1),
|
||||
.reader :global(h2),
|
||||
.reader :global(h3) {
|
||||
line-height: 1.3;
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
.reader :global(h1) {
|
||||
font-size: 1.55em;
|
||||
}
|
||||
.reader :global(h2) {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
.reader :global(h3) {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.reader :global(p) {
|
||||
margin: 0 0 1.05em 0;
|
||||
}
|
||||
.reader :global(a) {
|
||||
color: #ea580c;
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.reader-dark :global(a) {
|
||||
color: #fdba74;
|
||||
}
|
||||
.reader :global(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.35rem;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.reader :global(blockquote) {
|
||||
border-left: 3px solid currentColor;
|
||||
opacity: 0.85;
|
||||
margin: 1.2em 0;
|
||||
padding: 0.15em 0 0.15em 1em;
|
||||
font-style: italic;
|
||||
}
|
||||
.reader :global(pre),
|
||||
.reader :global(code) {
|
||||
font-family: 'SF Mono', Menlo, Consolas, monospace;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
.reader :global(pre) {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
padding: 0.8em 1em;
|
||||
border-radius: 0.4rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.reader-dark :global(pre) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.reader :global(ul),
|
||||
.reader :global(ol) {
|
||||
padding-left: 1.4em;
|
||||
margin: 0 0 1.05em 0;
|
||||
}
|
||||
.plain {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
font-family: inherit;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
29
apps/mana/apps/web/src/lib/modules/articles/index.ts
Normal file
29
apps/mana/apps/web/src/lib/modules/articles/index.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* Articles module — barrel exports.
|
||||
*/
|
||||
|
||||
export { articlesStore } from './stores/articles.svelte';
|
||||
export { highlightsStore } from './stores/highlights.svelte';
|
||||
export { articleTagOps } from './stores/tags.svelte';
|
||||
|
||||
export {
|
||||
useAllArticles,
|
||||
useArticle,
|
||||
useArticleHighlights,
|
||||
toArticle,
|
||||
toHighlight,
|
||||
filterByStatus,
|
||||
searchArticles,
|
||||
} from './queries';
|
||||
|
||||
export { articleTable, articleHighlightTable, articleTagTable } from './collections';
|
||||
|
||||
export type {
|
||||
LocalArticle,
|
||||
LocalHighlight,
|
||||
LocalArticleTag,
|
||||
Article,
|
||||
Highlight,
|
||||
ArticleStatus,
|
||||
HighlightColor,
|
||||
} from './types';
|
||||
195
apps/mana/apps/web/src/lib/modules/articles/lib/offsets.ts
Normal file
195
apps/mana/apps/web/src/lib/modules/articles/lib/offsets.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
/**
|
||||
* Highlight offset resolution.
|
||||
*
|
||||
* We persist each highlight as a `{ startOffset, endOffset }` pair of
|
||||
* plain-text character offsets into the Reader's root. "Plain text" here
|
||||
* is the concatenation of all text nodes in document order — i.e. the
|
||||
* value of `root.textContent` — so `<p>Hello <strong>world</strong></p>`
|
||||
* has the offsets `H=0, e=1, …, w=6, o=7, …`. <br>, <img>, and block
|
||||
* boundaries contribute zero characters, which matches `textContent`'s
|
||||
* behaviour and the user's mental model of "what did I actually select?"
|
||||
*
|
||||
* Storing offsets into the rendered DOM (as opposed to the article's raw
|
||||
* `content` field) means we don't have to reconcile Readability's
|
||||
* whitespace normalisation with the browser's. On re-open we walk the
|
||||
* same DOM and find the node for each offset.
|
||||
*
|
||||
* The context-snippet fields on `LocalHighlight` (`contextBefore`,
|
||||
* `contextAfter`) are populated here for re-anchor purposes in later
|
||||
* milestones (when the article is re-extracted and the offsets drift).
|
||||
*/
|
||||
|
||||
const CONTEXT_CHARS = 40;
|
||||
|
||||
export interface TextOffsetPair {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single contiguous text-node slice between `start` and `end` offsets.
|
||||
* Used when wrapping a multi-node range into highlight spans.
|
||||
*/
|
||||
export interface TextSlice {
|
||||
node: Text;
|
||||
/** inclusive offset inside this text node */
|
||||
start: number;
|
||||
/** exclusive offset inside this text node */
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a DOM Range to plain-text offsets relative to `root`.
|
||||
*
|
||||
* Returns null if the range is collapsed, not inside the root, or
|
||||
* crosses element boundaries we can't map (shouldn't happen for normal
|
||||
* user selections inside the reader body).
|
||||
*/
|
||||
export function rangeToTextOffsets(range: Range, root: Element): TextOffsetPair | null {
|
||||
if (range.collapsed) return null;
|
||||
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let start = -1;
|
||||
let end = -1;
|
||||
let offset = 0;
|
||||
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
let node: Node | null = walker.nextNode();
|
||||
while (node) {
|
||||
const text = node as Text;
|
||||
const length = text.data.length;
|
||||
|
||||
if (text === range.startContainer) {
|
||||
start = offset + Math.min(range.startOffset, length);
|
||||
}
|
||||
if (text === range.endContainer) {
|
||||
end = offset + Math.min(range.endOffset, length);
|
||||
break;
|
||||
}
|
||||
offset += length;
|
||||
node = walker.nextNode();
|
||||
}
|
||||
|
||||
// Edge case: range boundaries are element nodes (e.g. the user
|
||||
// triple-clicked a paragraph). Fall back to the first/last text
|
||||
// descendant so we still get something saveable.
|
||||
if (start === -1) start = descendantOffset(root, range.startContainer, range.startOffset);
|
||||
if (end === -1) end = descendantOffset(root, range.endContainer, range.endOffset);
|
||||
|
||||
if (start < 0 || end < 0 || end <= start) return null;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function descendantOffset(root: Element, container: Node, offset: number): number {
|
||||
// Element-container ranges report offset in terms of child *nodes*, not
|
||||
// characters. Translate by summing textContent of siblings before the
|
||||
// child index.
|
||||
if (container.nodeType === Node.TEXT_NODE) {
|
||||
// Already a text node but wasn't hit in the main walk — find its absolute offset.
|
||||
let total = 0;
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
let n: Node | null = walker.nextNode();
|
||||
while (n) {
|
||||
if (n === container) return total + Math.min(offset, (n as Text).data.length);
|
||||
total += (n as Text).data.length;
|
||||
n = walker.nextNode();
|
||||
}
|
||||
return total;
|
||||
}
|
||||
const childIndex = Math.min(offset, container.childNodes.length);
|
||||
let total = textLengthBefore(root, container, childIndex);
|
||||
return total;
|
||||
}
|
||||
|
||||
function textLengthBefore(root: Element, container: Node, childIndex: number): number {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
let total = 0;
|
||||
let n: Node | null = walker.nextNode();
|
||||
while (n) {
|
||||
// Stop once we're past the target child.
|
||||
if (isDescendantAtIndexOrLater(container, childIndex, n)) return total;
|
||||
total += (n as Text).data.length;
|
||||
n = walker.nextNode();
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function isDescendantAtIndexOrLater(container: Node, index: number, candidate: Node): boolean {
|
||||
// Walk up from candidate until we hit a direct child of container.
|
||||
let node: Node | null = candidate;
|
||||
while (node && node.parentNode !== container) {
|
||||
node = node.parentNode;
|
||||
}
|
||||
if (!node) return false;
|
||||
const idx = Array.prototype.indexOf.call(container.childNodes, node);
|
||||
return idx >= index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `{ start, end }` plain-text offsets back into a list of
|
||||
* contiguous text-node slices, suitable for wrapping in highlight
|
||||
* spans. Multi-paragraph selections yield multiple slices, one per
|
||||
* text node touched.
|
||||
*/
|
||||
export function textOffsetsToSlices(root: Element, start: number, end: number): TextSlice[] {
|
||||
const slices: TextSlice[] = [];
|
||||
if (end <= start) return slices;
|
||||
|
||||
let offset = 0;
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
let node: Node | null = walker.nextNode();
|
||||
while (node) {
|
||||
const text = node as Text;
|
||||
const length = text.data.length;
|
||||
const nodeStart = offset;
|
||||
const nodeEnd = offset + length;
|
||||
|
||||
if (nodeEnd > start && nodeStart < end) {
|
||||
slices.push({
|
||||
node: text,
|
||||
start: Math.max(0, start - nodeStart),
|
||||
end: Math.min(length, end - nodeStart),
|
||||
});
|
||||
}
|
||||
|
||||
if (nodeEnd >= end) break;
|
||||
offset = nodeEnd;
|
||||
node = walker.nextNode();
|
||||
}
|
||||
return slices;
|
||||
}
|
||||
|
||||
export interface SelectionSnapshot {
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
contextBefore: string | null;
|
||||
contextAfter: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Package a user Range into everything we need to persist a highlight:
|
||||
* offsets, selected text, and ~40 chars of surrounding context for
|
||||
* later re-anchor attempts.
|
||||
*/
|
||||
export function extractSelectionSnapshot(range: Range, root: Element): SelectionSnapshot | null {
|
||||
const offsets = rangeToTextOffsets(range, root);
|
||||
if (!offsets) return null;
|
||||
|
||||
const whole = root.textContent ?? '';
|
||||
const text = whole.slice(offsets.start, offsets.end).trim();
|
||||
if (!text) return null;
|
||||
|
||||
const before = whole.slice(Math.max(0, offsets.start - CONTEXT_CHARS), offsets.start) || null;
|
||||
const after = whole.slice(offsets.end, offsets.end + CONTEXT_CHARS) || null;
|
||||
|
||||
return {
|
||||
start: offsets.start,
|
||||
end: offsets.end,
|
||||
text,
|
||||
contextBefore: before,
|
||||
contextAfter: after,
|
||||
};
|
||||
}
|
||||
18
apps/mana/apps/web/src/lib/modules/articles/module.config.ts
Normal file
18
apps/mana/apps/web/src/lib/modules/articles/module.config.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { ModuleConfig } from '$lib/data/module-registry';
|
||||
|
||||
/**
|
||||
* Articles module — saved web articles + highlights + tag links.
|
||||
*
|
||||
* `articleTags` is a pure junction into globalTags (the core `tags`
|
||||
* appId). The junction itself syncs under `articles` appId with its
|
||||
* owning rows, the same pattern every other tagged module uses
|
||||
* (noteTags, eventTags, contactTags, placeTags, …).
|
||||
*/
|
||||
export const articlesModuleConfig: ModuleConfig = {
|
||||
appId: 'articles',
|
||||
tables: [
|
||||
{ name: 'articles' },
|
||||
{ name: 'articleHighlights', syncName: 'highlights' },
|
||||
{ name: 'articleTags' },
|
||||
],
|
||||
};
|
||||
118
apps/mana/apps/web/src/lib/modules/articles/queries.ts
Normal file
118
apps/mana/apps/web/src/lib/modules/articles/queries.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/**
|
||||
* Reactive queries + type converters for the Articles module.
|
||||
*
|
||||
* Reads always flow through `scopedForModule` so the current space /
|
||||
* scene-scope filter applies transparently — module code never needs
|
||||
* to know which space it's in.
|
||||
*/
|
||||
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { scopedForModule, scopedGet } from '$lib/data/scope';
|
||||
import type { LocalArticle, LocalHighlight, Article, Highlight, ArticleStatus } from './types';
|
||||
|
||||
// ─── Type Converters ─────────────────────────────────────
|
||||
|
||||
export function toArticle(local: LocalArticle): Article {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: local.id,
|
||||
originalUrl: local.originalUrl,
|
||||
title: local.title,
|
||||
excerpt: local.excerpt ?? null,
|
||||
content: local.content,
|
||||
htmlContent: local.htmlContent ?? null,
|
||||
author: local.author ?? null,
|
||||
siteName: local.siteName ?? null,
|
||||
imageUrl: local.imageUrl ?? null,
|
||||
wordCount: local.wordCount ?? null,
|
||||
readingTimeMinutes: local.readingTimeMinutes ?? null,
|
||||
publishedAt: local.publishedAt ?? null,
|
||||
status: local.status,
|
||||
readingProgress: local.readingProgress ?? 0,
|
||||
isFavorite: local.isFavorite ?? false,
|
||||
savedAt: local.savedAt,
|
||||
readAt: local.readAt ?? null,
|
||||
userNote: local.userNote ?? null,
|
||||
extractedVersion: local.extractedVersion ?? 1,
|
||||
createdAt: local.createdAt ?? now,
|
||||
updatedAt: local.updatedAt ?? now,
|
||||
};
|
||||
}
|
||||
|
||||
export function toHighlight(local: LocalHighlight): Highlight {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: local.id,
|
||||
articleId: local.articleId,
|
||||
text: local.text,
|
||||
note: local.note ?? null,
|
||||
color: local.color,
|
||||
startOffset: local.startOffset,
|
||||
endOffset: local.endOffset,
|
||||
contextBefore: local.contextBefore ?? null,
|
||||
contextAfter: local.contextAfter ?? null,
|
||||
createdAt: local.createdAt ?? now,
|
||||
updatedAt: local.updatedAt ?? now,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Live Queries ─────────────────────────────────────────
|
||||
|
||||
export function useAllArticles() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const locals = await scopedForModule<LocalArticle, string>('articles', 'articles').toArray();
|
||||
const visible = locals.filter((a) => !a.deletedAt);
|
||||
const decrypted = await decryptRecords('articles', visible);
|
||||
return decrypted
|
||||
.map(toArticle)
|
||||
.sort((a, b) => (b.savedAt ?? '').localeCompare(a.savedAt ?? ''));
|
||||
}, [] as Article[]);
|
||||
}
|
||||
|
||||
export function useArticle(id: string) {
|
||||
return useLiveQueryWithDefault(
|
||||
async () => {
|
||||
// scopedGet returns undefined if the article belongs to another
|
||||
// space — protects against URL-manipulated deep links.
|
||||
const local = await scopedGet<LocalArticle>('articles', id);
|
||||
if (!local || local.deletedAt) return null;
|
||||
const [decrypted] = await decryptRecords('articles', [local]);
|
||||
return decrypted ? toArticle(decrypted) : null;
|
||||
},
|
||||
null as Article | null
|
||||
);
|
||||
}
|
||||
|
||||
export function useArticleHighlights(articleId: string) {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
// scopedForModule returns the scope-filtered Collection; we narrow
|
||||
// to this article in a post-filter (O(highlights per space), tiny).
|
||||
// Using scopedForModule instead of a direct indexed where() keeps the
|
||||
// scope check centralised — same pattern other modules use for
|
||||
// per-parent lookups (e.g. notes tag subsets).
|
||||
const locals = await scopedForModule<LocalHighlight, string>(
|
||||
'articles',
|
||||
'articleHighlights'
|
||||
).toArray();
|
||||
const forArticle = locals.filter((h) => h.articleId === articleId && !h.deletedAt);
|
||||
const decrypted = await decryptRecords('articleHighlights', forArticle);
|
||||
return decrypted.map(toHighlight).sort((a, b) => a.startOffset - b.startOffset);
|
||||
}, [] as Highlight[]);
|
||||
}
|
||||
|
||||
// ─── Pure Helpers ─────────────────────────────────────────
|
||||
|
||||
export function filterByStatus(articles: Article[], status: ArticleStatus): Article[] {
|
||||
return articles.filter((a) => a.status === status);
|
||||
}
|
||||
|
||||
export function searchArticles(articles: Article[], query: string): Article[] {
|
||||
const lower = query.toLowerCase();
|
||||
return articles.filter(
|
||||
(a) =>
|
||||
a.title.toLowerCase().includes(lower) ||
|
||||
(a.author?.toLowerCase().includes(lower) ?? false) ||
|
||||
(a.siteName?.toLowerCase().includes(lower) ?? false)
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
/**
|
||||
* Articles store — mutation-only service.
|
||||
*
|
||||
* M1 scope is intentionally thin: delete + status/favourite/progress toggles
|
||||
* that exercise the encryption + event pipeline. `saveFromUrl` (the real
|
||||
* ingestion path) lands in M2 together with the server extract route and
|
||||
* AddUrlForm. The pipeline is wired now so the Reader view and CRUD plumbing
|
||||
* in M2/M3 can slot in without reshaping calls.
|
||||
*/
|
||||
|
||||
import { encryptRecord, decryptRecords } from '$lib/data/crypto';
|
||||
import { emitDomainEvent } from '$lib/data/events';
|
||||
import { scopedForModule } from '$lib/data/scope';
|
||||
import { articleTable } from '../collections';
|
||||
import { extractArticle, type ExtractedArticle } from '../api';
|
||||
import { toArticle } from '../queries';
|
||||
import type { Article, ArticleStatus, LocalArticle } from '../types';
|
||||
|
||||
export const articlesStore = {
|
||||
async setStatus(id: string, status: ArticleStatus): Promise<void> {
|
||||
const diff: Partial<LocalArticle> = {
|
||||
status,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
if (status === 'finished') {
|
||||
const existing = await articleTable.get(id);
|
||||
if (existing && !existing.readAt) diff.readAt = diff.updatedAt;
|
||||
}
|
||||
await articleTable.update(id, diff);
|
||||
},
|
||||
|
||||
async toggleFavorite(id: string): Promise<void> {
|
||||
const existing = await articleTable.get(id);
|
||||
if (!existing) return;
|
||||
await articleTable.update(id, {
|
||||
isFavorite: !existing.isFavorite,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async setProgress(id: string, progress: number): Promise<void> {
|
||||
const clamped = Math.max(0, Math.min(1, progress));
|
||||
await articleTable.update(id, {
|
||||
readingProgress: clamped,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async updateNote(id: string, note: string | null): Promise<void> {
|
||||
const diff: Partial<LocalArticle> = {
|
||||
userNote: note,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord('articles', diff as LocalArticle);
|
||||
await articleTable.update(id, diff);
|
||||
},
|
||||
|
||||
async deleteArticle(id: string): Promise<void> {
|
||||
await articleTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Look up an already-saved article by URL in the current space. Used
|
||||
* by the dedupe path in saveFromUrl and by AddUrlForm to offer
|
||||
* "already saved — open it" instead of duplicating the row.
|
||||
* Returns a decrypted snapshot, or null.
|
||||
*/
|
||||
async findByUrl(url: string): Promise<Article | null> {
|
||||
const match = await scopedForModule<LocalArticle, string>('articles', 'articles')
|
||||
.filter((r) => r.originalUrl === url && !r.deletedAt)
|
||||
.first();
|
||||
if (!match) return null;
|
||||
const [decrypted] = await decryptRecords('articles', [match]);
|
||||
return decrypted ? toArticle(decrypted) : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Persist an extracted payload as a saved article. Returns the snapshot
|
||||
* directly so callers can navigate to `/articles/<id>` without waiting
|
||||
* for the liveQuery to tick.
|
||||
*
|
||||
* AddUrlForm passes a pre-extracted payload (after it already called
|
||||
* extractArticle once to render the preview); the direct path in
|
||||
* saveFromUrl lets the store do the extract itself.
|
||||
*/
|
||||
async saveFromExtracted(extracted: ExtractedArticle): Promise<Article> {
|
||||
const now = new Date().toISOString();
|
||||
const newLocal: LocalArticle = {
|
||||
id: crypto.randomUUID(),
|
||||
originalUrl: extracted.originalUrl,
|
||||
title: extracted.title,
|
||||
excerpt: extracted.excerpt ?? null,
|
||||
content: extracted.content,
|
||||
htmlContent: extracted.htmlContent ?? null,
|
||||
author: extracted.author ?? null,
|
||||
siteName: extracted.siteName ?? null,
|
||||
imageUrl: null,
|
||||
wordCount: extracted.wordCount,
|
||||
readingTimeMinutes: extracted.readingTimeMinutes,
|
||||
publishedAt: null,
|
||||
status: 'unread',
|
||||
readingProgress: 0,
|
||||
isFavorite: false,
|
||||
savedAt: now,
|
||||
readAt: null,
|
||||
userNote: null,
|
||||
extractedVersion: 1,
|
||||
};
|
||||
const snapshot = toArticle(newLocal);
|
||||
await encryptRecord('articles', newLocal);
|
||||
await articleTable.add(newLocal);
|
||||
emitDomainEvent('ArticleSaved', 'articles', 'articles', newLocal.id, {
|
||||
articleId: newLocal.id,
|
||||
title: newLocal.title,
|
||||
});
|
||||
return snapshot;
|
||||
},
|
||||
|
||||
/**
|
||||
* Full save path: dedupe → extract → persist. Returns the existing
|
||||
* article when the URL is already saved in the current space (the
|
||||
* caller can then navigate to it instead of creating a duplicate).
|
||||
*/
|
||||
async saveFromUrl(url: string): Promise<{ article: Article; duplicate: boolean }> {
|
||||
const existing = await this.findByUrl(url);
|
||||
if (existing) return { article: existing, duplicate: true };
|
||||
const extracted = await extractArticle(url);
|
||||
const article = await this.saveFromExtracted(extracted);
|
||||
return { article, duplicate: false };
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* Highlights store — mutation-only service for `articleHighlights`.
|
||||
*
|
||||
* Every write routes through encryptRecord so text + note + context
|
||||
* snippets ship encrypted. Structural fields (articleId, startOffset,
|
||||
* endOffset, color) stay plaintext for the reader's range-scan query.
|
||||
*/
|
||||
|
||||
import { encryptRecord } from '$lib/data/crypto';
|
||||
import { articleHighlightTable } from '../collections';
|
||||
import { toHighlight } from '../queries';
|
||||
import type { Highlight, HighlightColor, LocalHighlight } from '../types';
|
||||
|
||||
export interface AddHighlightInput {
|
||||
articleId: string;
|
||||
text: string;
|
||||
color?: HighlightColor;
|
||||
note?: string | null;
|
||||
startOffset: number;
|
||||
endOffset: number;
|
||||
contextBefore?: string | null;
|
||||
contextAfter?: string | null;
|
||||
}
|
||||
|
||||
export const highlightsStore = {
|
||||
async addHighlight(input: AddHighlightInput): Promise<Highlight> {
|
||||
const newLocal: LocalHighlight = {
|
||||
id: crypto.randomUUID(),
|
||||
articleId: input.articleId,
|
||||
text: input.text,
|
||||
note: input.note ?? null,
|
||||
color: input.color ?? 'yellow',
|
||||
startOffset: input.startOffset,
|
||||
endOffset: input.endOffset,
|
||||
contextBefore: input.contextBefore ?? null,
|
||||
contextAfter: input.contextAfter ?? null,
|
||||
};
|
||||
const snapshot = toHighlight(newLocal);
|
||||
await encryptRecord('articleHighlights', newLocal);
|
||||
await articleHighlightTable.add(newLocal);
|
||||
return snapshot;
|
||||
},
|
||||
|
||||
async setColor(id: string, color: HighlightColor): Promise<void> {
|
||||
await articleHighlightTable.update(id, {
|
||||
color,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async setNote(id: string, note: string | null): Promise<void> {
|
||||
const diff: Partial<LocalHighlight> = {
|
||||
note,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord('articleHighlights', diff as LocalHighlight);
|
||||
await articleHighlightTable.update(id, diff);
|
||||
},
|
||||
|
||||
async deleteHighlight(id: string): Promise<void> {
|
||||
await articleHighlightTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* Articles Tags — junction ops into the global tag system.
|
||||
*
|
||||
* Mirrors notes/stores/tags.svelte.ts, calendar/stores/tags.svelte.ts,
|
||||
* contacts/stores/tags.svelte.ts — tag names/colors live in globalTags
|
||||
* (appId: 'tags'), articles just holds the junction rows.
|
||||
*/
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import { createTagLinkOps } from '@mana/shared-stores';
|
||||
|
||||
export {
|
||||
tagMutations,
|
||||
useAllTags,
|
||||
getTagById,
|
||||
getTagsByIds,
|
||||
getTagColor,
|
||||
} from '@mana/shared-stores';
|
||||
|
||||
export const articleTagOps = createTagLinkOps({
|
||||
table: () => db.table('articleTags'),
|
||||
entityIdField: 'articleId',
|
||||
});
|
||||
117
apps/mana/apps/web/src/lib/modules/articles/types.ts
Normal file
117
apps/mana/apps/web/src/lib/modules/articles/types.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* Articles module — Pocket-style read-it-later.
|
||||
*
|
||||
* Three Dexie tables:
|
||||
*
|
||||
* articles — saved URLs + extracted Readability content
|
||||
* (encrypted: title, excerpt, content, htmlContent,
|
||||
* author, userNote). Reading state + dedupe key
|
||||
* stay plaintext for indexing.
|
||||
* articleHighlights — per-selection rows with plain-text offsets.
|
||||
* Encrypted: text, note, context snippets.
|
||||
* articleTags — pure junction into globalTags. No user-typed
|
||||
* content lives here — tag names/colors are in
|
||||
* the global tag system (appId: 'tags').
|
||||
*/
|
||||
|
||||
import type { BaseRecord } from '@mana/local-store';
|
||||
|
||||
// ─── Discriminators ──────────────────────────────────────
|
||||
|
||||
export type ArticleStatus = 'unread' | 'reading' | 'finished' | 'archived';
|
||||
|
||||
export type HighlightColor = 'yellow' | 'green' | 'blue' | 'pink';
|
||||
|
||||
// ─── Local Records (Dexie) ───────────────────────────────
|
||||
|
||||
export interface LocalArticle extends BaseRecord {
|
||||
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;
|
||||
status: ArticleStatus;
|
||||
/** 0..1 scroll position so the reader can restore where the user stopped. */
|
||||
readingProgress: number;
|
||||
isFavorite: boolean;
|
||||
savedAt: string;
|
||||
readAt: string | null;
|
||||
userNote: string | null;
|
||||
/** Bumped when the article is re-extracted so highlight re-anchoring
|
||||
* can decide whether to trust cached offsets. */
|
||||
extractedVersion: number;
|
||||
}
|
||||
|
||||
export interface LocalHighlight extends BaseRecord {
|
||||
articleId: string;
|
||||
text: string;
|
||||
note: string | null;
|
||||
color: HighlightColor;
|
||||
/** Plain-text char offsets into `LocalArticle.content`. The reader maps
|
||||
* these back to DOM ranges over the rendered htmlContent. */
|
||||
startOffset: number;
|
||||
endOffset: number;
|
||||
/** Short fragments (~50 chars) around the selection — used to
|
||||
* re-anchor the highlight if the article gets re-extracted and
|
||||
* the offsets shift. */
|
||||
contextBefore: string | null;
|
||||
contextAfter: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Junction row linking one article to one global tag. Same shape as
|
||||
* noteTags / eventTags / contactTags / placeTags — zero user-typed
|
||||
* content, so the row stays out of the encryption registry and lives
|
||||
* on the plaintext allowlist. Tag name/color/group come from globalTags
|
||||
* via @mana/shared-stores helpers.
|
||||
*/
|
||||
export interface LocalArticleTag extends BaseRecord {
|
||||
articleId: string;
|
||||
tagId: string;
|
||||
}
|
||||
|
||||
// ─── Public DTOs (rendered by views) ─────────────────────
|
||||
|
||||
export interface Article {
|
||||
id: string;
|
||||
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;
|
||||
status: ArticleStatus;
|
||||
readingProgress: number;
|
||||
isFavorite: boolean;
|
||||
savedAt: string;
|
||||
readAt: string | null;
|
||||
userNote: string | null;
|
||||
extractedVersion: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Highlight {
|
||||
id: string;
|
||||
articleId: string;
|
||||
text: string;
|
||||
note: string | null;
|
||||
color: HighlightColor;
|
||||
startOffset: number;
|
||||
endOffset: number;
|
||||
contextBefore: string | null;
|
||||
contextAfter: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,366 @@
|
|||
<!--
|
||||
DetailView — article reader + action bar.
|
||||
|
||||
Composes the ReaderView typography shell with an action bar (status,
|
||||
favourite, archive, delete, external link) and a size/theme-picker
|
||||
that sits sticky at the top.
|
||||
|
||||
Reading progress is persisted per scroll event (throttled in the
|
||||
Reader). Re-opening the article restores the last scroll position.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { useArticle } from '../queries';
|
||||
import { articlesStore } from '../stores/articles.svelte';
|
||||
import ReaderView from '../components/ReaderView.svelte';
|
||||
import HighlightLayer from '../components/HighlightLayer.svelte';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
}
|
||||
let { id }: Props = $props();
|
||||
|
||||
// Re-create the live query when [id] changes. Without $derived.by the
|
||||
// subscription binds to the initial id only, so navigating directly from
|
||||
// one article's detail view to another's (same mount) would keep showing
|
||||
// the old one.
|
||||
const article$ = $derived.by(() => useArticle(id));
|
||||
const article = $derived(article$.value);
|
||||
|
||||
// Typography state — per-session only for now. Persisting into userSettings
|
||||
// comes later; M2 just gets the UX loop working.
|
||||
let fontSize = $state(1);
|
||||
let theme = $state<'light' | 'dark' | 'sepia'>('light');
|
||||
let fontFamily = $state<'serif' | 'sans'>('serif');
|
||||
|
||||
// Refs handed off to HighlightLayer: `shell` is the positioning anchor
|
||||
// for the floating menu, `readerScroller` is where text lives + where
|
||||
// selection events fire.
|
||||
let shell: HTMLDivElement | undefined = $state();
|
||||
let readerScroller = $state<HTMLDivElement | null>(null);
|
||||
|
||||
async function toggleRead() {
|
||||
if (!article) return;
|
||||
await articlesStore.setStatus(
|
||||
article.id,
|
||||
article.status === 'finished' ? 'unread' : 'finished'
|
||||
);
|
||||
}
|
||||
|
||||
async function toggleFavorite() {
|
||||
if (!article) return;
|
||||
await articlesStore.toggleFavorite(article.id);
|
||||
}
|
||||
|
||||
async function archive() {
|
||||
if (!article) return;
|
||||
await articlesStore.setStatus(article.id, 'archived');
|
||||
goto('/articles');
|
||||
}
|
||||
|
||||
async function deleteArticle() {
|
||||
if (!article) return;
|
||||
if (!confirm('Artikel wirklich löschen?')) return;
|
||||
await articlesStore.deleteArticle(article.id);
|
||||
goto('/articles');
|
||||
}
|
||||
|
||||
async function onProgress(progress: number) {
|
||||
if (!article) return;
|
||||
// First meaningful scroll flips unread → reading; reader handles the
|
||||
// rest of the lifecycle (mark-finished is an explicit user action).
|
||||
if (article.status === 'unread' && progress > 0.05) {
|
||||
await articlesStore.setStatus(article.id, 'reading');
|
||||
}
|
||||
await articlesStore.setProgress(article.id, progress);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{article?.title ?? 'Artikel'} — Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="detail-shell detail-{theme}" bind:this={shell}>
|
||||
<header class="topbar">
|
||||
<button type="button" class="topbtn" onclick={() => goto('/articles')} aria-label="Zurück">
|
||||
← Zurück
|
||||
</button>
|
||||
|
||||
{#if article}
|
||||
<div class="type-controls">
|
||||
<button
|
||||
type="button"
|
||||
class="topbtn"
|
||||
onclick={() => (fontSize = Math.max(0.85, fontSize - 0.075))}
|
||||
title="Kleiner"
|
||||
aria-label="Schrift kleiner"
|
||||
>
|
||||
A−
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="topbtn"
|
||||
onclick={() => (fontSize = Math.min(1.35, fontSize + 0.075))}
|
||||
title="Größer"
|
||||
aria-label="Schrift größer"
|
||||
>
|
||||
A+
|
||||
</button>
|
||||
<span class="divider"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="topbtn"
|
||||
class:active={fontFamily === 'serif'}
|
||||
onclick={() => (fontFamily = 'serif')}
|
||||
title="Serif"
|
||||
>
|
||||
Serif
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="topbtn"
|
||||
class:active={fontFamily === 'sans'}
|
||||
onclick={() => (fontFamily = 'sans')}
|
||||
title="Sans"
|
||||
>
|
||||
Sans
|
||||
</button>
|
||||
<span class="divider"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="topbtn swatch swatch-light"
|
||||
class:active={theme === 'light'}
|
||||
onclick={() => (theme = 'light')}
|
||||
aria-label="Heller Modus"
|
||||
title="Hell"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
class="topbtn swatch swatch-sepia"
|
||||
class:active={theme === 'sepia'}
|
||||
onclick={() => (theme = 'sepia')}
|
||||
aria-label="Sepia-Modus"
|
||||
title="Sepia"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
class="topbtn swatch swatch-dark"
|
||||
class:active={theme === 'dark'}
|
||||
onclick={() => (theme = 'dark')}
|
||||
aria-label="Dunkler Modus"
|
||||
title="Dunkel"
|
||||
></button>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if article$.loading}
|
||||
<p class="placeholder">Lädt…</p>
|
||||
{:else if !article}
|
||||
<div class="placeholder">
|
||||
<p>Artikel nicht gefunden.</p>
|
||||
<button type="button" class="topbtn" onclick={() => goto('/articles')}>
|
||||
Zurück zur Liste
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="meta-bar">
|
||||
<h1 class="title">{article.title}</h1>
|
||||
<div class="meta-row">
|
||||
{#if article.siteName}<span>{article.siteName}</span>{/if}
|
||||
{#if article.author}<span>· {article.author}</span>{/if}
|
||||
{#if article.readingTimeMinutes}<span>· {article.readingTimeMinutes} min</span>{/if}
|
||||
{#if article.wordCount}<span>· {article.wordCount} Wörter</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReaderView
|
||||
html={article.htmlContent}
|
||||
plainFallback={article.content}
|
||||
{theme}
|
||||
{fontSize}
|
||||
{fontFamily}
|
||||
initialProgress={article.readingProgress}
|
||||
onprogress={onProgress}
|
||||
onscroller={(el) => (readerScroller = el)}
|
||||
/>
|
||||
|
||||
<HighlightLayer
|
||||
articleId={article.id}
|
||||
scroller={readerScroller}
|
||||
container={shell ?? null}
|
||||
htmlVersion={article.htmlContent}
|
||||
/>
|
||||
|
||||
<footer class="actionbar">
|
||||
<button
|
||||
type="button"
|
||||
class="actionbtn"
|
||||
class:active={article.status === 'finished'}
|
||||
onclick={toggleRead}
|
||||
>
|
||||
{article.status === 'finished' ? '✓ Gelesen' : 'Als gelesen markieren'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="actionbtn"
|
||||
class:active={article.isFavorite}
|
||||
onclick={toggleFavorite}
|
||||
aria-label="Favorit umschalten"
|
||||
>
|
||||
{article.isFavorite ? '★ Favorit' : '☆ Favorit'}
|
||||
</button>
|
||||
<button type="button" class="actionbtn" onclick={archive}>Archivieren</button>
|
||||
<a class="actionbtn" href={article.originalUrl} target="_blank" rel="noopener noreferrer">
|
||||
Original ↗
|
||||
</a>
|
||||
<span class="spacer"></span>
|
||||
<button type="button" class="actionbtn danger" onclick={deleteArticle}>Löschen</button>
|
||||
</footer>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.detail-shell {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Positioning anchor for the floating HighlightMenu: its `top`/`left` */
|
||||
/* coordinates are computed relative to this box. */
|
||||
position: relative;
|
||||
}
|
||||
.detail-light {
|
||||
background: #ffffff;
|
||||
color: #1e293b;
|
||||
}
|
||||
.detail-sepia {
|
||||
background: #f4ecd8;
|
||||
color: #433422;
|
||||
}
|
||||
.detail-dark {
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 0.9rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, currentColor 12%, transparent);
|
||||
background: inherit;
|
||||
}
|
||||
.type-controls {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
margin-left: auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 1.2em;
|
||||
background: color-mix(in srgb, currentColor 25%, transparent);
|
||||
align-self: center;
|
||||
margin: 0 0.2rem;
|
||||
}
|
||||
.topbtn {
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.3rem 0.65rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||
border-radius: 0.45rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.topbtn:hover {
|
||||
border-color: color-mix(in srgb, currentColor 35%, transparent);
|
||||
}
|
||||
.topbtn.active {
|
||||
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||
border-color: color-mix(in srgb, currentColor 35%, transparent);
|
||||
}
|
||||
.swatch {
|
||||
width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.swatch-light {
|
||||
background: #ffffff;
|
||||
}
|
||||
.swatch-sepia {
|
||||
background: #f4ecd8;
|
||||
}
|
||||
.swatch-dark {
|
||||
background: #0f172a;
|
||||
}
|
||||
.meta-bar {
|
||||
max-width: 700px;
|
||||
margin: 1.2rem auto 0;
|
||||
padding: 0 clamp(1rem, 5vw, 3rem);
|
||||
width: 100%;
|
||||
}
|
||||
.title {
|
||||
font-size: 1.8rem;
|
||||
line-height: 1.25;
|
||||
margin: 0 0 0.4rem 0;
|
||||
}
|
||||
.meta-row {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.75;
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.actionbar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 0.9rem;
|
||||
border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent);
|
||||
background: inherit;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.actionbtn {
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||
border-radius: 0.45rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.actionbtn:hover {
|
||||
border-color: color-mix(in srgb, currentColor 35%, transparent);
|
||||
}
|
||||
.actionbtn.active {
|
||||
background: color-mix(in srgb, #f97316 85%, transparent);
|
||||
color: white;
|
||||
border-color: #f97316;
|
||||
}
|
||||
.actionbtn.danger {
|
||||
color: #ef4444;
|
||||
border-color: color-mix(in srgb, #ef4444 30%, transparent);
|
||||
}
|
||||
.actionbtn.danger:hover {
|
||||
background: color-mix(in srgb, #ef4444 10%, transparent);
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.placeholder {
|
||||
text-align: center;
|
||||
margin: 3rem auto;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<script lang="ts">
|
||||
import ListView from '$lib/modules/articles/ListView.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Artikel - Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<ListView />
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import DetailView from '$lib/modules/articles/views/DetailView.svelte';
|
||||
|
||||
const id = $derived($page.params.id ?? '');
|
||||
</script>
|
||||
|
||||
<DetailView {id} />
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<script lang="ts">
|
||||
import AddUrlForm from '$lib/modules/articles/components/AddUrlForm.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Artikel speichern — Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<AddUrlForm />
|
||||
386
docs/plans/articles-module.md
Normal file
386
docs/plans/articles-module.md
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
# Articles — Module Plan
|
||||
|
||||
## Status (2026-04-21)
|
||||
|
||||
Proposed. Noch nichts gebaut.
|
||||
|
||||
## Ziel
|
||||
|
||||
Ein dediziertes Pocket-/Instapaper-Ersatzmodul: der Nutzer speichert beliebige Web-URLs, der Inhalt wird serverseitig mit Readability extrahiert, landet verschlüsselt in IndexedDB und ist danach **offline lesbar** im eigenen Reader-View — mit Highlights, Tags, Notizen und Reading-Progress.
|
||||
|
||||
Kernfrage: *„Ich will diesen Artikel später in Ruhe lesen."*
|
||||
|
||||
Nicht im Scope: Web-Browser-Extension mit automatischem Save (kommt in M7 als PWA-Share-Target/Bookmarklet), Social-Features, Public-Sharing, Full-Text-Search-Index. Kein Highlights-Export in andere Tools (Phase 3).
|
||||
|
||||
## Abgrenzung zu bestehenden Modulen
|
||||
|
||||
- **`news`**: bleibt der **kuratierte Feed** aus dem server-seitigen `curated_articles`-Pool + Reaktionen/Preferences. Die dortige `type: 'saved'`-Funktion (ad-hoc URL-Save) wird auf `articles` migriert und im News-Modul deprecated. `/news/saved` entfällt, `/news/add` fällt weg (Redirect auf `/articles/add`).
|
||||
- **`library`**: konsumierte Medien (Bücher, Filme, Serien, Comics). Keine Web-Artikel.
|
||||
- **`guides`**: eigene strukturierte Schritt-für-Schritt-Anleitungen. Kein Web-Extract.
|
||||
- **`notes`**: freie Notizen, kein Web-Extract + Reader-View.
|
||||
- **`kontext`**: URL-Crawl für AI-Kontext (Singleton-Doc, nicht pro Artikel). Überlappt nicht.
|
||||
|
||||
## Entscheidungen vorab
|
||||
|
||||
- **Name `articles` + appId `articles`** (nicht `pocket` — markenneutral, klar, generisch).
|
||||
- **Shared Extract statt neuem Code:** `@mana/shared-rss` bietet bereits `extractFromUrl()` (Readability + JSDOM, siehe `packages/shared-rss/src/extract.ts`). Beide Module nutzen dasselbe Package. Kein Refactor der Bibliothek nötig.
|
||||
- **Eigener API-Endpoint:** `/api/v1/articles/extract/preview` + `/api/v1/articles/extract/save` in `apps/api/src/modules/articles/routes.ts` — dupliziert einen kleinen Handler, damit das articles-Modul nicht auf `news/routes.ts` angewiesen ist. Die eigentliche Extraktion passiert weiterhin in `shared-rss`.
|
||||
- **Drei Tabellen statt einer:**
|
||||
- `articles` — Haupttabelle (extrahierter Inhalt + Reading-State)
|
||||
- `articleHighlights` — pro Highlight eine Row (Offset-Range + optionale Notiz)
|
||||
- `articleTags` — reine **Junction-Tabelle** `(id, articleId, tagId)` ins globale Tag-System
|
||||
Begründung: Highlights brauchen eigene Write-Pfade (Select → Save) und eigene Encryption-Felder. Tags **sind bereits global** als Kern-Infra (`globalTags`/`tagGroups` mit `appId: 'tags'`, siehe `apps/mana/apps/web/src/lib/modules/core/module.config.ts`) — jedes Modul hält nur eine schlanke Junction. Kein eigener Name/Farbe/Sortierung — das lebt zentral.
|
||||
- **`originalUrl` bleibt plaintext.** Dedupe-Key, gleiche Begründung wie bei `newsArticles.originalUrl` und `uLoad.links.originalUrl` in der Encryption-Registry.
|
||||
- **Content + Titel + Excerpt + Author + Highlights + Notizen bleiben verschlüsselt.** Reading-Behavior ist GDPR-sensitiv — gleiche Schutzklasse wie `newsArticles`.
|
||||
- **Kein Client-side Extract im ersten Schritt.** JSDOM läuft nur serverseitig. Offline-gepuffertes Save-Later (z.B. aus PWA-Share-Target ohne Internet) geht in eine lokale `_pendingUrls`-Queue und wird beim nächsten Online-Sync extrahiert. Das kommt in M7.
|
||||
- **Migration der `news`-saved-Rows:** einmaliger Upgrade-Hook in der Dexie-Schema-Migration — alle `newsArticles` mit `type='saved'` wandern nach `articles`. Danach kann die News-Types um das `type`-Discriminator-Feld und den `saveFromUrl`-Pfad verschlankt werden.
|
||||
|
||||
## Modul-Struktur
|
||||
|
||||
```
|
||||
apps/mana/apps/web/src/lib/modules/articles/
|
||||
├── types.ts # LocalArticle, LocalHighlight, LocalTag, public DTOs
|
||||
├── collections.ts # articleTable, highlightTable, tagTable + Defaults
|
||||
├── queries.ts # useAllArticles, useArticle(id), useHighlights(articleId), useTags, toArticle/toHighlight/toTag
|
||||
├── api.ts # fetch wrappers for /api/v1/articles/extract/*
|
||||
├── stores/
|
||||
│ ├── articles.svelte.ts # saveFromUrl, markRead, toggleFavorite, archive, setProgress, delete
|
||||
│ ├── highlights.svelte.ts # addHighlight, updateHighlightNote, deleteHighlight
|
||||
│ └── tags.svelte.ts # Vier-Zeiler: re-export aus @mana/shared-stores + articleTagOps = createTagLinkOps({...})
|
||||
├── components/
|
||||
│ ├── ArticleCard.svelte # Listeneintrag (Cover + Titel + Excerpt + Reading-Time + Status-Badge)
|
||||
│ ├── AddUrlForm.svelte # URL-Paste + Preview + Save
|
||||
│ ├── ReaderView.svelte # Reader-Typografie (Serif/Sans, Größe, Zeilenhöhe, Sepia/Dunkel)
|
||||
│ ├── HighlightLayer.svelte # Overlay für bestehende Highlights + Selection-Handler
|
||||
│ ├── HighlightMenu.svelte # Floating-Menu bei Text-Selection (Farbe + Notiz + Save)
|
||||
│ ├── TagPicker.svelte # Multi-Select mit Inline-Create
|
||||
│ ├── TagChip.svelte # Farbige Chip-Darstellung
|
||||
│ ├── ProgressBar.svelte # Reading-Fortschritt (0..1)
|
||||
│ └── StatusFilter.svelte # Alle | Ungelesen | Favoriten | Archiv
|
||||
├── views/
|
||||
│ ├── ListView.svelte # Modul-Root (List + Filter + FAB)
|
||||
│ ├── DetailView.svelte # Reader-View + Highlight-Layer + Tag/Action-Bar
|
||||
│ └── HighlightsView.svelte # Sammelansicht über alle Artikel (Phase 2)
|
||||
├── tools.ts # AI-Tools — siehe AI-Integration
|
||||
├── constants.ts # READER_FONTS, READER_THEMES, DEFAULT_HIGHLIGHT_COLORS
|
||||
├── module.config.ts # { appId: 'articles', tables: [...] }
|
||||
└── index.ts # Re-Exports
|
||||
```
|
||||
|
||||
## Daten-Schema
|
||||
|
||||
### `LocalArticle`
|
||||
|
||||
```typescript
|
||||
export type ArticleStatus = 'unread' | 'reading' | 'finished' | 'archived';
|
||||
|
||||
export interface LocalArticle extends BaseRecord {
|
||||
originalUrl: string; // plaintext — Dedupe-Key
|
||||
title: string; // encrypted
|
||||
excerpt: string | null; // encrypted
|
||||
content: string; // encrypted — plain text (fallback)
|
||||
htmlContent: string | null; // encrypted — sanitisiertes HTML (Reader)
|
||||
author: string | null; // encrypted
|
||||
siteName: string | null; // plaintext — Filter (nach Quelle gruppieren)
|
||||
imageUrl: string | null; // plaintext — Externe URL / media-Ref
|
||||
wordCount: number | null; // plaintext — Reading-Time, Stats
|
||||
readingTimeMinutes: number | null; // plaintext
|
||||
publishedAt: string | null; // plaintext ISO — Sort/Filter
|
||||
// Reading-State
|
||||
status: ArticleStatus; // plaintext — Haupt-Filter
|
||||
readingProgress: number; // plaintext 0..1 — Scroll-Position beim Re-Open
|
||||
isFavorite: boolean; // plaintext
|
||||
savedAt: string; // plaintext ISO
|
||||
readAt: string | null; // plaintext ISO — wann zuerst „finished"
|
||||
// Organisation — KEIN tagIds: string[] direkt auf dem Record.
|
||||
// Tag-Zuordnung lebt ausschließlich in der Junction-Tabelle `articleTags`.
|
||||
// Gelesen via `articleTagOps.getTagIds(id)` / `getTagIdsForMany(ids)`.
|
||||
userNote: string | null; // encrypted — freie Notiz des Users zum Artikel
|
||||
// Meta
|
||||
extractedVersion: number; // plaintext — falls wir später re-extrahieren
|
||||
}
|
||||
```
|
||||
|
||||
### `LocalHighlight`
|
||||
|
||||
```typescript
|
||||
export type HighlightColor = 'yellow' | 'green' | 'blue' | 'pink';
|
||||
|
||||
export interface LocalHighlight extends BaseRecord {
|
||||
articleId: string; // plaintext — FK, indexed
|
||||
text: string; // encrypted — der markierte Text
|
||||
note: string | null; // encrypted — optionale Notiz
|
||||
color: HighlightColor; // plaintext
|
||||
/** Offsets ins extrahierte `content`-Feld (Plain-Text-Offset). HTML-Rendering
|
||||
* mapped im HighlightLayer von Plain-Offset → DOM-Range. */
|
||||
startOffset: number; // plaintext
|
||||
endOffset: number; // plaintext
|
||||
/** Kontext-Snippet (ca. 50 chars vorher/nachher), falls das Article-Content
|
||||
* später re-extrahiert wird und die Offsets verrutschen — dann kann man
|
||||
* anhand des Snippets re-anchorn. */
|
||||
contextBefore: string | null; // encrypted
|
||||
contextAfter: string | null; // encrypted
|
||||
}
|
||||
```
|
||||
|
||||
### `articleTags` (Junction → globalTags)
|
||||
|
||||
Keine eigene Tag-Entity. Schema ist identisch zu `noteTags`, `eventTags`, `contactTags` etc.:
|
||||
|
||||
```typescript
|
||||
export interface LocalArticleTag {
|
||||
id: string;
|
||||
articleId: string;
|
||||
tagId: string; // FK → globalTags.id
|
||||
userId?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
deletedAt?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Tag-Namen, Farben, Gruppen leben zentral in `globalTags` / `tagGroups` und werden via `@mana/shared-stores`-Helpers abgefragt (`useAllTags`, `getTagById`, `getTagsByIds`, `getTagColor`, `tagMutations`).
|
||||
|
||||
### Dexie-Indizes
|
||||
|
||||
```typescript
|
||||
// apps/mana/apps/web/src/lib/data/database.ts — neue Version N+1:
|
||||
articles: 'id, userId, status, savedAt, isFavorite, siteName, originalUrl',
|
||||
articleHighlights: 'id, userId, articleId, [articleId+startOffset]',
|
||||
articleTags: 'id, userId, articleId, tagId, [articleId+tagId]',
|
||||
```
|
||||
|
||||
- `originalUrl` indexiert für O(1)-Dedupe beim Save.
|
||||
- `[articleId+startOffset]` für sortierten Highlight-Render im Reader.
|
||||
- `status` für den Main-Filter (Unread/Reading/Finished/Archived).
|
||||
- `[articleId+tagId]` matcht das Pattern aller anderen Tag-Junctions (N+1-freies Batch-Read via `getTagIdsForMany`).
|
||||
|
||||
### Encryption-Registry
|
||||
|
||||
`apps/mana/apps/web/src/lib/data/crypto/registry.ts` — drei neue Einträge:
|
||||
|
||||
```typescript
|
||||
// ─── Articles ────────────────────────────────────────────
|
||||
// Pocket-style read-it-later. Same sensitivity class as newsArticles —
|
||||
// reading behaviour is GDPR-relevant. originalUrl stays plaintext
|
||||
// (dedupe key, same rationale as newsArticles.originalUrl / links.originalUrl).
|
||||
// siteName plaintext for the "group by source" view.
|
||||
articles: entry<LocalArticle>(['title', 'excerpt', 'content', 'htmlContent', 'author', 'userNote']),
|
||||
// Highlights carry the marked text + an optional user note. The article
|
||||
// FK stays plaintext (indexed for range scans in the reader). Offsets +
|
||||
// color are structural. Context snippets are fragments of encrypted
|
||||
// content and are therefore themselves encrypted.
|
||||
articleHighlights: entry<LocalHighlight>(['text', 'note', 'contextBefore', 'contextAfter']),
|
||||
// articleTags ist NICHT registriert — pure FK-Junction (articleId, tagId),
|
||||
// zero user-typed content. Gleicher Pattern wie noteTags, eventTags,
|
||||
// contactTags, placeTags, manaLinks: Tag-Namen leben in globalTags und
|
||||
// haben dort ihre eigene Encryption-Policy. Eintrag in plaintext-allowlist.ts.
|
||||
```
|
||||
|
||||
## Routing
|
||||
|
||||
```
|
||||
apps/mana/apps/web/src/routes/(app)/articles/
|
||||
├── +page.svelte # ListView
|
||||
├── add/+page.svelte # AddUrlForm — paste URL → preview → save
|
||||
├── [id]/+page.svelte # DetailView (Reader + Highlights)
|
||||
└── highlights/+page.svelte # HighlightsView (Phase 2)
|
||||
```
|
||||
|
||||
Deep-Links:
|
||||
- `/articles?status=unread` — vorgefilterte Liste
|
||||
- `/articles?tag=<tagId>` — nach Tag gefiltert
|
||||
- `/articles/add?url=...` — aus externem Share-Target (M7) vorbefüllt
|
||||
|
||||
## UI-Konzept
|
||||
|
||||
### Landing (`/articles`)
|
||||
|
||||
- **Top-Bar:** Status-Filter-Segmented-Control (Alle | Ungelesen | In Arbeit | Favoriten | Archiv), Tag-Chips horizontal scrollbar, Sort (Neu gespeichert | Lesezeit | Titel).
|
||||
- **Liste:** Kachel- oder Zeilen-View (Toggle). Kachel zeigt Cover (16:9), Titel, Excerpt (2 Zeilen), Site-Name, Reading-Time, Status-Badge. Zeile kompakter.
|
||||
- **FAB:** „+" öffnet `/articles/add`.
|
||||
- **Empty-State:** „Noch nichts gespeichert" + CTA „Erste URL einfügen" (+ SceneScopeEmptyState wenn Scope aktiv — gleiches Pattern wie andere Module).
|
||||
|
||||
### AddUrlForm (`/articles/add`)
|
||||
|
||||
- URL-Input (groß, Autofocus).
|
||||
- „Vorschau abrufen" → Call auf `/api/v1/articles/extract/preview` → zeigt Titel, Excerpt, Cover, Lesezeit.
|
||||
- „Speichern" → Call auf `/extract/save` + `articlesStore.saveFromUrl(url)`.
|
||||
- Dedupe: beim Paste sofort `articleTable.where('originalUrl').equals(url).first()` — wenn bereits vorhanden, statt Save direkt auf bestehenden Artikel routen.
|
||||
- Optional im selben Dialog: Tags vor dem Speichern setzen, Notiz hinzufügen.
|
||||
|
||||
### DetailView (`/articles/[id]`)
|
||||
|
||||
- **Header:** Titel, Author, Site-Name, Published-Date, Wordcount/Lesezeit.
|
||||
- **Reader-Body:** rendert `htmlContent` mit sanitisierendem DOMPurify durch eine Reader-Typografie-Schale (Serif-Default, konfigurierbar Größe/Zeilenhöhe/Theme).
|
||||
- **Action-Bar (sticky):**
|
||||
- Als gelesen markieren / entmarkieren
|
||||
- Favorit-Toggle
|
||||
- Archivieren
|
||||
- Tags-Picker
|
||||
- „Original öffnen" (external link)
|
||||
- Notiz
|
||||
- Löschen
|
||||
- **Highlight-Layer:**
|
||||
- Bei Text-Selection erscheint `HighlightMenu` mit 4 Farben + Notiz-Feld.
|
||||
- Beim Save: `highlightsStore.addHighlight({ articleId, text, startOffset, endOffset, color, contextBefore, contextAfter })`.
|
||||
- Bestehende Highlights werden beim Render als gefärbte Spans überlagert (Plain-Text-Offset → DOM-Range-Resolver).
|
||||
- **Reading-Progress:** Scroll-Event setzt throttled `articlesStore.setProgress(id, progress)`; beim nächsten Öffnen springt der View auf die letzte Position zurück.
|
||||
- **Bottom-Drawer (Phase 2):** alle Highlights dieses Artikels in einer Liste, klickbar → springt zur Stelle.
|
||||
|
||||
### HighlightsView (`/articles/highlights`, Phase 2)
|
||||
|
||||
Sammelansicht: alle Highlights über alle Artikel chronologisch oder nach Artikel gruppiert, mit Notizen + Quell-Link zurück zum Artikel. Export als Markdown (Plain-Text-Export, kein Share).
|
||||
|
||||
## Registrierung (Checklist)
|
||||
|
||||
1. `apps/mana/apps/web/src/lib/modules/articles/module.config.ts` anlegen:
|
||||
```typescript
|
||||
export const articlesModuleConfig: ModuleConfig = {
|
||||
appId: 'articles',
|
||||
tables: [
|
||||
{ name: 'articles' },
|
||||
{ name: 'articleHighlights', syncName: 'highlights' },
|
||||
{ name: 'articleTags', syncName: 'tags' },
|
||||
],
|
||||
};
|
||||
```
|
||||
2. Config in `apps/mana/apps/web/src/lib/data/module-registry.ts` importieren + in `MODULE_CONFIGS` aufnehmen.
|
||||
3. Dexie-Schema-Migration: neue `db.version(N+1).stores({ articles: '...', articleHighlights: '...', articleTags: '...' })` + `.upgrade()`-Hook, der `newsArticles` mit `type='saved'` nach `articles` kopiert (siehe Migration unten).
|
||||
4. Encryption-Registry — drei Einträge (siehe oben). Unit-Test für Crypto-Roundtrip.
|
||||
5. Routes unter `(app)/articles/` anlegen.
|
||||
6. App-Registry-Eintrag in `packages/shared-branding/src/mana-apps.ts`:
|
||||
```typescript
|
||||
{
|
||||
id: 'articles',
|
||||
name: 'Artikel',
|
||||
description: { de: 'Später lesen', en: 'Read Later' },
|
||||
longDescription: {
|
||||
de: 'Speichere Web-Artikel und lies sie später offline — mit Highlights, Tags und Notizen.',
|
||||
en: 'Save web articles and read them offline later — with highlights, tags, and notes.',
|
||||
},
|
||||
icon: APP_ICONS.articles,
|
||||
color: '#ef4444', // oder ein anderes Rot/Orange, anti-News (Grün)
|
||||
status: 'development',
|
||||
requiredTier: 'guest',
|
||||
}
|
||||
```
|
||||
7. Icon in `packages/shared-branding/src/app-icons.ts` (SVG als Data-URL — Lesezeichen-Form o.ä.).
|
||||
8. API-Modul in `apps/api/src/modules/articles/routes.ts` + Mount unter `/api/v1/articles`.
|
||||
9. `docs/MODULE_REGISTRY.md` unter „Produktivität & Wissen" ergänzen.
|
||||
10. `docs/PORT_SCHEMA.md` prüfen — neuer Endpoint bekommt keinen neuen Port, läuft im bestehenden `apps/api`.
|
||||
11. Vitest-Tests:
|
||||
- Store-Mutationen (save, highlight, tag)
|
||||
- Encryption-Roundtrip (alle drei Tabellen)
|
||||
- Dedupe-Pfad in `saveFromUrl`
|
||||
- Offset-Mapping im HighlightLayer (unabhängig von DOM)
|
||||
12. Playwright-Happy-Path (M2 Ende): Artikel speichern → öffnen → Reader sichtbar → Highlight setzen → Tag vergeben → als gelesen markieren.
|
||||
|
||||
## Migration von `news`-saved-Rows
|
||||
|
||||
Einmaliger Upgrade-Hook in der Dexie-Migration, die `articles` einführt:
|
||||
|
||||
```typescript
|
||||
db.version(N+1).stores({ articles: '...', articleHighlights: '...', articleTags: '...' })
|
||||
.upgrade(async (tx) => {
|
||||
const saved = await tx.table('newsArticles').where('type').equals('saved').toArray();
|
||||
for (const old of saved) {
|
||||
await tx.table('articles').add({
|
||||
id: crypto.randomUUID(), // neue ID, alte bleibt verwaist im newsArticles für Sync-Sauberkeit
|
||||
originalUrl: old.originalUrl,
|
||||
title: old.title,
|
||||
excerpt: old.excerpt,
|
||||
content: old.content,
|
||||
htmlContent: old.htmlContent,
|
||||
author: old.author,
|
||||
siteName: old.siteName,
|
||||
imageUrl: old.imageUrl,
|
||||
wordCount: old.wordCount,
|
||||
readingTimeMinutes: old.readingTimeMinutes,
|
||||
publishedAt: old.publishedAt,
|
||||
status: old.isRead ? 'finished' : (old.isArchived ? 'archived' : 'unread'),
|
||||
readingProgress: 0,
|
||||
isFavorite: old.isFavorite ?? false,
|
||||
savedAt: old.createdAt,
|
||||
readAt: old.isRead ? old.updatedAt : null,
|
||||
tagIds: [],
|
||||
userNote: null,
|
||||
extractedVersion: 1,
|
||||
userId: old.userId,
|
||||
createdAt: old.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
// Alte Row soft-deleten, damit sie nicht mehr im /news/saved auftaucht
|
||||
// und der Sync-Engine den Delete propagiert:
|
||||
await tx.table('newsArticles').update(old.id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Hinweis Encryption:** Die `newsArticles`-Rows sind beim Upgrade-Hook-Lauf **noch verschlüsselt** (Dexie-Upgrade läuft unterhalb der Store-Abstraktion). Zwei Optionen:
|
||||
|
||||
- **A (bevorzugt):** Migration läuft nicht im `.upgrade()`, sondern als Boot-Task *nach* Crypto-Init — in `apps/mana/apps/web/src/lib/data/migrations/articles-from-news.ts` mit einer `_migrationFlags`-Dexie-Tabelle, die markiert, dass die Migration einmal lief. Dann ist `decryptRecords` verfügbar und die Daten wandern korrekt decrypted → re-encrypted unter den neuen Feld-Allowlists.
|
||||
- **B:** Migration bei der *Store-Ebene* — beim ersten Mount von `/articles` einmalig ausführen. Einfacher, aber User sieht beim ersten Öffnen eine kurze Ladephase.
|
||||
|
||||
Empfehlung: **A**. Entkoppelt Dexie-Version von der Crypto-abhängigen Daten-Bewegung; folgt demselben Muster wie die `companion` → `ai-agents`-Migration.
|
||||
|
||||
**Nach-Migration im `news`-Modul:**
|
||||
- `saveFromUrl` in `articles.svelte.ts` (news) entfernen.
|
||||
- `type: 'curated' | 'saved'` → `type: 'curated'` (Discriminator entfällt, da alle Rows curated sind).
|
||||
- Route `/news/add` → Redirect auf `/articles/add`.
|
||||
- Route `/news/saved` entfällt (oder redirectet auf `/articles?status=unread`).
|
||||
- AI-Tool `save_news_article` bleibt als **Alias** für `save_article` (ruft intern `articlesStore.saveFromUrl`). Begründung: bestehende Missionen/Workbench-Events in der DB beziehen sich auf den Namen — hartes Löschen würde historische Iterations brechen.
|
||||
|
||||
## AI-Integration
|
||||
|
||||
Tools in `apps/mana/apps/web/src/lib/modules/articles/tools.ts` + Katalog-Eintrag in `@mana/shared-ai/src/tools/schemas.ts` (Single Source of Truth — webapp + mana-ai leiten daraus ab):
|
||||
|
||||
| Tool | Policy | Beschreibung |
|
||||
|-------------------------|---------|----------------------------------------------------------------------|
|
||||
| `list_articles` | auto | Filter nach `status`/`tag`, read-only; für Recherche-Missionen. |
|
||||
| `save_article` | propose | URL → Readability-Extract → User bestätigt im Proposal-Dialog. |
|
||||
| `archive_article` | propose | Status → `archived`. |
|
||||
| `tag_article` | propose | Tag-ID(s) setzen. |
|
||||
| `add_article_highlight` | propose | Textausschnitt + optionale Notiz; User bestätigt Stelle + Farbe. |
|
||||
|
||||
Der Runner injiziert `articles` in der Pool-Filterung zusätzlich zu `news`/`news-research`, damit Missionen wie *„Speichere die drei meistzitierten Artikel zu Thema X"* nativ gehen.
|
||||
|
||||
`AiProposalInbox` wird im `/articles` Hauptview eingebettet (`<AiProposalInbox module="articles" />`) — gleiches Pattern wie `/todo`, `/calendar`, `/places`.
|
||||
|
||||
## Scene Scope
|
||||
|
||||
Standardpattern wie in library/notes: `scopeTagIds` auf der aktiven Scene filtert Artikel via `filterBySceneScopeBatch`. Wenn der Scope alles ausblendet → `<ScopeEmptyState label="Artikel" />` anzeigen.
|
||||
|
||||
## Cross-Modul-Hooks
|
||||
|
||||
- **Tags:** articles klinkt sich in das **globale Tag-System** ein (`globalTags` + `tagGroups` unter `appId: 'tags'`). Derselbe Tag-Pool wie notes/calendar/contacts/chat/picture/places/… — Umbenennen und Farbwechsel propagieren automatisch. Scene-Scope via `scopeTagIds` funktioniert sofort, ohne zusätzlichen Code (gleicher Tag-Raum).
|
||||
- **Notizen aus Highlights:** Später (Phase 3) Button „Highlight als Note speichern" → erzeugt `note` im notes-Modul mit Backlink.
|
||||
- **Goals:** „X Artikel pro Woche lesen" kann der goals-Modul über die `completedAt`-Äquivalent `readAt` abfragen (cross-module-mechanik wie bei library).
|
||||
|
||||
## Offene Fragen
|
||||
|
||||
- **Site-Favicon als Source-Indikator:** lohnt sich der Aufwand (Favicon fetch + cache)? Für M1/M2 weglassen, aus `siteName` Text-Badge rendern. Kommt ggf. in M6+.
|
||||
- **Kein Content-Refresh:** was, wenn ein Artikel aktualisiert wurde? Vorschlag: Button „neu extrahieren" im Detail-View, setzt `extractedVersion++`, Highlights bleiben per Kontext-Snippet re-anchored. Erste Version: keine automatische Aktualisierung.
|
||||
- **PDF/Mobilizer:** Paywall-Artikel → kein Extract. Erste Version: Fehlermeldung „nicht extrahierbar" + Link zum Original. Mercury-/archive.org-Fallback später.
|
||||
- **YouTube/Video:** URLs mit YouTube/Vimeo → out-of-scope oder als Special-Case mit Title+Description+Embed? **Vorschlag:** Für M1 kein Special-Case, `extractFromUrl` liefert `null` → Fehler. Wenn Bedarf, separater Handler in `shared-rss`.
|
||||
- **Share-Target Trigger:** PWA-Manifest braucht `share_target`-Eintrag (Web Share Target Level 2). Funktioniert nur für installierte PWAs auf Android/Chromium. Für iOS bleibt Bookmarklet.
|
||||
- **Encryption-Phase für Migrations-Pfad:** wenn der User zero-knowledge-Mode hat, ist der Crypto-State beim Boot erst verfügbar, sobald das Recovery-Code-Unlock passiert. Migration muss dahinter laufen — siehe DATA_LAYER_AUDIT.md §Encryption Rollout.
|
||||
|
||||
## Milestones
|
||||
|
||||
1. **M1 — Skelett**: types, collections, module.config, Registry-Einträge, Dexie-Migration (Tabellen anlegen, **noch keine** news-Migration), API-Modul leer, Routes mit Empty-State. App registry + Icon. *Ziel: `/articles` mountet, zeigt „Noch nichts gespeichert", nichts crasht, encryption-Audit grün.*
|
||||
2. **M2 — URL-Save + Reader**: AddUrlForm, `/api/v1/articles/extract/*`, `articlesStore.saveFromUrl`, ArticleCard-Liste, DetailView mit Reader-Typografie (Serif default + Size-Slider + Light/Dark/Sepia). *Ziel: manueller Workflow „URL einfügen → lesen" geht durchgängig, offline-reload funktioniert.*
|
||||
3. **M3 — Highlights**: HighlightLayer, HighlightMenu, `highlightsStore`, Offset-Resolver. *Ziel: Text markieren + Notiz anheften + beim Re-Open wieder sehen.*
|
||||
4. **M4 — Tags + Filter + Progress**: `articleTagOps` + TagPicker (re-use bestehender Komponenten aus notes/calendar wenn vorhanden, sonst minimal neu), Status-Filter-Chips in ListView, Reading-Progress-Scroll-Restore, Favorit-Toggle, Archivieren. *Ziel: Volle organisatorische UX steht.* Kleiner Scope als ursprünglich geplant — kein Tag-CRUD im Modul (gehört ins globale Tag-System).
|
||||
5. **M5 — Migration von news:type='saved'**: Boot-Migration nach Option A, News-Code-Deprecation (`saveFromUrl` raus, Route-Redirects, AI-Tool-Alias). *Ziel: Alle bestehenden saved-Artikel im neuen Modul, `/news/saved` leer/redirect.*
|
||||
6. **M6 — AI-Tools**: list/save/tag/highlight/archive Tools, Katalog-Eintrag, Policy, AiProposalInbox-Mount. *Ziel: Missionen können URLs speichern und taggen.*
|
||||
7. **M7 — Share-Target + Bookmarklet**: PWA-Manifest `share_target` + Bookmarklet-Snippet in Settings (`javascript:` → öffnet `/articles/add?url=...`). Offline-Queue für Share ohne Internet (`_pendingUrls`). *Ziel: „Seite im Browser → drei Clicks → in Mana gespeichert" geht.*
|
||||
8. **M8 — HighlightsView + Stats + Dashboard-Widget**: `/articles/highlights` Sammelansicht, Markdown-Export, `useStats()` (Artikel/Woche, gelesen/gespeichert, Lieblings-Sites), Dashboard-Widget „Ungelesene Artikel" im widget-grid. *Ziel: Modul steht auf Augenhöhe mit notes/library auf dem Dashboard.*
|
||||
|
||||
Phase-3-Kandidaten (kein fester Milestone):
|
||||
- Highlight → Note-Export mit Backlink
|
||||
- Full-Text-Search (sqlite-wasm oder Dexie-Minisearch)
|
||||
- Mercury/archive.org-Fallback für Paywalls
|
||||
- Goodreads-ähnlicher Jahresrückblick („Du hast 142 Artikel gelesen, 28 Stunden Lesezeit …")
|
||||
|
|
@ -231,6 +231,12 @@ export const APP_ICONS = {
|
|||
// gradient sits next to music/photos/picture in the Kreativität & Medien row.
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="lb" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#a855f7"/><stop offset="100%" style="stop-color:#d946ef"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#lb)"/><rect x="22" y="28" width="10" height="44" rx="2" fill="white" fill-opacity="0.95"/><rect x="34" y="24" width="10" height="48" rx="2" fill="white" fill-opacity="0.8"/><rect x="46" y="30" width="10" height="42" rx="2" fill="white" fill-opacity="0.95"/><rect x="58" y="28" width="22" height="44" rx="3" fill="white"/><rect x="62" y="34" width="4" height="4" fill="#a855f7"/><rect x="72" y="34" width="4" height="4" fill="#a855f7"/><rect x="62" y="44" width="4" height="4" fill="#a855f7"/><rect x="72" y="44" width="4" height="4" fill="#a855f7"/><rect x="62" y="54" width="4" height="4" fill="#a855f7"/><rect x="72" y="54" width="4" height="4" fill="#a855f7"/><rect x="62" y="64" width="4" height="4" fill="#a855f7"/><rect x="72" y="64" width="4" height="4" fill="#a855f7"/><rect x="20" y="74" width="62" height="4" rx="2" fill="white" fill-opacity="0.5"/></svg>`
|
||||
),
|
||||
articles: svgToDataUrl(
|
||||
// Bookmark ribbon tucked into a folded document corner — "Für später
|
||||
// gemerkt". Orange→amber gradient sets it apart from news (emerald)
|
||||
// and news-research (cyan) in the Wissen & Recherche row.
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="ar" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#f97316"/><stop offset="100%" style="stop-color:#f59e0b"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#ar)"/><path d="M28 22h30l18 18v38a4 4 0 0 1-4 4H28a4 4 0 0 1-4-4V26a4 4 0 0 1 4-4z" fill="white" fill-opacity="0.95"/><path d="M58 22v14a4 4 0 0 0 4 4h14" fill="none" stroke="#f97316" stroke-width="2" stroke-opacity="0.35"/><rect x="32" y="48" width="26" height="3" rx="1.5" fill="#f97316" fill-opacity="0.6"/><rect x="32" y="56" width="22" height="3" rx="1.5" fill="#f97316" fill-opacity="0.45"/><rect x="32" y="64" width="24" height="3" rx="1.5" fill="#f97316" fill-opacity="0.6"/><path d="M62 54v22l8-6 8 6V54a4 4 0 0 0-4-4h-8a4 4 0 0 0-4 4z" fill="#f97316"/></svg>`
|
||||
),
|
||||
invoices: svgToDataUrl(
|
||||
// Document with a QR-code corner (CH QR-Bill) + a diagonal amount line.
|
||||
// Emerald→teal sits next to finance green in the Arbeit & Finanzen row.
|
||||
|
|
|
|||
|
|
@ -1020,6 +1020,23 @@ export const MANA_APPS: ManaApp[] = [
|
|||
status: 'development',
|
||||
requiredTier: 'guest',
|
||||
},
|
||||
{
|
||||
id: 'articles',
|
||||
name: 'Artikel',
|
||||
description: {
|
||||
de: 'Später lesen — offline',
|
||||
en: 'Read later — offline',
|
||||
},
|
||||
longDescription: {
|
||||
de: 'Speichere Web-Artikel und lies sie offline im Reader — mit Highlights, Tags und Notizen. Ein Zuhause für alles, das du später in Ruhe lesen willst.',
|
||||
en: 'Save web articles and read them offline in a distraction-free reader — with highlights, tags and notes. A home for everything you want to read properly later.',
|
||||
},
|
||||
icon: APP_ICONS.articles,
|
||||
color: '#f97316',
|
||||
comingSoon: false,
|
||||
status: 'development',
|
||||
requiredTier: 'guest',
|
||||
},
|
||||
{
|
||||
id: 'broadcast',
|
||||
name: 'Broadcasts',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue