mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-19 10:21:24 +02:00
chore(mana): news aus unified-App entfernen
Some checks are pending
CD Mac Mini / Detect Changes (push) Waiting to run
CD Mac Mini / Deploy (push) Blocked by required conditions
CI / Build mana-sync (push) Blocked by required conditions
CI / Build mana-api-gateway (push) Blocked by required conditions
CI / Build mana-crawler (push) Blocked by required conditions
CI / Detect Changes (push) Waiting to run
CI / Validate (push) Waiting to run
CI / Build mana-search (push) Blocked by required conditions
Docker Validate / Validate Dockerfiles (push) Waiting to run
Docker Validate / Build calendar-web (push) Blocked by required conditions
Docker Validate / Build quotes-web (push) Blocked by required conditions
Docker Validate / Build todo-backend (push) Blocked by required conditions
Docker Validate / Build todo-web (push) Blocked by required conditions
Docker Validate / Build mana-auth (push) Blocked by required conditions
Docker Validate / Build mana-sync (push) Blocked by required conditions
Docker Validate / Build mana-media (push) Blocked by required conditions
Mirror to Forgejo / Push to Forgejo (push) Waiting to run
Some checks are pending
CD Mac Mini / Detect Changes (push) Waiting to run
CD Mac Mini / Deploy (push) Blocked by required conditions
CI / Build mana-sync (push) Blocked by required conditions
CI / Build mana-api-gateway (push) Blocked by required conditions
CI / Build mana-crawler (push) Blocked by required conditions
CI / Detect Changes (push) Waiting to run
CI / Validate (push) Waiting to run
CI / Build mana-search (push) Blocked by required conditions
Docker Validate / Validate Dockerfiles (push) Waiting to run
Docker Validate / Build calendar-web (push) Blocked by required conditions
Docker Validate / Build quotes-web (push) Blocked by required conditions
Docker Validate / Build todo-backend (push) Blocked by required conditions
Docker Validate / Build todo-web (push) Blocked by required conditions
Docker Validate / Build mana-auth (push) Blocked by required conditions
Docker Validate / Build mana-sync (push) Blocked by required conditions
Docker Validate / Build mana-media (push) Blocked by required conditions
Mirror to Forgejo / Push to Forgejo (push) Waiting to run
Reader-Surface ist nach Pageta (pageta.mana.how + pageta-api.mana.how)
umgezogen, das seit 2026-05-16 live ist und mehr Features bietet als
das alte managarten-news-Modul:
- Highlights (4 Farben, plain-text-offsets, Kontext)
- Reading-Progress + User-Note pro Artikel
- Bulk-Import (200 URLs/Job mit Worker)
- 5 MCP-Tools (save/list/archive/tag/highlight)
- Reading-Status-Enum (unread/reading/finished/archived) statt Boolean
Was Pageta NICHT hat: Categories mit Color+Icon — Pageta verwendet
freie String-Tags statt visuelle Folders. Bewusste Design-Entscheidung
in Pageta.
Daten-Migration: KEIN automatisches Skript. User mit gespeicherten
Artikeln im managarten-newsArticles müssen ihre Liste in Pageta neu
aufbauen (oder Bulk-Import via /api/v1/imports verwenden).
Gelöscht / abgebaut:
- Module: apps/mana/.../modules/news + Routen + Locales
- apps/articles/migrations/from-news.ts (one-off-Migration nach
articles-Modul, Sentinel-gated, abgeschlossen) + Call in
(app)/+layout.svelte
- apps/api/src/modules/news + MCP-Executor save_news_article
- shared-branding: APP_ICONS.news + MANA_APPS news-Entry
- shared-ai/tools/schemas save_news_article
- shared-types/spaces: 3 'news'-Einträge in Space-Modul-Listen
- Cross-Module: news-research/ListView + (app)/news-research/+page.svelte
hatten den preferencesStore + usePreferences vom news-Modul für
Custom-Feed-Pinning — Pin-UI entfernt (Custom-Feeds sind jetzt
Pageta-Verantwortung)
- Dashboard: 'news-unread' Widget + NewsUnreadWidget-Import
- Registries: app-registry/apps.ts (News registerApp + Newspaper icon +
Header), categories, help-content, module-registry, data/tools/init
- i18n: news in apps/{de,en,es,fr,it}.json
Was BLEIBT:
- `news-research` Modul + `apps/api/src/modules/news-research/` —
RSS-Discovery + Search-Funktion bleibt im managarten als
Recherche-Tool für andere Module
- `mana-news-pool` Plattform-Service (Code/mana/services/) — wird von
news-research + Pageta-Standalone konsumiert
- shared-ai `research_news` Tool
Dexie v65 Migration:
- droppt newsArticles, newsCategories, newsPreferences, newsReactions,
newsCachedFeed
mana-web svelte-check 0/0, snapshot test 10/10.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fc616688e3
commit
609f662538
54 changed files with 19 additions and 4766 deletions
|
|
@ -34,7 +34,6 @@ import { profileRoutes } from './modules/profile/routes';
|
|||
import { storageRoutes } from './modules/storage/routes';
|
||||
import { todoRoutes } from './modules/todo/routes';
|
||||
import { guidesRoutes } from './modules/guides/routes';
|
||||
import { newsRoutes } from './modules/news/routes';
|
||||
import { newsResearchRoutes } from './modules/news-research/routes';
|
||||
import { articlesRoutes } from './modules/articles/routes';
|
||||
import { startArticleImportWorker } from './modules/articles/import-worker';
|
||||
|
|
@ -130,7 +129,6 @@ app.route('/api/v1/profile', profileRoutes);
|
|||
app.route('/api/v1/storage', storageRoutes);
|
||||
app.route('/api/v1/todo', todoRoutes);
|
||||
app.route('/api/v1/guides', guidesRoutes);
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -616,33 +616,6 @@ register('log_habit', async (args, userId) => {
|
|||
return ok(`Habit geloggt.`);
|
||||
});
|
||||
|
||||
// ── News tools ────────────────────────────────────────────────
|
||||
|
||||
register('save_news_article', async (args, userId) => {
|
||||
const articleId = crypto.randomUUID();
|
||||
const now = nowIso();
|
||||
const data = {
|
||||
id: articleId,
|
||||
userId,
|
||||
url: args.url as string,
|
||||
title: (args.title as string) ?? '',
|
||||
summary: (args.summary as string) ?? '',
|
||||
savedAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await writeRecord(
|
||||
userId,
|
||||
'news',
|
||||
'savedArticles',
|
||||
articleId,
|
||||
'insert',
|
||||
data,
|
||||
fieldTs(Object.keys(data))
|
||||
);
|
||||
return ok(`Artikel gespeichert: "${args.title || args.url}"`, { id: articleId });
|
||||
});
|
||||
|
||||
// ── Entry point ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,94 +0,0 @@
|
|||
/**
|
||||
* News module — Reads the curated article pool + extracts ad-hoc URLs.
|
||||
*
|
||||
* Pool population: handled by the Plattform-Service `mana-news-pool`
|
||||
* (Port 3079, eigene DB `mana_news_pool`, Schema `pool.curated_articles`).
|
||||
* Cutover am 2026-05-17: ehemals direkter Raw-SQL-Read auf
|
||||
* `mana_platform.news.curated_articles` aus dem `news-ingester:3066`-
|
||||
* Container. Hier nur noch HTTP-Proxy auf den Plattform-Pool.
|
||||
*
|
||||
* Saved articles (die persönliche Reading-List eines Users) leben
|
||||
* weiterhin client-side in der IndexedDB der unified Mana-App und
|
||||
* syncen via mana-sync; dieses Modul sieht sie nicht.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { extractFromUrl } from '@mana/shared-rss';
|
||||
|
||||
const POOL_URL = process.env.MANA_NEWS_POOL_URL ?? 'http://mana-news-pool:3079';
|
||||
const POOL_KEY = process.env.MANA_SERVICE_KEY ?? '';
|
||||
|
||||
// ─── Routes ─────────────────────────────────────────────────
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
// ─── Feed (proxy on mana-news-pool) ────────────────────────
|
||||
//
|
||||
// Query params:
|
||||
// topics — comma-separated topic slugs (tech,wissenschaft,…)
|
||||
// lang — 'de' | 'en' | 'all' (default 'all')
|
||||
// since — ISO timestamp
|
||||
// limit — default 50, max 200
|
||||
// offset — default 0
|
||||
|
||||
routes.get('/feed', async (c) => {
|
||||
const passthrough = ['topics', 'lang', 'since', 'limit', 'offset'] as const;
|
||||
const url = new URL(`${POOL_URL}/feed`);
|
||||
for (const k of passthrough) {
|
||||
const v = c.req.query(k);
|
||||
if (v) url.searchParams.set(k, v);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { 'X-Service-Key': POOL_KEY },
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(`[news] pool ${url} → ${res.status}`);
|
||||
return c.json([] as Record<string, unknown>[]);
|
||||
}
|
||||
const data = (await res.json()) as Record<string, unknown>[];
|
||||
return c.json(data);
|
||||
} catch (err) {
|
||||
console.warn('[news] pool fetch failed', err);
|
||||
return c.json([] as Record<string, unknown>[]);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Extract (content extraction for user-pasted URLs) ─────
|
||||
|
||||
routes.post('/extract/preview', async (c) => {
|
||||
const { url } = await c.req.json<{ url: string }>();
|
||||
if (!url) return c.json({ error: 'URL is required' }, 400);
|
||||
|
||||
const article = await extractFromUrl(url);
|
||||
if (!article) return c.json({ error: 'Extraction failed' }, 502);
|
||||
return c.json(article);
|
||||
});
|
||||
|
||||
routes.post('/extract/save', async (c) => {
|
||||
const { url } = await c.req.json<{ url: string }>();
|
||||
if (!url) return c.json({ error: 'URL is required' }, 400);
|
||||
|
||||
const extracted = await extractFromUrl(url);
|
||||
if (!extracted) return c.json({ error: 'Extraction failed' }, 502);
|
||||
|
||||
return c.json({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'saved',
|
||||
sourceOrigin: 'user_saved',
|
||||
originalUrl: url,
|
||||
title: extracted.title,
|
||||
content: extracted.content,
|
||||
htmlContent: extracted.htmlContent,
|
||||
excerpt: extracted.excerpt,
|
||||
author: extracted.byline,
|
||||
siteName: extracted.siteName,
|
||||
wordCount: extracted.wordCount,
|
||||
readingTimeMinutes: extracted.readingTimeMinutes,
|
||||
isArchived: false,
|
||||
});
|
||||
});
|
||||
|
||||
export { routes as newsRoutes };
|
||||
|
|
@ -36,7 +36,6 @@ import {
|
|||
Calculator,
|
||||
Lightning,
|
||||
PencilRuler,
|
||||
Newspaper,
|
||||
Person,
|
||||
GenderFemale,
|
||||
CalendarStar,
|
||||
|
|
@ -96,7 +95,7 @@ import {
|
|||
// Daily-use: habits · notes · journal · myday · drink ·
|
||||
// mood · sleep · activity · times · finance
|
||||
// Knowledge: chat · kontext · cards · quiz · guides ·
|
||||
// news · news-research · research-lab · articles ·
|
||||
// news-research · research-lab · articles ·
|
||||
// library · writing · comic · presi
|
||||
// Body & life: body · meditate · stretch · period ·
|
||||
// dreams · firsts · lasts · habits · recipes
|
||||
|
|
@ -755,27 +754,6 @@ registerApp({
|
|||
paramKey: 'eventId',
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'news',
|
||||
name: 'News',
|
||||
color: '#10B981',
|
||||
icon: Newspaper,
|
||||
views: {
|
||||
list: { load: () => import('$lib/modules/news/ListView.svelte') },
|
||||
},
|
||||
contextMenuActions: [
|
||||
{
|
||||
id: 'open-feed',
|
||||
label: 'Feed öffnen',
|
||||
icon: Plus,
|
||||
action: () =>
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('mana:quick-action', { detail: { app: 'news', action: 'open' } })
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'news-research',
|
||||
name: 'News Research',
|
||||
|
|
|
|||
|
|
@ -816,21 +816,6 @@ export const MODULE_HELP: Record<string, ModuleHelp> = {
|
|||
],
|
||||
tips: ['Jedes Modul hat zusätzlich sein eigenes ?-Icon mit kontextueller Hilfe'],
|
||||
},
|
||||
news: {
|
||||
description:
|
||||
'Kuratierter News-Feed aus vertrauenswürdigen Quellen. 7 Themen (Tech, Wissenschaft, Weltgeschehen, Wirtschaft, Kultur, Gesundheit, Politik). Alle 15 Minuten aktualisiert.',
|
||||
features: [
|
||||
'Thema-Filter + Sprachauswahl (DE/EN)',
|
||||
'Eigene Abos über Custom-Feeds',
|
||||
'Artikel in Leseliste speichern (verschlüsselt)',
|
||||
'AI-Tool: `save_news_article`',
|
||||
'Schwester-Modul: /research-lab für tiefere Recherche',
|
||||
],
|
||||
tips: [
|
||||
'Ziehe einen Artikel auf Notizen um eine Zusammenfassung zu erstellen',
|
||||
'Für Nicht-RSS-Quellen: Research Lab mit Deep-Research nutzen',
|
||||
],
|
||||
},
|
||||
profile: {
|
||||
description:
|
||||
'Dein persönliches Profil — der Kontext-Doc, den alle AI-Agents als Basis nutzen. Wer bist du, was willst du, was sollte Mana über dich wissen.',
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import PresiDecksWidget from './widgets/PresiDecksWidget.svelte';
|
|||
import RecentContactsWidget from '$lib/modules/core/widgets/RecentContactsWidget.svelte';
|
||||
import ActiveTimerWidget from '$lib/modules/core/widgets/ActiveTimerWidget.svelte';
|
||||
import PeriodWidget from '$lib/modules/core/widgets/PeriodWidget.svelte';
|
||||
import NewsUnreadWidget from '$lib/modules/news/widgets/NewsUnreadWidget.svelte';
|
||||
import ArticlesUnreadWidget from '$lib/modules/articles/widgets/ArticlesUnreadWidget.svelte';
|
||||
import BodyStatsWidget from '$lib/modules/body/widgets/BodyStatsWidget.svelte';
|
||||
import InvoicesOpenWidget from '$lib/modules/invoices/widgets/InvoicesOpenWidget.svelte';
|
||||
|
|
@ -56,7 +55,6 @@ export const widgetComponents: Record<WidgetType, Component> = {
|
|||
'day-timeline': DayTimelineWidget,
|
||||
'activity-feed': ActivityFeedWidget,
|
||||
period: PeriodWidget,
|
||||
'news-unread': NewsUnreadWidget,
|
||||
'articles-unread': ArticlesUnreadWidget,
|
||||
'body-stats': BodyStatsWidget,
|
||||
'invoices-open': InvoicesOpenWidget,
|
||||
|
|
|
|||
|
|
@ -1575,6 +1575,22 @@ db.version(64).stores({
|
|||
memoSpaces: null,
|
||||
});
|
||||
|
||||
// v65 — News module retirement (2026-05-18).
|
||||
// News-Reader-Surface ist nach Pageta (pageta.mana.how) umgezogen,
|
||||
// das mit eigener Postgres-DB + eigenem Article-Store läuft. Der
|
||||
// kuratierte Pool selbst lebt im Plattform-Service `mana-news-pool`
|
||||
// und wird via news-research (in managarten) + Pageta-Reader (extern)
|
||||
// weiterhin genutzt.
|
||||
// dropped: newsArticles, newsCategories, newsPreferences, newsReactions,
|
||||
// newsCachedFeed (war NICHT synced, nur local-mirror).
|
||||
db.version(65).stores({
|
||||
newsArticles: null,
|
||||
newsCategories: null,
|
||||
newsPreferences: null,
|
||||
newsReactions: null,
|
||||
newsCachedFeed: null,
|
||||
});
|
||||
|
||||
// ─── 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
|
||||
|
|
|
|||
|
|
@ -57,8 +57,6 @@ const INTERNAL_TABLES = new Set([
|
|||
// which writes via its module's sync path — proposals themselves never
|
||||
// leave the device.
|
||||
'pendingProposals',
|
||||
// Local-only news feed cache.
|
||||
'newsCachedFeed',
|
||||
]);
|
||||
|
||||
// ─── Dexie tables that survive in the schema for backwards-compat with
|
||||
|
|
@ -232,7 +230,6 @@ describe('module-registry — snapshot', () => {
|
|||
finance: ['transactions', 'financeCategories', 'budgets'],
|
||||
places: ['places', 'locationLogs', 'placeTags'],
|
||||
playground: ['playgroundSnippets', 'playgroundConversations', 'playgroundMessages'],
|
||||
news: ['newsArticles', 'newsCategories', 'newsPreferences', 'newsReactions'],
|
||||
body: [
|
||||
'bodyExercises',
|
||||
'bodyRoutines',
|
||||
|
|
@ -315,10 +312,6 @@ describe('module-registry — snapshot', () => {
|
|||
playgroundSnippets: 'snippets',
|
||||
playgroundConversations: 'conversations',
|
||||
playgroundMessages: 'messages',
|
||||
newsArticles: 'articles',
|
||||
newsCategories: 'categories',
|
||||
newsPreferences: 'preferences',
|
||||
newsReactions: 'reactions',
|
||||
quizQuestions: 'questions',
|
||||
quizAttempts: 'attempts',
|
||||
articleHighlights: 'highlights',
|
||||
|
|
|
|||
|
|
@ -77,7 +77,6 @@ import { eventsModuleConfig } from '$lib/modules/events/module.config';
|
|||
import { financeModuleConfig } from '$lib/modules/finance/module.config';
|
||||
import { placesModuleConfig } from '$lib/modules/places/module.config';
|
||||
import { playgroundModuleConfig } from '$lib/modules/playground/module.config';
|
||||
import { newsModuleConfig } from '$lib/modules/news/module.config';
|
||||
import { bodyModuleConfig } from '$lib/modules/body/module.config';
|
||||
import { firstsModuleConfig } from '$lib/modules/firsts/module.config';
|
||||
import { lastsModuleConfig } from '$lib/modules/lasts/module.config';
|
||||
|
|
@ -133,7 +132,6 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
|
|||
financeModuleConfig,
|
||||
placesModuleConfig,
|
||||
playgroundModuleConfig,
|
||||
newsModuleConfig,
|
||||
bodyModuleConfig,
|
||||
firstsModuleConfig,
|
||||
lastsModuleConfig,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ import { firstsTools } from '$lib/modules/firsts/tools';
|
|||
import { lastsTools } from '$lib/modules/lasts/tools';
|
||||
import { guidesTools } from '$lib/modules/guides/tools';
|
||||
import { inventoryTools } from '$lib/modules/inventory/tools';
|
||||
import { newsTools } from '$lib/modules/news/tools';
|
||||
import { newsResearchTools } from '$lib/modules/news-research/tools';
|
||||
import { articlesTools } from '$lib/modules/articles/tools';
|
||||
import { recipesTools } from '$lib/modules/recipes/tools';
|
||||
|
|
@ -74,7 +73,6 @@ export function initTools(): void {
|
|||
registerTools(lastsTools);
|
||||
registerTools(guidesTools);
|
||||
registerTools(inventoryTools);
|
||||
registerTools(newsTools);
|
||||
registerTools(newsResearchTools);
|
||||
registerTools(articlesTools);
|
||||
registerTools(recipesTools);
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@
|
|||
"automations": "Automationen",
|
||||
"playground": "Playground",
|
||||
"kontext": "Web-Kontext",
|
||||
"news": "News",
|
||||
"news-research": "News-Recherche",
|
||||
"articles": "Artikel",
|
||||
"research-lab": "Recherche-Labor",
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@
|
|||
"automations": "Automations",
|
||||
"playground": "Playground",
|
||||
"kontext": "Web Context",
|
||||
"news": "News",
|
||||
"news-research": "News Research",
|
||||
"articles": "Articles",
|
||||
"research-lab": "Research Lab",
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@
|
|||
"automations": "Automatizaciones",
|
||||
"playground": "Playground",
|
||||
"kontext": "Contexto web",
|
||||
"news": "Noticias",
|
||||
"news-research": "Investigación de noticias",
|
||||
"articles": "Artículos",
|
||||
"research-lab": "Laboratorio de investigación",
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@
|
|||
"automations": "Automations",
|
||||
"playground": "Playground",
|
||||
"kontext": "Contexte web",
|
||||
"news": "Actualités",
|
||||
"news-research": "Recherche d'actus",
|
||||
"articles": "Articles",
|
||||
"research-lab": "Laboratoire de recherche",
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@
|
|||
"automations": "Automazioni",
|
||||
"playground": "Playground",
|
||||
"kontext": "Contesto Web",
|
||||
"news": "News",
|
||||
"news-research": "Ricerca News",
|
||||
"articles": "Articoli",
|
||||
"research-lab": "Laboratorio di ricerca",
|
||||
|
|
|
|||
|
|
@ -1,149 +0,0 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "News",
|
||||
"tagline": "Dein kuratierter Newsfeed"
|
||||
},
|
||||
"feed": {
|
||||
"title": "News",
|
||||
"articles": "{count} Artikel",
|
||||
"refresh": "Neu laden",
|
||||
"loading": "Lade Artikel…",
|
||||
"empty": "Keine neuen Artikel zu deinen Themen.",
|
||||
"emptyHint": "Probiere ↻ oder erweitere deine Themen.",
|
||||
"loadError": "Fehler beim Laden",
|
||||
"savedLink": "Gespeichert",
|
||||
"settingsLink": "Einstellungen",
|
||||
"openArticleAria": "Artikel öffnen",
|
||||
"savedBadgeTitle": "In deiner Leseliste",
|
||||
"savedBadgeText": "❤️ gespeichert",
|
||||
"readingTimeMin": "{n} min"
|
||||
},
|
||||
"reactions": {
|
||||
"interested": "Interessiert",
|
||||
"interestedSaved": "Gespeichert",
|
||||
"interestedTitle": "Speichern + mehr davon",
|
||||
"interestedSavedTitle": "Schon gespeichert — nochmal klicken bestätigt nur",
|
||||
"notInterested": "Nicht für mich",
|
||||
"notInterestedTitle": "Weniger davon",
|
||||
"blockSource": "Quelle ausblenden",
|
||||
"blockSourceLabel": "{source} ausblenden"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome": "Willkommen beim News Hub",
|
||||
"intro": "In drei Schritten baust du dir deinen persönlichen Newsfeed.",
|
||||
"stepTopics": "1. Themen",
|
||||
"stepLanguage": "2. Sprache",
|
||||
"stepSources": "3. Quellen",
|
||||
"topicsTitle": "Was interessiert dich?",
|
||||
"topicsHint": "Wähle mindestens zwei Themen.",
|
||||
"languageTitle": "In welchen Sprachen liest du?",
|
||||
"sourcesTitle": "Quellen aus deinen Themen",
|
||||
"sourcesHint": "Tippe eine Quelle an um sie auszublenden. Du kannst das jederzeit ändern.",
|
||||
"back": "Zurück",
|
||||
"next": "Weiter",
|
||||
"finish": "Fertig",
|
||||
"finishLoading": "Speichere…"
|
||||
},
|
||||
"reader": {
|
||||
"back": "Zurück",
|
||||
"smaller": "Kleiner",
|
||||
"larger": "Größer",
|
||||
"save": "Speichern",
|
||||
"loading": "Lade…",
|
||||
"notFound": "Artikel nicht gefunden.",
|
||||
"backToFeed": "Zurück zum Feed",
|
||||
"openOriginal": "Original öffnen"
|
||||
},
|
||||
"saved": {
|
||||
"title": "Gespeichert",
|
||||
"backToFeed": "Feed",
|
||||
"addUrl": "URL hinzufügen",
|
||||
"tabUnread": "Ungelesen",
|
||||
"tabFavorites": "Favoriten",
|
||||
"tabArchive": "Archiv",
|
||||
"emptyUnread": "Keine ungelesenen Artikel.",
|
||||
"emptyUnreadHint": "Reagiere im Feed mit „Interessiert\" um Artikel hier zu sammeln.",
|
||||
"emptyFavorites": "Noch keine Favoriten.",
|
||||
"emptyArchive": "Archiv ist leer.",
|
||||
"badgeOwn": "eigen",
|
||||
"actionFavorite": "Favorit",
|
||||
"actionArchive": "Archivieren",
|
||||
"actionUnarchive": "Wiederherstellen",
|
||||
"actionDelete": "Löschen",
|
||||
"actionCategory": "Kategorie",
|
||||
"categoryNone": "— Keine —"
|
||||
},
|
||||
"categories": {
|
||||
"all": "Alle",
|
||||
"manage": "Kategorien verwalten",
|
||||
"placeholder": "Neue Kategorie…",
|
||||
"add": "Hinzufügen",
|
||||
"empty": "Noch keine Kategorien. Erstelle eine oben.",
|
||||
"rename": "umbenennen",
|
||||
"delete": "löschen",
|
||||
"deleteConfirm": "Kategorie löschen? Artikel bleiben erhalten."
|
||||
},
|
||||
"add": {
|
||||
"title": "Artikel speichern",
|
||||
"hint": "Füge eine URL ein. Wir extrahieren den Volltext (Mozilla Readability) und legen ihn in deine verschlüsselte Leseliste.",
|
||||
"backLink": "Gespeichert",
|
||||
"placeholder": "https://…",
|
||||
"submit": "Speichern",
|
||||
"loading": "Lade…",
|
||||
"errorGeneric": "Speichern fehlgeschlagen"
|
||||
},
|
||||
"preferences": {
|
||||
"title": "News-Einstellungen",
|
||||
"page_title_html": "News-Einstellungen — Mana",
|
||||
"subtitle": "Themen · Sprachen · Gewichtungen",
|
||||
"backToFeed": "Feed",
|
||||
"topicsHeading": "Themen",
|
||||
"topicsHint": "Welche Themen sollen im Feed auftauchen?",
|
||||
"languagesHeading": "Sprachen",
|
||||
"sourcesHeading": "Quellen",
|
||||
"sourcesHint": "Du blockst aktuell {count} Quellen.",
|
||||
"sourcesHintHtml": "Du blockst aktuell <strong>{count}</strong> Quellen.",
|
||||
"sourcesLink": "Quellen verwalten",
|
||||
"sourcesLinkArrow": "Quellen verwalten →",
|
||||
"weightsHeading": "Gelernte Gewichtungen",
|
||||
"weightsHint": "Über Reaktionen lernt der Feed deine Vorlieben: {topics} Themen-Gewichte, {sources} Quellen-Gewichte.",
|
||||
"weightsReset": "Zurücksetzen",
|
||||
"weightsResetConfirm": "Alle gelernten Gewichtungen zurücksetzen?",
|
||||
"onboardingHeading": "Onboarding",
|
||||
"onboardingHint": "Themen, Sprachen und Quellen neu wählen.",
|
||||
"onboardingRerun": "Onboarding neu starten"
|
||||
},
|
||||
"sources": {
|
||||
"title": "Quellen",
|
||||
"backToPreferences": "Einstellungen",
|
||||
"hint": "{count} blockiert. Tippe auf eine Quelle um sie ein- oder auszublenden.",
|
||||
"blocked": "blockiert",
|
||||
"weightTooltip": "Gewicht: {weight}"
|
||||
},
|
||||
"topics": {
|
||||
"tech": "Tech",
|
||||
"wissenschaft": "Wissenschaft",
|
||||
"weltgeschehen": "Weltgeschehen",
|
||||
"wirtschaft": "Wirtschaft",
|
||||
"kultur": "Kultur",
|
||||
"gesundheit": "Gesundheit",
|
||||
"politik": "Politik"
|
||||
},
|
||||
"languages": {
|
||||
"de": "Deutsch",
|
||||
"en": "English"
|
||||
},
|
||||
"widget": {
|
||||
"title": "News",
|
||||
"empty": "Keine ungelesenen News.",
|
||||
"viewAll": "Alle ansehen"
|
||||
},
|
||||
"workbench": {
|
||||
"cta_title": "News Hub einrichten",
|
||||
"cta_hint": "Wähle Themen, Sprachen und Quellen — danach erscheinen hier deine Artikel.",
|
||||
"cta_action": "Jetzt einrichten",
|
||||
"err_short": "Fehler",
|
||||
"empty_short": "Keine neuen Artikel.",
|
||||
"open_aria": "Öffnen"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "News",
|
||||
"tagline": "Your curated news feed"
|
||||
},
|
||||
"feed": {
|
||||
"title": "News",
|
||||
"articles": "{count} articles",
|
||||
"refresh": "Refresh",
|
||||
"loading": "Loading articles…",
|
||||
"empty": "No new articles for your topics.",
|
||||
"emptyHint": "Try ↻ or pick more topics.",
|
||||
"loadError": "Loading failed",
|
||||
"savedLink": "Saved",
|
||||
"settingsLink": "Settings",
|
||||
"openArticleAria": "Open article",
|
||||
"savedBadgeTitle": "In your reading list",
|
||||
"savedBadgeText": "❤️ saved",
|
||||
"readingTimeMin": "{n} min"
|
||||
},
|
||||
"reactions": {
|
||||
"interested": "Interested",
|
||||
"interestedSaved": "Saved",
|
||||
"interestedTitle": "Save and see more like this",
|
||||
"interestedSavedTitle": "Already saved — clicking again only confirms",
|
||||
"notInterested": "Not for me",
|
||||
"notInterestedTitle": "Show less of this",
|
||||
"blockSource": "Hide source",
|
||||
"blockSourceLabel": "Hide {source}"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome": "Welcome to the News Hub",
|
||||
"intro": "Three steps and you'll have your personal news feed.",
|
||||
"stepTopics": "1. Topics",
|
||||
"stepLanguage": "2. Language",
|
||||
"stepSources": "3. Sources",
|
||||
"topicsTitle": "What are you interested in?",
|
||||
"topicsHint": "Pick at least two topics.",
|
||||
"languageTitle": "Which languages do you read?",
|
||||
"sourcesTitle": "Sources for your topics",
|
||||
"sourcesHint": "Tap a source to hide it. You can change this any time.",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"finish": "Done",
|
||||
"finishLoading": "Saving…"
|
||||
},
|
||||
"reader": {
|
||||
"back": "Back",
|
||||
"smaller": "Smaller",
|
||||
"larger": "Larger",
|
||||
"save": "Save",
|
||||
"loading": "Loading…",
|
||||
"notFound": "Article not found.",
|
||||
"backToFeed": "Back to feed",
|
||||
"openOriginal": "Open original"
|
||||
},
|
||||
"saved": {
|
||||
"title": "Saved",
|
||||
"backToFeed": "Feed",
|
||||
"addUrl": "Add URL",
|
||||
"tabUnread": "Unread",
|
||||
"tabFavorites": "Favorites",
|
||||
"tabArchive": "Archive",
|
||||
"emptyUnread": "No unread articles.",
|
||||
"emptyUnreadHint": "Tap \"Interested\" in the feed to collect articles here.",
|
||||
"emptyFavorites": "No favorites yet.",
|
||||
"emptyArchive": "Archive is empty.",
|
||||
"badgeOwn": "own",
|
||||
"actionFavorite": "Favorite",
|
||||
"actionArchive": "Archive",
|
||||
"actionUnarchive": "Restore",
|
||||
"actionDelete": "Delete",
|
||||
"actionCategory": "Category",
|
||||
"categoryNone": "— None —"
|
||||
},
|
||||
"categories": {
|
||||
"all": "All",
|
||||
"manage": "Manage categories",
|
||||
"placeholder": "New category…",
|
||||
"add": "Add",
|
||||
"empty": "No categories yet. Create one above.",
|
||||
"rename": "rename",
|
||||
"delete": "delete",
|
||||
"deleteConfirm": "Delete category? Articles are kept."
|
||||
},
|
||||
"add": {
|
||||
"title": "Save article",
|
||||
"hint": "Paste a URL. We'll extract the full text (Mozilla Readability) and store it in your encrypted reading list.",
|
||||
"backLink": "Saved",
|
||||
"placeholder": "https://…",
|
||||
"submit": "Save",
|
||||
"loading": "Loading…",
|
||||
"errorGeneric": "Save failed"
|
||||
},
|
||||
"preferences": {
|
||||
"title": "News settings",
|
||||
"page_title_html": "News settings — Mana",
|
||||
"subtitle": "Topics · Languages · Weights",
|
||||
"backToFeed": "Feed",
|
||||
"topicsHeading": "Topics",
|
||||
"topicsHint": "Which topics should appear in the feed?",
|
||||
"languagesHeading": "Languages",
|
||||
"sourcesHeading": "Sources",
|
||||
"sourcesHint": "You're currently blocking {count} sources.",
|
||||
"sourcesHintHtml": "You're currently blocking <strong>{count}</strong> sources.",
|
||||
"sourcesLink": "Manage sources",
|
||||
"sourcesLinkArrow": "Manage sources →",
|
||||
"weightsHeading": "Learned weights",
|
||||
"weightsHint": "From your reactions the feed learns your preferences: {topics} topic weights, {sources} source weights.",
|
||||
"weightsReset": "Reset",
|
||||
"weightsResetConfirm": "Reset all learned weights?",
|
||||
"onboardingHeading": "Onboarding",
|
||||
"onboardingHint": "Pick topics, languages and sources from scratch.",
|
||||
"onboardingRerun": "Restart onboarding"
|
||||
},
|
||||
"sources": {
|
||||
"title": "Sources",
|
||||
"backToPreferences": "Settings",
|
||||
"hint": "{count} blocked. Tap a source to toggle.",
|
||||
"blocked": "blocked",
|
||||
"weightTooltip": "Weight: {weight}"
|
||||
},
|
||||
"topics": {
|
||||
"tech": "Tech",
|
||||
"wissenschaft": "Science",
|
||||
"weltgeschehen": "World",
|
||||
"wirtschaft": "Business",
|
||||
"kultur": "Culture",
|
||||
"gesundheit": "Health",
|
||||
"politik": "Politics"
|
||||
},
|
||||
"languages": {
|
||||
"de": "German",
|
||||
"en": "English"
|
||||
},
|
||||
"widget": {
|
||||
"title": "News",
|
||||
"empty": "No unread news.",
|
||||
"viewAll": "View all"
|
||||
},
|
||||
"workbench": {
|
||||
"cta_title": "Set up the News Hub",
|
||||
"cta_hint": "Pick topics, languages and sources — after that your articles appear here.",
|
||||
"cta_action": "Set up now",
|
||||
"err_short": "Error",
|
||||
"empty_short": "No new articles.",
|
||||
"open_aria": "Open"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "News",
|
||||
"tagline": "Tu feed de noticias curado"
|
||||
},
|
||||
"feed": {
|
||||
"title": "Noticias",
|
||||
"articles": "{count} artículos",
|
||||
"refresh": "Recargar",
|
||||
"loading": "Cargando artículos…",
|
||||
"empty": "No hay artículos nuevos para tus temas.",
|
||||
"emptyHint": "Prueba ↻ o añade más temas.",
|
||||
"loadError": "Error al cargar",
|
||||
"savedLink": "Guardados",
|
||||
"settingsLink": "Ajustes",
|
||||
"openArticleAria": "Abrir artículo",
|
||||
"savedBadgeTitle": "En tu lista de lectura",
|
||||
"savedBadgeText": "❤️ guardado",
|
||||
"readingTimeMin": "{n} min"
|
||||
},
|
||||
"reactions": {
|
||||
"interested": "Me interesa",
|
||||
"interestedSaved": "Guardado",
|
||||
"interestedTitle": "Guardar y ver más como esto",
|
||||
"interestedSavedTitle": "Ya guardado — volver a hacer clic solo confirma",
|
||||
"notInterested": "No es para mí",
|
||||
"notInterestedTitle": "Mostrar menos de esto",
|
||||
"blockSource": "Ocultar fuente",
|
||||
"blockSourceLabel": "Ocultar {source}"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome": "Bienvenido al News Hub",
|
||||
"intro": "En tres pasos crearás tu feed personal.",
|
||||
"stepTopics": "1. Temas",
|
||||
"stepLanguage": "2. Idioma",
|
||||
"stepSources": "3. Fuentes",
|
||||
"topicsTitle": "¿Qué te interesa?",
|
||||
"topicsHint": "Elige al menos dos temas.",
|
||||
"languageTitle": "¿En qué idiomas lees?",
|
||||
"sourcesTitle": "Fuentes de tus temas",
|
||||
"sourcesHint": "Toca una fuente para ocultarla. Puedes cambiarlo cuando quieras.",
|
||||
"back": "Atrás",
|
||||
"next": "Siguiente",
|
||||
"finish": "Listo",
|
||||
"finishLoading": "Guardando…"
|
||||
},
|
||||
"reader": {
|
||||
"back": "Atrás",
|
||||
"smaller": "Menor",
|
||||
"larger": "Mayor",
|
||||
"save": "Guardar",
|
||||
"loading": "Cargando…",
|
||||
"notFound": "Artículo no encontrado.",
|
||||
"backToFeed": "Volver al feed",
|
||||
"openOriginal": "Abrir original"
|
||||
},
|
||||
"saved": {
|
||||
"title": "Guardados",
|
||||
"backToFeed": "Feed",
|
||||
"addUrl": "Añadir URL",
|
||||
"tabUnread": "No leídos",
|
||||
"tabFavorites": "Favoritos",
|
||||
"tabArchive": "Archivo",
|
||||
"emptyUnread": "No hay artículos sin leer.",
|
||||
"emptyUnreadHint": "Toca \"Me interesa\" en el feed para coleccionarlos aquí.",
|
||||
"emptyFavorites": "Aún no hay favoritos.",
|
||||
"emptyArchive": "El archivo está vacío.",
|
||||
"badgeOwn": "propio",
|
||||
"actionFavorite": "Favorito",
|
||||
"actionArchive": "Archivar",
|
||||
"actionUnarchive": "Restaurar",
|
||||
"actionDelete": "Eliminar",
|
||||
"actionCategory": "Categoría",
|
||||
"categoryNone": "— Ninguna —"
|
||||
},
|
||||
"categories": {
|
||||
"all": "Todos",
|
||||
"manage": "Gestionar categorías",
|
||||
"placeholder": "Nueva categoría…",
|
||||
"add": "Añadir",
|
||||
"empty": "Aún no hay categorías. Crea una arriba.",
|
||||
"rename": "renombrar",
|
||||
"delete": "eliminar",
|
||||
"deleteConfirm": "¿Eliminar la categoría? Los artículos se mantienen."
|
||||
},
|
||||
"add": {
|
||||
"title": "Guardar artículo",
|
||||
"hint": "Pega una URL. Extraemos el texto completo (Mozilla Readability) y lo guardamos en tu lista de lectura cifrada.",
|
||||
"backLink": "Guardados",
|
||||
"placeholder": "https://…",
|
||||
"submit": "Guardar",
|
||||
"loading": "Cargando…",
|
||||
"errorGeneric": "Error al guardar"
|
||||
},
|
||||
"preferences": {
|
||||
"title": "Ajustes de noticias",
|
||||
"page_title_html": "Ajustes de noticias — Mana",
|
||||
"subtitle": "Temas · Idiomas · Pesos",
|
||||
"backToFeed": "Feed",
|
||||
"topicsHeading": "Temas",
|
||||
"topicsHint": "¿Qué temas deben aparecer en el feed?",
|
||||
"languagesHeading": "Idiomas",
|
||||
"sourcesHeading": "Fuentes",
|
||||
"sourcesHint": "Estás bloqueando {count} fuentes.",
|
||||
"sourcesHintHtml": "Estás bloqueando <strong>{count}</strong> fuentes.",
|
||||
"sourcesLink": "Gestionar fuentes",
|
||||
"sourcesLinkArrow": "Gestionar fuentes →",
|
||||
"weightsHeading": "Pesos aprendidos",
|
||||
"weightsHint": "El feed aprende tus preferencias a partir de tus reacciones: {topics} pesos de temas, {sources} pesos de fuentes.",
|
||||
"weightsReset": "Restablecer",
|
||||
"weightsResetConfirm": "¿Restablecer todos los pesos aprendidos?",
|
||||
"onboardingHeading": "Onboarding",
|
||||
"onboardingHint": "Vuelve a elegir temas, idiomas y fuentes.",
|
||||
"onboardingRerun": "Reiniciar onboarding"
|
||||
},
|
||||
"sources": {
|
||||
"title": "Fuentes",
|
||||
"backToPreferences": "Ajustes",
|
||||
"hint": "{count} bloqueadas. Toca una fuente para alternar.",
|
||||
"blocked": "bloqueada",
|
||||
"weightTooltip": "Peso: {weight}"
|
||||
},
|
||||
"topics": {
|
||||
"tech": "Tecnología",
|
||||
"wissenschaft": "Ciencia",
|
||||
"weltgeschehen": "Mundo",
|
||||
"wirtschaft": "Economía",
|
||||
"kultur": "Cultura",
|
||||
"gesundheit": "Salud",
|
||||
"politik": "Política"
|
||||
},
|
||||
"languages": {
|
||||
"de": "Alemán",
|
||||
"en": "Inglés"
|
||||
},
|
||||
"widget": {
|
||||
"title": "Noticias",
|
||||
"empty": "Sin noticias por leer.",
|
||||
"viewAll": "Ver todo"
|
||||
},
|
||||
"workbench": {
|
||||
"cta_title": "Configurar el News Hub",
|
||||
"cta_hint": "Elige temas, idiomas y fuentes — luego tus artículos aparecerán aquí.",
|
||||
"cta_action": "Configurar ahora",
|
||||
"err_short": "Error",
|
||||
"empty_short": "No hay artículos nuevos.",
|
||||
"open_aria": "Abrir"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "News",
|
||||
"tagline": "Ton fil d'actualité personnalisé"
|
||||
},
|
||||
"feed": {
|
||||
"title": "Actualités",
|
||||
"articles": "{count} articles",
|
||||
"refresh": "Recharger",
|
||||
"loading": "Chargement des articles…",
|
||||
"empty": "Aucun nouvel article pour tes thèmes.",
|
||||
"emptyHint": "Essaie ↻ ou ajoute des thèmes.",
|
||||
"loadError": "Échec du chargement",
|
||||
"savedLink": "Enregistrés",
|
||||
"settingsLink": "Réglages",
|
||||
"openArticleAria": "Ouvrir l'article",
|
||||
"savedBadgeTitle": "Dans ta liste de lecture",
|
||||
"savedBadgeText": "❤️ enregistré",
|
||||
"readingTimeMin": "{n} min"
|
||||
},
|
||||
"reactions": {
|
||||
"interested": "Intéressant",
|
||||
"interestedSaved": "Enregistré",
|
||||
"interestedTitle": "Enregistrer et en voir plus",
|
||||
"interestedSavedTitle": "Déjà enregistré — recliquer ne fait que confirmer",
|
||||
"notInterested": "Pas pour moi",
|
||||
"notInterestedTitle": "Moins de ce genre",
|
||||
"blockSource": "Masquer la source",
|
||||
"blockSourceLabel": "Masquer {source}"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome": "Bienvenue dans le News Hub",
|
||||
"intro": "Trois étapes pour bâtir ton fil personnel.",
|
||||
"stepTopics": "1. Thèmes",
|
||||
"stepLanguage": "2. Langue",
|
||||
"stepSources": "3. Sources",
|
||||
"topicsTitle": "Qu'est-ce qui t'intéresse ?",
|
||||
"topicsHint": "Choisis au moins deux thèmes.",
|
||||
"languageTitle": "Dans quelles langues lis-tu ?",
|
||||
"sourcesTitle": "Sources de tes thèmes",
|
||||
"sourcesHint": "Touche une source pour la masquer. Tu peux changer à tout moment.",
|
||||
"back": "Retour",
|
||||
"next": "Suivant",
|
||||
"finish": "Terminé",
|
||||
"finishLoading": "Enregistrement…"
|
||||
},
|
||||
"reader": {
|
||||
"back": "Retour",
|
||||
"smaller": "Plus petit",
|
||||
"larger": "Plus grand",
|
||||
"save": "Enregistrer",
|
||||
"loading": "Chargement…",
|
||||
"notFound": "Article introuvable.",
|
||||
"backToFeed": "Retour au fil",
|
||||
"openOriginal": "Ouvrir l'original"
|
||||
},
|
||||
"saved": {
|
||||
"title": "Enregistrés",
|
||||
"backToFeed": "Fil",
|
||||
"addUrl": "Ajouter une URL",
|
||||
"tabUnread": "Non lus",
|
||||
"tabFavorites": "Favoris",
|
||||
"tabArchive": "Archives",
|
||||
"emptyUnread": "Aucun article non lu.",
|
||||
"emptyUnreadHint": "Touche « Intéressant » dans le fil pour collecter les articles ici.",
|
||||
"emptyFavorites": "Aucun favori pour l'instant.",
|
||||
"emptyArchive": "Les archives sont vides.",
|
||||
"badgeOwn": "perso",
|
||||
"actionFavorite": "Favori",
|
||||
"actionArchive": "Archiver",
|
||||
"actionUnarchive": "Restaurer",
|
||||
"actionDelete": "Supprimer",
|
||||
"actionCategory": "Catégorie",
|
||||
"categoryNone": "— Aucune —"
|
||||
},
|
||||
"categories": {
|
||||
"all": "Tous",
|
||||
"manage": "Gérer les catégories",
|
||||
"placeholder": "Nouvelle catégorie…",
|
||||
"add": "Ajouter",
|
||||
"empty": "Aucune catégorie. Crées-en une au-dessus.",
|
||||
"rename": "renommer",
|
||||
"delete": "supprimer",
|
||||
"deleteConfirm": "Supprimer la catégorie ? Les articles sont conservés."
|
||||
},
|
||||
"add": {
|
||||
"title": "Enregistrer un article",
|
||||
"hint": "Colle une URL. Nous extrayons le texte complet (Mozilla Readability) et l'ajoutons à ta liste de lecture chiffrée.",
|
||||
"backLink": "Enregistrés",
|
||||
"placeholder": "https://…",
|
||||
"submit": "Enregistrer",
|
||||
"loading": "Chargement…",
|
||||
"errorGeneric": "Échec de l'enregistrement"
|
||||
},
|
||||
"preferences": {
|
||||
"title": "Réglages des actualités",
|
||||
"page_title_html": "Réglages des actualités — Mana",
|
||||
"subtitle": "Thèmes · Langues · Pondérations",
|
||||
"backToFeed": "Fil",
|
||||
"topicsHeading": "Thèmes",
|
||||
"topicsHint": "Quels thèmes doivent apparaître dans le fil ?",
|
||||
"languagesHeading": "Langues",
|
||||
"sourcesHeading": "Sources",
|
||||
"sourcesHint": "Tu bloques actuellement {count} sources.",
|
||||
"sourcesHintHtml": "Tu bloques actuellement <strong>{count}</strong> sources.",
|
||||
"sourcesLink": "Gérer les sources",
|
||||
"sourcesLinkArrow": "Gérer les sources →",
|
||||
"weightsHeading": "Pondérations apprises",
|
||||
"weightsHint": "Le fil apprend tes préférences via tes réactions : {topics} pondérations de thèmes, {sources} pondérations de sources.",
|
||||
"weightsReset": "Réinitialiser",
|
||||
"weightsResetConfirm": "Réinitialiser toutes les pondérations apprises ?",
|
||||
"onboardingHeading": "Onboarding",
|
||||
"onboardingHint": "Re-choisis thèmes, langues et sources.",
|
||||
"onboardingRerun": "Recommencer l'onboarding"
|
||||
},
|
||||
"sources": {
|
||||
"title": "Sources",
|
||||
"backToPreferences": "Réglages",
|
||||
"hint": "{count} bloquées. Touche une source pour basculer.",
|
||||
"blocked": "bloquée",
|
||||
"weightTooltip": "Poids : {weight}"
|
||||
},
|
||||
"topics": {
|
||||
"tech": "Tech",
|
||||
"wissenschaft": "Sciences",
|
||||
"weltgeschehen": "Monde",
|
||||
"wirtschaft": "Économie",
|
||||
"kultur": "Culture",
|
||||
"gesundheit": "Santé",
|
||||
"politik": "Politique"
|
||||
},
|
||||
"languages": {
|
||||
"de": "Allemand",
|
||||
"en": "Anglais"
|
||||
},
|
||||
"widget": {
|
||||
"title": "Actualités",
|
||||
"empty": "Aucune actualité non lue.",
|
||||
"viewAll": "Tout voir"
|
||||
},
|
||||
"workbench": {
|
||||
"cta_title": "Configurer le News Hub",
|
||||
"cta_hint": "Choisis thèmes, langues et sources — ensuite tes articles apparaîtront ici.",
|
||||
"cta_action": "Configurer maintenant",
|
||||
"err_short": "Erreur",
|
||||
"empty_short": "Aucun nouvel article.",
|
||||
"open_aria": "Ouvrir"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "News",
|
||||
"tagline": "Il tuo feed di notizie curato"
|
||||
},
|
||||
"feed": {
|
||||
"title": "Notizie",
|
||||
"articles": "{count} articoli",
|
||||
"refresh": "Ricarica",
|
||||
"loading": "Caricamento articoli…",
|
||||
"empty": "Nessun nuovo articolo per i tuoi temi.",
|
||||
"emptyHint": "Prova ↻ o aggiungi temi.",
|
||||
"loadError": "Errore di caricamento",
|
||||
"savedLink": "Salvati",
|
||||
"settingsLink": "Impostazioni",
|
||||
"openArticleAria": "Apri articolo",
|
||||
"savedBadgeTitle": "Nella tua lista di lettura",
|
||||
"savedBadgeText": "❤️ salvato",
|
||||
"readingTimeMin": "{n} min"
|
||||
},
|
||||
"reactions": {
|
||||
"interested": "Mi interessa",
|
||||
"interestedSaved": "Salvato",
|
||||
"interestedTitle": "Salva e mostra di più di questo",
|
||||
"interestedSavedTitle": "Già salvato — un altro click conferma soltanto",
|
||||
"notInterested": "Non per me",
|
||||
"notInterestedTitle": "Mostra di meno",
|
||||
"blockSource": "Nascondi fonte",
|
||||
"blockSourceLabel": "Nascondi {source}"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome": "Benvenuto nel News Hub",
|
||||
"intro": "In tre passi crei il tuo feed personale.",
|
||||
"stepTopics": "1. Temi",
|
||||
"stepLanguage": "2. Lingua",
|
||||
"stepSources": "3. Fonti",
|
||||
"topicsTitle": "Cosa ti interessa?",
|
||||
"topicsHint": "Scegli almeno due temi.",
|
||||
"languageTitle": "In quali lingue leggi?",
|
||||
"sourcesTitle": "Fonti dei tuoi temi",
|
||||
"sourcesHint": "Tocca una fonte per nasconderla. Puoi cambiare in qualsiasi momento.",
|
||||
"back": "Indietro",
|
||||
"next": "Avanti",
|
||||
"finish": "Fatto",
|
||||
"finishLoading": "Salvataggio…"
|
||||
},
|
||||
"reader": {
|
||||
"back": "Indietro",
|
||||
"smaller": "Più piccolo",
|
||||
"larger": "Più grande",
|
||||
"save": "Salva",
|
||||
"loading": "Caricamento…",
|
||||
"notFound": "Articolo non trovato.",
|
||||
"backToFeed": "Torna al feed",
|
||||
"openOriginal": "Apri originale"
|
||||
},
|
||||
"saved": {
|
||||
"title": "Salvati",
|
||||
"backToFeed": "Feed",
|
||||
"addUrl": "Aggiungi URL",
|
||||
"tabUnread": "Da leggere",
|
||||
"tabFavorites": "Preferiti",
|
||||
"tabArchive": "Archivio",
|
||||
"emptyUnread": "Nessun articolo da leggere.",
|
||||
"emptyUnreadHint": "Tocca \"Mi interessa\" nel feed per raccogliere articoli qui.",
|
||||
"emptyFavorites": "Ancora nessun preferito.",
|
||||
"emptyArchive": "L'archivio è vuoto.",
|
||||
"badgeOwn": "personale",
|
||||
"actionFavorite": "Preferito",
|
||||
"actionArchive": "Archivia",
|
||||
"actionUnarchive": "Ripristina",
|
||||
"actionDelete": "Elimina",
|
||||
"actionCategory": "Categoria",
|
||||
"categoryNone": "— Nessuna —"
|
||||
},
|
||||
"categories": {
|
||||
"all": "Tutti",
|
||||
"manage": "Gestisci categorie",
|
||||
"placeholder": "Nuova categoria…",
|
||||
"add": "Aggiungi",
|
||||
"empty": "Nessuna categoria. Creane una sopra.",
|
||||
"rename": "rinomina",
|
||||
"delete": "elimina",
|
||||
"deleteConfirm": "Eliminare la categoria? Gli articoli vengono mantenuti."
|
||||
},
|
||||
"add": {
|
||||
"title": "Salva articolo",
|
||||
"hint": "Incolla un URL. Estraiamo il testo completo (Mozilla Readability) e lo salviamo nella tua lista di lettura cifrata.",
|
||||
"backLink": "Salvati",
|
||||
"placeholder": "https://…",
|
||||
"submit": "Salva",
|
||||
"loading": "Caricamento…",
|
||||
"errorGeneric": "Salvataggio fallito"
|
||||
},
|
||||
"preferences": {
|
||||
"title": "Impostazioni notizie",
|
||||
"page_title_html": "Impostazioni notizie — Mana",
|
||||
"subtitle": "Temi · Lingue · Pesi",
|
||||
"backToFeed": "Feed",
|
||||
"topicsHeading": "Temi",
|
||||
"topicsHint": "Quali temi devono apparire nel feed?",
|
||||
"languagesHeading": "Lingue",
|
||||
"sourcesHeading": "Fonti",
|
||||
"sourcesHint": "Stai bloccando {count} fonti.",
|
||||
"sourcesHintHtml": "Stai bloccando <strong>{count}</strong> fonti.",
|
||||
"sourcesLink": "Gestisci fonti",
|
||||
"sourcesLinkArrow": "Gestisci fonti →",
|
||||
"weightsHeading": "Pesi appresi",
|
||||
"weightsHint": "Dalle tue reazioni il feed impara le tue preferenze: {topics} pesi tema, {sources} pesi fonte.",
|
||||
"weightsReset": "Reimposta",
|
||||
"weightsResetConfirm": "Reimpostare tutti i pesi appresi?",
|
||||
"onboardingHeading": "Onboarding",
|
||||
"onboardingHint": "Riscegli temi, lingue e fonti.",
|
||||
"onboardingRerun": "Riavvia onboarding"
|
||||
},
|
||||
"sources": {
|
||||
"title": "Fonti",
|
||||
"backToPreferences": "Impostazioni",
|
||||
"hint": "{count} bloccate. Tocca una fonte per cambiare stato.",
|
||||
"blocked": "bloccata",
|
||||
"weightTooltip": "Peso: {weight}"
|
||||
},
|
||||
"topics": {
|
||||
"tech": "Tech",
|
||||
"wissenschaft": "Scienza",
|
||||
"weltgeschehen": "Mondo",
|
||||
"wirtschaft": "Economia",
|
||||
"kultur": "Cultura",
|
||||
"gesundheit": "Salute",
|
||||
"politik": "Politica"
|
||||
},
|
||||
"languages": {
|
||||
"de": "Tedesco",
|
||||
"en": "Inglese"
|
||||
},
|
||||
"widget": {
|
||||
"title": "Notizie",
|
||||
"empty": "Nessuna notizia da leggere.",
|
||||
"viewAll": "Vedi tutte"
|
||||
},
|
||||
"workbench": {
|
||||
"cta_title": "Configura il News Hub",
|
||||
"cta_hint": "Scegli temi, lingue e fonti — poi i tuoi articoli appariranno qui.",
|
||||
"cta_action": "Configura ora",
|
||||
"err_short": "Errore",
|
||||
"empty_short": "Nessun nuovo articolo.",
|
||||
"open_aria": "Apri"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
/**
|
||||
* One-off migration: move `newsArticles` with `type='saved'` into the
|
||||
* new `articles` module.
|
||||
*
|
||||
* Runs at app-shell boot (from routes/(app)/+layout.svelte) rather than
|
||||
* inside the Dexie `.upgrade()` hook because we need the encryption
|
||||
* layer initialised: the source rows are encrypted under the
|
||||
* `newsArticles` field allowlist, the target rows need to be
|
||||
* re-encrypted under the `articles` allowlist, and both roundtrips
|
||||
* require Web Crypto + the master key — which the Dexie upgrade path
|
||||
* runs before.
|
||||
*
|
||||
* Idempotent: a localStorage sentinel prevents re-runs per device.
|
||||
* The original rows are soft-deleted (deletedAt stamped) so the sync
|
||||
* layer propagates the removal to the server and to other devices.
|
||||
*
|
||||
* Migration mapping:
|
||||
* newsArticles.isArchived = true → articles.status = 'archived'
|
||||
* newsArticles.isRead = true → articles.status = 'finished'
|
||||
* otherwise → articles.status = 'unread'
|
||||
*
|
||||
* isFavorite, createdAt, userId carry across. `sourceSlug` /
|
||||
* `sourceCuratedId` / `categoryId` don't have a counterpart on
|
||||
* articles (they're news-feed-specific) and are dropped — the user's
|
||||
* reading-list view doesn't depend on them.
|
||||
*/
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import { encryptRecord, decryptRecords } from '$lib/data/crypto';
|
||||
import { hasAnyEncryption } from '$lib/data/crypto/registry';
|
||||
import type { LocalArticle as NewLocalArticle, ArticleStatus } from '../types';
|
||||
|
||||
const SENTINEL_KEY = 'mana:articles:from-news-migration:v1';
|
||||
|
||||
// Shape of the source rows we care about. Kept narrow so the migration
|
||||
// stays decoupled from the news module's evolving type file.
|
||||
interface LegacyNewsArticle {
|
||||
id: string;
|
||||
type: 'curated' | 'saved';
|
||||
originalUrl: string;
|
||||
title: string;
|
||||
excerpt: string | null;
|
||||
content: string;
|
||||
htmlContent: string | null;
|
||||
author: string | null;
|
||||
siteName: string | null;
|
||||
imageUrl: string | null;
|
||||
wordCount: number | null;
|
||||
readingTimeMinutes: number | null;
|
||||
publishedAt: string | null;
|
||||
isArchived?: boolean;
|
||||
isRead?: boolean;
|
||||
isFavorite?: boolean;
|
||||
userId?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
deletedAt?: string | null;
|
||||
}
|
||||
|
||||
function statusFor(row: LegacyNewsArticle): ArticleStatus {
|
||||
if (row.isArchived) return 'archived';
|
||||
if (row.isRead) return 'finished';
|
||||
return 'unread';
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the migration once per device. Returns the number of rows moved.
|
||||
* Fire-and-forget from app boot; errors are logged but never thrown so
|
||||
* a single broken row never blocks the rest of the app from starting.
|
||||
*/
|
||||
export async function runArticlesFromNewsMigration(): Promise<number> {
|
||||
if (typeof window === 'undefined') return 0;
|
||||
if (window.localStorage.getItem(SENTINEL_KEY)) return 0;
|
||||
|
||||
// The migration requires the crypto layer to be live. If the app is
|
||||
// running entirely plaintext (Phase 1 bootstrap or a test harness),
|
||||
// decryptRecords is a pass-through so this still works — we check
|
||||
// anyway as a defensive gate and bail if the registry isn't ready.
|
||||
try {
|
||||
// Access the flag so linters don't flag the import as unused when
|
||||
// someone later decides the gate isn't worth keeping. The call is
|
||||
// cheap either way.
|
||||
hasAnyEncryption();
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const newsTable = db.table<LegacyNewsArticle>('newsArticles');
|
||||
const articlesTable = db.table<NewLocalArticle>('articles');
|
||||
|
||||
const candidates = await newsTable.where('type').equals('saved').toArray();
|
||||
const visible = candidates.filter((row) => !row.deletedAt);
|
||||
if (visible.length === 0) {
|
||||
window.localStorage.setItem(SENTINEL_KEY, new Date().toISOString());
|
||||
return 0;
|
||||
}
|
||||
|
||||
const decrypted = (await decryptRecords(
|
||||
'newsArticles',
|
||||
visible as unknown as Record<string, unknown>[]
|
||||
)) as unknown as LegacyNewsArticle[];
|
||||
|
||||
const now = new Date().toISOString();
|
||||
let moved = 0;
|
||||
|
||||
// Separate transactions: one write batch per row with its own
|
||||
// encryption roundtrip, so a single bad row doesn't lose the
|
||||
// batch. Dexie auto-batches the internal index updates either way.
|
||||
for (const row of decrypted) {
|
||||
try {
|
||||
const newRow: NewLocalArticle = {
|
||||
id: crypto.randomUUID(),
|
||||
originalUrl: row.originalUrl,
|
||||
title: row.title,
|
||||
excerpt: row.excerpt,
|
||||
content: row.content,
|
||||
htmlContent: row.htmlContent,
|
||||
author: row.author,
|
||||
siteName: row.siteName,
|
||||
imageUrl: row.imageUrl,
|
||||
wordCount: row.wordCount,
|
||||
readingTimeMinutes: row.readingTimeMinutes,
|
||||
publishedAt: row.publishedAt,
|
||||
status: statusFor(row),
|
||||
readingProgress: 0,
|
||||
isFavorite: row.isFavorite ?? false,
|
||||
savedAt: row.createdAt ?? now,
|
||||
readAt: row.isRead ? (row.updatedAt ?? now) : null,
|
||||
userNote: null,
|
||||
extractedVersion: 1,
|
||||
// userId is stamped by the Dexie creating-hook from the active
|
||||
// session — don't set it manually, let the hook do its job.
|
||||
};
|
||||
await encryptRecord('articles', newRow);
|
||||
await articlesTable.add(newRow);
|
||||
// Soft-delete the source so the sync engine removes it from
|
||||
// the server + other devices. Keep it in the local table so
|
||||
// if someone later rolls back the migration they can still
|
||||
// see what was there.
|
||||
await newsTable.update(row.id, { deletedAt: now });
|
||||
moved++;
|
||||
} catch (rowErr) {
|
||||
console.warn(`[articles/from-news] skipping row ${row.id} — ${(rowErr as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
window.localStorage.setItem(SENTINEL_KEY, now);
|
||||
if (moved > 0) {
|
||||
console.info(`[articles/from-news] migrated ${moved} saved article(s) into /articles`);
|
||||
}
|
||||
return moved;
|
||||
} catch (err) {
|
||||
console.error('[articles/from-news] migration failed:', err);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear the sentinel so the next boot re-runs. Test / recovery helper only. */
|
||||
export function resetArticlesFromNewsSentinel(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.removeItem(SENTINEL_KEY);
|
||||
}
|
||||
}
|
||||
|
|
@ -14,14 +14,10 @@
|
|||
import type { ViewProps } from '$lib/app-registry';
|
||||
import { researchSessionStore } from '$lib/modules/news-research/stores/session.svelte';
|
||||
import { articlesStore } from '$lib/modules/articles/stores/articles.svelte';
|
||||
import { preferencesStore } from '$lib/modules/news/stores/preferences.svelte';
|
||||
import { usePreferences } from '$lib/modules/news/queries';
|
||||
|
||||
const {}: ViewProps = $props();
|
||||
|
||||
const store = researchSessionStore;
|
||||
const prefs$ = usePreferences();
|
||||
const pinnedUrls = $derived(new Set((prefs$.value?.customFeeds ?? []).map((f) => f.url)));
|
||||
|
||||
let mode = $state<'query' | 'site'>('query');
|
||||
let query = $state('');
|
||||
|
|
@ -58,15 +54,6 @@
|
|||
feedsOpen = false;
|
||||
}
|
||||
|
||||
async function togglePin(feed: { url: string; title: string | null }) {
|
||||
if (pinnedUrls.has(feed.url)) {
|
||||
const existing = (prefs$.value?.customFeeds ?? []).find((f) => f.url === feed.url);
|
||||
if (existing) await preferencesStore.unpinCustomFeed(existing.id);
|
||||
} else {
|
||||
await preferencesStore.pinCustomFeed({ url: feed.url, title: feed.title ?? feed.url });
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave(articleUrl: string) {
|
||||
savingUrl = articleUrl;
|
||||
saveError = null;
|
||||
|
|
@ -157,15 +144,6 @@
|
|||
/>
|
||||
<span class="ft">{feed.title ?? feed.url}</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="pin"
|
||||
class:pinned={pinnedUrls.has(feed.url)}
|
||||
onclick={() => togglePin(feed)}
|
||||
title={pinnedUrls.has(feed.url) ? 'Abo entfernen' : 'Abonnieren'}
|
||||
>
|
||||
{pinnedUrls.has(feed.url) ? '★' : '☆'}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
|
@ -356,17 +334,6 @@
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pin {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
.pin.pinned {
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
.results-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
|
|||
|
|
@ -1,369 +0,0 @@
|
|||
<!--
|
||||
News — Workbench ListView.
|
||||
|
||||
This is the version that renders inside an AppPage carousel slot, not
|
||||
the dedicated /news route. Two important differences from the route:
|
||||
|
||||
1. We boot the feed-cache poll loop here too — a user might add the
|
||||
News card to a workbench scene without ever opening the /news route,
|
||||
and we don't want them to stare at an empty card.
|
||||
2. The onboarding wizard lives only on the /news route. Inside the
|
||||
compact workbench frame there's no room for a 3-step picker, so
|
||||
un-onboarded users get a CTA card pointing them at /news.
|
||||
|
||||
Header is intentionally bare — the workbench AppPage already supplies
|
||||
the title bar and close/move/minimize controls.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { ViewProps } from '$lib/app-registry';
|
||||
import {
|
||||
usePreferences,
|
||||
useCachedFeed,
|
||||
useReactions,
|
||||
formatRelativeTime,
|
||||
} from '$lib/modules/news/queries';
|
||||
import { rankFeed, buildReactionSets } from '$lib/modules/news/feed-engine';
|
||||
import { reactionsStore } from '$lib/modules/news/stores/reactions.svelte';
|
||||
import { articlesStore } from '$lib/modules/news/stores/articles.svelte';
|
||||
import { feedCacheStore } from '$lib/modules/news/stores/feed-cache.svelte';
|
||||
import type { LocalCachedArticle } from '$lib/modules/news/types';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
// We accept ViewProps for protocol compatibility but the workbench
|
||||
// view doesn't navigate within itself — every "open" jumps to the
|
||||
// dedicated /news routes. Empty destructure satisfies the $props()
|
||||
// declaration without referencing the props object (which would
|
||||
// trigger Svelte's "captured initial value" warning).
|
||||
const {}: ViewProps = $props();
|
||||
|
||||
const prefs$ = usePreferences();
|
||||
const pool$ = useCachedFeed();
|
||||
const reactions$ = useReactions();
|
||||
|
||||
const prefs = $derived(prefs$.value);
|
||||
const pool = $derived(pool$.value);
|
||||
const reactions = $derived(reactions$.value);
|
||||
|
||||
const { dismissedIds, interestedIds } = $derived(buildReactionSets(reactions));
|
||||
const ranked = $derived(
|
||||
prefs.onboardingCompleted ? rankFeed(pool, { prefs, dismissedIds, interestedIds }) : []
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
feedCacheStore.start();
|
||||
});
|
||||
onDestroy(() => {
|
||||
// Don't stop the poll — the /news layout uses it too and the
|
||||
// store dedupes via inFlight. Stopping here would race with a
|
||||
// concurrently-mounted /news route.
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!prefs.onboardingCompleted) return;
|
||||
void feedCacheStore.refresh({
|
||||
topics: prefs.selectedTopics,
|
||||
lang: prefs.preferredLanguages.length === 1 ? prefs.preferredLanguages[0] : 'all',
|
||||
});
|
||||
});
|
||||
|
||||
async function react(
|
||||
article: LocalCachedArticle,
|
||||
kind: 'interested' | 'not_interested' | 'source_blocked'
|
||||
) {
|
||||
await reactionsStore.react({
|
||||
articleId: article.id,
|
||||
reaction: kind,
|
||||
topic: article.topic,
|
||||
sourceSlug: article.sourceSlug,
|
||||
});
|
||||
if (kind === 'interested') {
|
||||
await articlesStore.saveFromCurated(article);
|
||||
}
|
||||
}
|
||||
|
||||
function open(article: LocalCachedArticle) {
|
||||
goto(`/news/${article.id}`);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await feedCacheStore.refresh({
|
||||
topics: prefs.selectedTopics,
|
||||
lang: prefs.preferredLanguages.length === 1 ? prefs.preferredLanguages[0] : 'all',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="wb-news">
|
||||
{#if !prefs.onboardingCompleted}
|
||||
<div class="cta">
|
||||
<p class="cta-title">{$_('news.workbench.cta_title')}</p>
|
||||
<p class="cta-hint">
|
||||
{$_('news.workbench.cta_hint')}
|
||||
</p>
|
||||
<a class="cta-btn" href="/news">{$_('news.workbench.cta_action')}</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="toolbar">
|
||||
<div class="counts">
|
||||
{$_('news.feed.articles', { values: { count: ranked.length } })}
|
||||
{#if feedCacheStore.lastError}
|
||||
· <span class="err">{$_('news.workbench.err_short')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="tools">
|
||||
<button
|
||||
type="button"
|
||||
class="tool"
|
||||
onclick={refresh}
|
||||
disabled={feedCacheStore.inFlight}
|
||||
title={$_('news.feed.refresh')}
|
||||
>
|
||||
{feedCacheStore.inFlight ? '…' : '↻'}
|
||||
</button>
|
||||
<a class="tool" href="/news/saved" title={$_('news.feed.savedLink')}>📑</a>
|
||||
<a class="tool" href="/news/preferences" title={$_('news.feed.settingsLink')}>⚙</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if ranked.length === 0}
|
||||
<div class="empty">
|
||||
{#if pool.length === 0}
|
||||
<p>{$_('news.feed.loading')}</p>
|
||||
{:else}
|
||||
<p>{$_('news.workbench.empty_short')}</p>
|
||||
<button type="button" class="link" onclick={refresh}>{$_('news.feed.refresh')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="list">
|
||||
{#each ranked.slice(0, 30) as { article } (article.id)}
|
||||
<li class="item">
|
||||
{#if article.imageUrl}
|
||||
<button
|
||||
type="button"
|
||||
class="thumb"
|
||||
onclick={() => open(article)}
|
||||
aria-label={$_('news.workbench.open_aria')}
|
||||
>
|
||||
<img src={article.imageUrl} alt="" loading="lazy" />
|
||||
</button>
|
||||
{/if}
|
||||
<div class="body">
|
||||
<div class="meta">
|
||||
<span class="site">{article.siteName}</span>
|
||||
<span>·</span>
|
||||
<span>{formatRelativeTime(article.publishedAt)}</span>
|
||||
</div>
|
||||
<button type="button" class="title" onclick={() => open(article)}>
|
||||
{article.title}
|
||||
</button>
|
||||
<div class="actions">
|
||||
<button
|
||||
type="button"
|
||||
class="rxn"
|
||||
onclick={() => react(article, 'interested')}
|
||||
title={$_('news.reactions.interested')}
|
||||
>
|
||||
❤️
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rxn"
|
||||
onclick={() => react(article, 'not_interested')}
|
||||
title={$_('news.reactions.notInterested')}
|
||||
>
|
||||
👎
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rxn"
|
||||
onclick={() => react(article, 'source_blocked')}
|
||||
title={$_('news.reactions.blockSource')}
|
||||
>
|
||||
🚫
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wb-news {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
padding: 0.5rem 0.25rem;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1.5rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.cta-title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.cta-hint {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.cta-btn {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-primary));
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
.counts {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.counts .err {
|
||||
color: hsl(var(--color-destructive));
|
||||
}
|
||||
.tools {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.tool {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.625rem;
|
||||
height: 1.625rem;
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-muted));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.tool:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 1.5rem 0;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: hsl(var(--color-primary));
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
font-size: 0.8125rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.625rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-muted));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
.thumb {
|
||||
width: 64px;
|
||||
height: 48px;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: hsl(var(--color-background));
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
.thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.meta .site {
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.title {
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: hsl(var(--color-foreground));
|
||||
cursor: pointer;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.title:hover {
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
.rxn {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 0.25rem;
|
||||
background: hsl(var(--color-background));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
/**
|
||||
* News API client — talks to apps/api `/api/v1/news/*`.
|
||||
*
|
||||
* Two flavors of endpoints:
|
||||
* - GET /feed — pulls the curated pool, with topic/lang filters
|
||||
* - POST /extract/* — Mozilla Readability for ad-hoc URL saves
|
||||
*
|
||||
* The base URL comes from `getManaApiUrl()`, which on the client reads the
|
||||
* browser-injected `__PUBLIC_MANA_API_URL__` (set from
|
||||
* `PUBLIC_MANA_API_URL_CLIENT` in hooks.server.ts → e.g.
|
||||
* `https://mana-api.mana.how`) and on the server reads `process.env`
|
||||
* directly. Reading `$env/dynamic/public.PUBLIC_MANA_API_URL` here would
|
||||
* leak the SSR-side internal Docker hostname (`http://mana-api:3060`) to
|
||||
* the browser and trip CSP / DNS.
|
||||
*
|
||||
* Auth is the unified Mana JWT pulled from `authStore.getAccessToken()`
|
||||
* and attached as a `Authorization: Bearer …` header. The credentials/
|
||||
* cookie path does NOT work — the apps/api authMiddleware only reads
|
||||
* the Authorization header. Initially we passed `credentials: 'include'`
|
||||
* thinking the cookie alone was enough, which made every browser-side
|
||||
* fetch return 401 because mana-api never sees a token.
|
||||
*/
|
||||
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { getManaApiUrl } from '$lib/api/config';
|
||||
|
||||
async function authHeader(): Promise<Record<string, string>> {
|
||||
// getValidToken (not getAccessToken) — runs the token through the
|
||||
// tokenManager so it refreshes if expired. getAccessToken just reads
|
||||
// localStorage and returns null/stale, which is what made the first
|
||||
// pass at this fix still 401. sync.ts uses the same getValidToken.
|
||||
const token = await authStore.getValidToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
export interface FeedArticleDto {
|
||||
id: string;
|
||||
originalUrl: string;
|
||||
title: string;
|
||||
excerpt: string | null;
|
||||
content: string;
|
||||
htmlContent: string | null;
|
||||
author: string | null;
|
||||
siteName: string;
|
||||
sourceSlug: string;
|
||||
imageUrl: string | null;
|
||||
topic: string;
|
||||
language: string;
|
||||
wordCount: number | null;
|
||||
readingTimeMinutes: number | null;
|
||||
publishedAt: string | null;
|
||||
ingestedAt: string;
|
||||
}
|
||||
|
||||
export interface FeedQuery {
|
||||
topics?: string[];
|
||||
lang?: 'de' | 'en' | 'all';
|
||||
since?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export async function fetchFeed(
|
||||
query: FeedQuery = {},
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<FeedArticleDto[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (query.topics && query.topics.length > 0) {
|
||||
params.set('topics', query.topics.join(','));
|
||||
}
|
||||
if (query.lang && query.lang !== 'all') params.set('lang', query.lang);
|
||||
if (query.since) params.set('since', query.since);
|
||||
if (query.limit != null) params.set('limit', String(query.limit));
|
||||
if (query.offset != null) params.set('offset', String(query.offset));
|
||||
|
||||
const url = `${getManaApiUrl()}/api/v1/news/feed${params.toString() ? `?${params}` : ''}`;
|
||||
const response = await fetchImpl(url, {
|
||||
headers: await authHeader(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`fetchFeed failed: ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as FeedArticleDto[];
|
||||
}
|
||||
|
||||
// Ad-hoc URL extraction moved to the `articles` module in M5 — see
|
||||
// `modules/articles/api.ts` and `modules/articles/stores/articles.svelte.ts`.
|
||||
// The `/api/v1/news/extract/*` routes in apps/api are kept for now as
|
||||
// a legacy surface; the `news-research` module still relies on them.
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
/**
|
||||
* News module — Dexie table accessors and seed data.
|
||||
*/
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import type {
|
||||
LocalArticle,
|
||||
LocalCachedArticle,
|
||||
LocalCategory,
|
||||
LocalPreferences,
|
||||
LocalReaction,
|
||||
} from './types';
|
||||
import { PREFERENCES_ID } from './types';
|
||||
|
||||
export const articleTable = db.table<LocalArticle>('newsArticles');
|
||||
export const categoryTable = db.table<LocalCategory>('newsCategories');
|
||||
export const preferencesTable = db.table<LocalPreferences>('newsPreferences');
|
||||
export const reactionTable = db.table<LocalReaction>('newsReactions');
|
||||
export const cachedFeedTable = db.table<LocalCachedArticle>('newsCachedFeed');
|
||||
|
||||
/**
|
||||
* Default preferences row written on first launch (before the user runs
|
||||
* the onboarding flow). `onboardingCompleted: false` is what triggers
|
||||
* the onboarding view to render instead of the feed.
|
||||
*/
|
||||
export const DEFAULT_PREFERENCES: LocalPreferences = {
|
||||
id: PREFERENCES_ID,
|
||||
selectedTopics: [],
|
||||
blockedSources: [],
|
||||
preferredLanguages: ['de', 'en'],
|
||||
topicWeights: {},
|
||||
sourceWeights: {},
|
||||
onboardingCompleted: false,
|
||||
customFeeds: [],
|
||||
};
|
||||
|
|
@ -1,204 +0,0 @@
|
|||
/**
|
||||
* Pure feed-engine: takes the raw cached pool + the user's preferences
|
||||
* and reactions, returns a sorted, filtered list of articles to show.
|
||||
*
|
||||
* No state, no I/O — every input is passed in. The store layer wires
|
||||
* this up against live Dexie data via $derived.
|
||||
*
|
||||
* Scoring formula (deterministic, no ML):
|
||||
* score = recency × topicWeight × sourceWeight
|
||||
*
|
||||
* recency 1.0 for <1h old, decays linearly to 0 over 7 days
|
||||
* topicWeight default 1.0, +0.1 per "interested" reaction in that
|
||||
* topic, −0.05 per "not_interested" (clamped 0.1..3.0)
|
||||
* sourceWeight same dynamics keyed on source slug
|
||||
*
|
||||
* Hard filters (applied before scoring):
|
||||
* - article topic must be in preferences.selectedTopics
|
||||
* - article source must NOT be in preferences.blockedSources
|
||||
* - language must be in preferences.preferredLanguages
|
||||
* - article must not have a prior reaction
|
||||
* (interested → moved to reading list, not_interested/hidden →
|
||||
* explicitly suppressed)
|
||||
*/
|
||||
|
||||
import type { LocalCachedArticle, Preferences, Reaction, ReactionKind } from './types';
|
||||
|
||||
export const TOPIC_WEIGHT_DEFAULT = 1.0;
|
||||
export const TOPIC_WEIGHT_MIN = 0.1;
|
||||
export const TOPIC_WEIGHT_MAX = 3.0;
|
||||
|
||||
export const INTERESTED_DELTA = 0.1;
|
||||
export const NOT_INTERESTED_DELTA = -0.05;
|
||||
|
||||
const RECENCY_WINDOW_HOURS = 168; // 7 days
|
||||
|
||||
function recencyScore(publishedAt: string | null): number {
|
||||
if (!publishedAt) return 0.1;
|
||||
const ageH = (Date.now() - new Date(publishedAt).getTime()) / 3.6e6;
|
||||
if (ageH < 0) return 1.0;
|
||||
return Math.max(0, 1 - ageH / RECENCY_WINDOW_HOURS);
|
||||
}
|
||||
|
||||
export interface ScoreContext {
|
||||
prefs: Preferences;
|
||||
/**
|
||||
* Set of curatedArticleIds the user has actively dismissed
|
||||
* (`not_interested`, `hidden`, or via `source_blocked`). Used as a
|
||||
* hard hide-from-feed filter.
|
||||
*
|
||||
* `interested` reactions are NOT in this set on purpose — those
|
||||
* articles stay visible in the feed (with a saved-badge) so the
|
||||
* user can keep reading and clicking around without articles
|
||||
* disappearing the moment they tap "❤️". The reading list remains
|
||||
* the source of truth for "what did I save".
|
||||
*/
|
||||
dismissedIds: ReadonlySet<string>;
|
||||
/** Set of curatedArticleIds the user marked as interested. */
|
||||
interestedIds: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the user's reactions into two sets: dismissed (hard-hide
|
||||
* from feed) and interested (keep visible, badge in UI). Built once
|
||||
* per render and reused across all scoreArticle calls.
|
||||
*
|
||||
* `source_blocked` reactions are NOT added to dismissedIds even
|
||||
* though they hide articles — the source-level filter in
|
||||
* `scoreArticle` handles those via `prefs.blockedSources` instead,
|
||||
* so adding them here would be a no-op duplicate.
|
||||
*/
|
||||
export function buildReactionSets(reactions: readonly Reaction[]): {
|
||||
dismissedIds: Set<string>;
|
||||
interestedIds: Set<string>;
|
||||
} {
|
||||
const dismissedIds = new Set<string>();
|
||||
const interestedIds = new Set<string>();
|
||||
for (const r of reactions) {
|
||||
if (r.reaction === 'interested') {
|
||||
interestedIds.add(r.articleId);
|
||||
} else if (r.reaction === 'not_interested' || r.reaction === 'hidden') {
|
||||
dismissedIds.add(r.articleId);
|
||||
}
|
||||
}
|
||||
return { dismissedIds, interestedIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Kept for backwards compat with the dashboard widget.
|
||||
* New call sites should use `buildReactionSets` and the
|
||||
* dismissedIds/interestedIds shape instead. This helper now returns
|
||||
* only the dismissed ids — same effect on the feed filter, but it
|
||||
* means widgets that haven't migrated still hide
|
||||
* `not_interested`/`hidden` articles correctly while leaving
|
||||
* `interested` articles visible (matching the new feed behavior).
|
||||
*/
|
||||
export function buildReactedIds(reactions: readonly Reaction[]): Set<string> {
|
||||
return buildReactionSets(reactions).dismissedIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a number ≥ 0 if the article passes filters, or `null` if it
|
||||
* should be hidden entirely. Callers sort by descending score.
|
||||
*/
|
||||
export function scoreArticle(article: LocalCachedArticle, ctx: ScoreContext): number | null {
|
||||
const { prefs, dismissedIds } = ctx;
|
||||
|
||||
if (prefs.selectedTopics.length > 0 && !prefs.selectedTopics.includes(article.topic as never)) {
|
||||
return null;
|
||||
}
|
||||
if (prefs.blockedSources.includes(article.sourceSlug)) return null;
|
||||
if (
|
||||
prefs.preferredLanguages.length > 0 &&
|
||||
!prefs.preferredLanguages.includes(article.language as never)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// Only ACTIVELY DISMISSED articles are filtered out. `interested`
|
||||
// reactions stay visible in the feed (the user's read state is
|
||||
// communicated via the badge in the UI, not by hiding the card).
|
||||
if (dismissedIds.has(article.id)) return null;
|
||||
|
||||
const topicW = prefs.topicWeights[article.topic] ?? TOPIC_WEIGHT_DEFAULT;
|
||||
const sourceW = prefs.sourceWeights[article.sourceSlug] ?? TOPIC_WEIGHT_DEFAULT;
|
||||
const recency = recencyScore(article.publishedAt);
|
||||
|
||||
// Floor recency at 0.05 so very old but highly-weighted sources still
|
||||
// surface above brand-new but unweighted ones — keeps the feed from
|
||||
// devolving into a pure recency stream.
|
||||
const floored = Math.max(recency, 0.05);
|
||||
return floored * topicW * sourceW;
|
||||
}
|
||||
|
||||
export interface ScoredArticle {
|
||||
article: LocalCachedArticle;
|
||||
score: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score the whole pool, drop the rejected ones, and return descending
|
||||
* by score. Stable: ties broken by `publishedAt` desc.
|
||||
*/
|
||||
export function rankFeed(pool: readonly LocalCachedArticle[], ctx: ScoreContext): ScoredArticle[] {
|
||||
const out: ScoredArticle[] = [];
|
||||
for (const article of pool) {
|
||||
const score = scoreArticle(article, ctx);
|
||||
if (score == null) continue;
|
||||
out.push({ article, score });
|
||||
}
|
||||
out.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
const ap = a.article.publishedAt ?? '';
|
||||
const bp = b.article.publishedAt ?? '';
|
||||
return bp.localeCompare(ap);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Weight updates (returned as a partial Preferences diff) ───
|
||||
|
||||
export interface WeightDiff {
|
||||
topicWeights?: Record<string, number>;
|
||||
sourceWeights?: Record<string, number>;
|
||||
blockedSources?: string[];
|
||||
}
|
||||
|
||||
function clamp(n: number): number {
|
||||
return Math.max(TOPIC_WEIGHT_MIN, Math.min(TOPIC_WEIGHT_MAX, n));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the preferences delta for a new reaction. The store layer
|
||||
* merges this back onto the existing preferences row in a single
|
||||
* `update()` call.
|
||||
*/
|
||||
export function applyReaction(
|
||||
prefs: Preferences,
|
||||
reaction: ReactionKind,
|
||||
topic: string,
|
||||
sourceSlug: string
|
||||
): WeightDiff {
|
||||
if (reaction === 'source_blocked') {
|
||||
if (prefs.blockedSources.includes(sourceSlug)) return {};
|
||||
return { blockedSources: [...prefs.blockedSources, sourceSlug] };
|
||||
}
|
||||
|
||||
if (reaction === 'hidden') {
|
||||
// "Hidden" is a per-article suppression — no weight change. The
|
||||
// reaction row alone is enough for `reactedIds` to filter it.
|
||||
return {};
|
||||
}
|
||||
|
||||
const delta = reaction === 'interested' ? INTERESTED_DELTA : NOT_INTERESTED_DELTA;
|
||||
|
||||
const currentTopic = prefs.topicWeights[topic] ?? TOPIC_WEIGHT_DEFAULT;
|
||||
const currentSource = prefs.sourceWeights[sourceSlug] ?? TOPIC_WEIGHT_DEFAULT;
|
||||
|
||||
return {
|
||||
topicWeights: { ...prefs.topicWeights, [topic]: clamp(currentTopic + delta) },
|
||||
sourceWeights: {
|
||||
...prefs.sourceWeights,
|
||||
[sourceSlug]: clamp(currentSource + delta),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
/**
|
||||
* News module — barrel exports.
|
||||
*/
|
||||
|
||||
export {
|
||||
articleTable,
|
||||
categoryTable,
|
||||
preferencesTable,
|
||||
reactionTable,
|
||||
cachedFeedTable,
|
||||
DEFAULT_PREFERENCES,
|
||||
} from './collections';
|
||||
|
||||
export {
|
||||
useSavedArticles,
|
||||
useArticle,
|
||||
useCategories,
|
||||
usePreferences,
|
||||
useReactions,
|
||||
useCachedFeed,
|
||||
toArticle,
|
||||
toCategory,
|
||||
toPreferences,
|
||||
toReaction,
|
||||
formatRelativeTime,
|
||||
} from './queries';
|
||||
|
||||
export {
|
||||
rankFeed,
|
||||
scoreArticle,
|
||||
buildReactedIds,
|
||||
buildReactionSets,
|
||||
applyReaction,
|
||||
TOPIC_WEIGHT_DEFAULT,
|
||||
} from './feed-engine';
|
||||
|
||||
export type { ScoredArticle, ScoreContext, WeightDiff } from './feed-engine';
|
||||
|
||||
export { articlesStore } from './stores/articles.svelte';
|
||||
export { categoriesStore } from './stores/categories.svelte';
|
||||
export { preferencesStore } from './stores/preferences.svelte';
|
||||
export { reactionsStore } from './stores/reactions.svelte';
|
||||
export { feedCacheStore } from './stores/feed-cache.svelte';
|
||||
|
||||
export { fetchFeed } from './api';
|
||||
export type { FeedArticleDto, FeedQuery } from './api';
|
||||
|
||||
export { SOURCES_META, SOURCE_META_BY_SLUG, sourcesForTopic, TOPIC_LABELS } from './sources-meta';
|
||||
export type { SourceMeta } from './sources-meta';
|
||||
|
||||
export { ALL_TOPICS, PREFERENCES_ID } from './types';
|
||||
export type {
|
||||
Article,
|
||||
Category,
|
||||
LocalArticle,
|
||||
LocalCachedArticle,
|
||||
LocalCategory,
|
||||
LocalPreferences,
|
||||
LocalReaction,
|
||||
Language,
|
||||
Preferences,
|
||||
Reaction,
|
||||
ReactionKind,
|
||||
Topic,
|
||||
} from './types';
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import type { ModuleConfig } from '$lib/data/module-registry';
|
||||
|
||||
/**
|
||||
* News module — five Dexie tables, four of them synced.
|
||||
*
|
||||
* `newsCachedFeed` is intentionally absent: it mirrors the public
|
||||
* server pool, refreshes on a 10-minute poll, and would chew through
|
||||
* sync bandwidth + storage quota for zero benefit (the same data is
|
||||
* just an HTTP fetch away).
|
||||
*/
|
||||
export const newsModuleConfig: ModuleConfig = {
|
||||
appId: 'news',
|
||||
tables: [
|
||||
{ name: 'newsArticles', syncName: 'articles' },
|
||||
{ name: 'newsCategories', syncName: 'categories' },
|
||||
{ name: 'newsPreferences', syncName: 'preferences' },
|
||||
{ name: 'newsReactions', syncName: 'reactions' },
|
||||
],
|
||||
};
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
import { formatDate } from '$lib/i18n/format';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
/**
|
||||
* Reactive queries + type converters for News.
|
||||
*
|
||||
* Read-side only. Anything that mutates lives in stores/*.svelte.ts.
|
||||
*/
|
||||
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import {
|
||||
articleTable,
|
||||
cachedFeedTable,
|
||||
categoryTable,
|
||||
preferencesTable,
|
||||
reactionTable,
|
||||
DEFAULT_PREFERENCES,
|
||||
} from './collections';
|
||||
import type {
|
||||
Article,
|
||||
Category,
|
||||
LocalArticle,
|
||||
LocalCachedArticle,
|
||||
LocalCategory,
|
||||
LocalPreferences,
|
||||
LocalReaction,
|
||||
Preferences,
|
||||
Reaction,
|
||||
} from './types';
|
||||
import { PREFERENCES_ID } from './types';
|
||||
|
||||
// ─── Type converters ───────────────────────────────────────
|
||||
|
||||
export function toArticle(local: LocalArticle): Article {
|
||||
return {
|
||||
id: local.id,
|
||||
type: local.type,
|
||||
sourceCuratedId: local.sourceCuratedId ?? undefined,
|
||||
originalUrl: local.originalUrl,
|
||||
title: local.title,
|
||||
excerpt: local.excerpt,
|
||||
content: local.content,
|
||||
htmlContent: local.htmlContent,
|
||||
author: local.author,
|
||||
siteName: local.siteName,
|
||||
sourceSlug: local.sourceSlug,
|
||||
imageUrl: local.imageUrl,
|
||||
categoryId: local.categoryId,
|
||||
wordCount: local.wordCount,
|
||||
readingTimeMinutes: local.readingTimeMinutes,
|
||||
publishedAt: local.publishedAt,
|
||||
isArchived: local.isArchived,
|
||||
isRead: local.isRead,
|
||||
isFavorite: local.isFavorite,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
export function toCategory(local: LocalCategory): Category {
|
||||
return {
|
||||
id: local.id,
|
||||
name: local.name,
|
||||
color: local.color,
|
||||
icon: local.icon,
|
||||
sortOrder: local.sortOrder,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
export function toPreferences(local: LocalPreferences): Preferences {
|
||||
// Force the array fields back to arrays even if decryption left an
|
||||
// encrypted blob string in place (vault locked at boot). Without this
|
||||
// guard `{#each prefs.selectedTopics}` iterates the encrypted string
|
||||
// char-by-char and crashes `TOPIC_LABELS[topic].emoji` on render.
|
||||
return {
|
||||
id: local.id,
|
||||
selectedTopics: Array.isArray(local.selectedTopics) ? local.selectedTopics : [],
|
||||
blockedSources: Array.isArray(local.blockedSources) ? local.blockedSources : [],
|
||||
preferredLanguages: Array.isArray(local.preferredLanguages)
|
||||
? local.preferredLanguages
|
||||
: ['de', 'en'],
|
||||
topicWeights:
|
||||
local.topicWeights && typeof local.topicWeights === 'object' ? local.topicWeights : {},
|
||||
sourceWeights:
|
||||
local.sourceWeights && typeof local.sourceWeights === 'object' ? local.sourceWeights : {},
|
||||
onboardingCompleted: local.onboardingCompleted ?? false,
|
||||
customFeeds: Array.isArray(local.customFeeds) ? local.customFeeds : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function toReaction(local: LocalReaction): Reaction {
|
||||
return {
|
||||
id: local.id,
|
||||
articleId: local.articleId,
|
||||
reaction: local.reaction,
|
||||
sourceSlug: local.sourceSlug,
|
||||
topic: local.topic,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Live queries ──────────────────────────────────────────
|
||||
|
||||
/** Saved articles (the personal reading list). Encrypted on disk. */
|
||||
export function useSavedArticles() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const visible = (await articleTable.toArray()).filter((a) => !a.deletedAt && !a.isArchived);
|
||||
const decrypted = await decryptRecords('newsArticles', visible);
|
||||
return decrypted.map(toArticle).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}, [] as Article[]);
|
||||
}
|
||||
|
||||
export function useArticle(id: string) {
|
||||
return useLiveQueryWithDefault(
|
||||
async () => {
|
||||
const local = await articleTable.get(id);
|
||||
if (!local || local.deletedAt) return null;
|
||||
const [decrypted] = await decryptRecords('newsArticles', [local]);
|
||||
return decrypted ? toArticle(decrypted) : null;
|
||||
},
|
||||
null as Article | null
|
||||
);
|
||||
}
|
||||
|
||||
export function useCategories() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const visible = (await categoryTable.toArray()).filter((c) => !c.deletedAt);
|
||||
const decrypted = await decryptRecords('newsCategories', visible);
|
||||
return decrypted.map(toCategory).sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
}, [] as Category[]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton preferences row. Returns the default-shape preferences if
|
||||
* the user has never opened the module before — onboardingCompleted
|
||||
* starts as `false`, which the route layer uses to redirect into the
|
||||
* onboarding view on first launch.
|
||||
*/
|
||||
export function usePreferences() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const local = await preferencesTable.get(PREFERENCES_ID);
|
||||
if (!local) return toPreferences(DEFAULT_PREFERENCES);
|
||||
const [decrypted] = await decryptRecords('newsPreferences', [local]);
|
||||
return toPreferences(decrypted ?? DEFAULT_PREFERENCES);
|
||||
}, toPreferences(DEFAULT_PREFERENCES));
|
||||
}
|
||||
|
||||
export function useReactions() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const visible = (await reactionTable.toArray()).filter((r) => !r.deletedAt);
|
||||
const decrypted = await decryptRecords('newsReactions', visible);
|
||||
return decrypted.map(toReaction);
|
||||
}, [] as Reaction[]);
|
||||
}
|
||||
|
||||
/** The local mirror of the server's curated pool — plaintext, not synced. */
|
||||
export function useCachedFeed() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const all = await cachedFeedTable.toArray();
|
||||
// Newest first, but the feed engine re-sorts by score so this is
|
||||
// only a stable input order.
|
||||
return all.sort((a, b) => (b.publishedAt ?? '').localeCompare(a.publishedAt ?? ''));
|
||||
}, [] as LocalCachedArticle[]);
|
||||
}
|
||||
|
||||
// ─── Pure helpers ──────────────────────────────────────────
|
||||
|
||||
export function formatRelativeTime(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'jetzt';
|
||||
if (mins < 60) return `vor ${mins}m`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `vor ${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) return `vor ${days}d`;
|
||||
return formatDate(new Date(iso), { day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
/**
|
||||
* Source metadata for the News onboarding picker and per-article badges.
|
||||
*
|
||||
* MUST stay in sync with `services/news-ingester/src/sources.ts` —
|
||||
* the `slug` is the cross-reference key (user blocklists store it
|
||||
* verbatim, articles in the curated pool reference it). Adding or
|
||||
* removing a source means editing both files.
|
||||
*
|
||||
* `language` and `topic` are duplicated from the ingester so the client
|
||||
* doesn't need to fetch source metadata at runtime.
|
||||
*/
|
||||
|
||||
import type { Topic, Language } from './types';
|
||||
|
||||
export interface SourceMeta {
|
||||
slug: string;
|
||||
name: string;
|
||||
topic: Topic;
|
||||
language: Language;
|
||||
}
|
||||
|
||||
export const SOURCES_META: readonly SourceMeta[] = [
|
||||
// tech
|
||||
{ slug: 'hacker-news', name: 'Hacker News', topic: 'tech', language: 'en' },
|
||||
{ slug: 'arstechnica', name: 'Ars Technica', topic: 'tech', language: 'en' },
|
||||
{ slug: 'theverge', name: 'The Verge', topic: 'tech', language: 'en' },
|
||||
{ slug: 'heise', name: 'heise online', topic: 'tech', language: 'de' },
|
||||
// wissenschaft
|
||||
{ slug: 'quanta-magazine', name: 'Quanta Magazine', topic: 'wissenschaft', language: 'en' },
|
||||
{ slug: 'spektrum', name: 'Spektrum', topic: 'wissenschaft', language: 'de' },
|
||||
{ slug: 'nature-news', name: 'Nature News', topic: 'wissenschaft', language: 'en' },
|
||||
{ slug: 'phys-org', name: 'Phys.org', topic: 'wissenschaft', language: 'en' },
|
||||
// weltgeschehen
|
||||
{ slug: 'tagesschau', name: 'Tagesschau', topic: 'weltgeschehen', language: 'de' },
|
||||
{ slug: 'bbc-world', name: 'BBC World', topic: 'weltgeschehen', language: 'en' },
|
||||
{ slug: 'aljazeera', name: 'Al Jazeera', topic: 'weltgeschehen', language: 'en' },
|
||||
{ slug: 'dw-top', name: 'Deutsche Welle', topic: 'weltgeschehen', language: 'en' },
|
||||
// wirtschaft
|
||||
{ slug: 'handelsblatt', name: 'Handelsblatt', topic: 'wirtschaft', language: 'de' },
|
||||
{ slug: 'ft-world', name: 'Financial Times', topic: 'wirtschaft', language: 'en' },
|
||||
{ slug: 'bloomberg-markets', name: 'Bloomberg Markets', topic: 'wirtschaft', language: 'en' },
|
||||
{
|
||||
slug: 'economist-finance',
|
||||
name: 'The Economist — Finance',
|
||||
topic: 'wirtschaft',
|
||||
language: 'en',
|
||||
},
|
||||
// kultur
|
||||
{ slug: 'guardian-culture', name: 'The Guardian Culture', topic: 'kultur', language: 'en' },
|
||||
{ slug: 'guardian-books', name: 'The Guardian Books', topic: 'kultur', language: 'en' },
|
||||
{ slug: 'npr-arts', name: 'NPR Arts', topic: 'kultur', language: 'en' },
|
||||
// gesundheit
|
||||
{ slug: 'stat-news', name: 'STAT News', topic: 'gesundheit', language: 'en' },
|
||||
{ slug: 'bbc-health', name: 'BBC Health', topic: 'gesundheit', language: 'en' },
|
||||
{ slug: 'sciencedaily-health', name: 'ScienceDaily Health', topic: 'gesundheit', language: 'en' },
|
||||
// politik
|
||||
{ slug: 'spiegel-politik', name: 'Spiegel Politik', topic: 'politik', language: 'de' },
|
||||
{ slug: 'politico-eu', name: 'Politico EU', topic: 'politik', language: 'en' },
|
||||
{
|
||||
slug: 'atlantic-politics',
|
||||
name: 'The Atlantic — Politics',
|
||||
topic: 'politik',
|
||||
language: 'en',
|
||||
},
|
||||
];
|
||||
|
||||
export const SOURCE_META_BY_SLUG: Record<string, SourceMeta> = Object.fromEntries(
|
||||
SOURCES_META.map((s) => [s.slug, s])
|
||||
);
|
||||
|
||||
export function sourcesForTopic(topic: Topic): readonly SourceMeta[] {
|
||||
return SOURCES_META.filter((s) => s.topic === topic);
|
||||
}
|
||||
|
||||
export const TOPIC_LABELS: Record<Topic, { de: string; en: string; emoji: string }> = {
|
||||
tech: { de: 'Tech', en: 'Tech', emoji: '💻' },
|
||||
wissenschaft: { de: 'Wissenschaft', en: 'Science', emoji: '🔬' },
|
||||
weltgeschehen: { de: 'Weltgeschehen', en: 'World', emoji: '🌍' },
|
||||
wirtschaft: { de: 'Wirtschaft', en: 'Business', emoji: '📈' },
|
||||
kultur: { de: 'Kultur', en: 'Culture', emoji: '🎭' },
|
||||
gesundheit: { de: 'Gesundheit', en: 'Health', emoji: '🩺' },
|
||||
politik: { de: 'Politik', en: 'Politics', emoji: '🏛️' },
|
||||
};
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
/**
|
||||
* Articles store — the user's saved reading list.
|
||||
*
|
||||
* Now single-purpose: saveFromCurated copies a row from the local pool
|
||||
* mirror into the encrypted reading list (hit when the user presses
|
||||
* "speichern" on a feed card). The ad-hoc URL path (`saveFromUrl` +
|
||||
* the `type: 'saved'` discriminator) moved to the Articles module in
|
||||
* M5 — see `modules/articles/migrations/from-news.ts` for the one-off
|
||||
* data migration and `modules/articles/stores/articles.svelte.ts` for
|
||||
* the replacement flow.
|
||||
*
|
||||
* All other operations (read/archive/favorite/delete) are plain
|
||||
* updates against `newsArticles`.
|
||||
*/
|
||||
|
||||
import { encryptRecord } from '$lib/data/crypto';
|
||||
import { emitDomainEvent } from '$lib/data/events';
|
||||
import { articleTable } from '../collections';
|
||||
import { toArticle } from '../queries';
|
||||
import type { Article, LocalArticle, LocalCachedArticle } from '../types';
|
||||
|
||||
export const articlesStore = {
|
||||
async saveFromCurated(input: LocalCachedArticle): Promise<Article> {
|
||||
// Dedupe: if the user has already saved this curated article,
|
||||
// return the existing row instead of creating a duplicate. The
|
||||
// `sourceCuratedId` index makes this O(1).
|
||||
const existing = await articleTable.where('sourceCuratedId').equals(input.id).first();
|
||||
if (existing) return toArticle(existing);
|
||||
|
||||
const newLocal: LocalArticle = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'curated',
|
||||
sourceCuratedId: input.id,
|
||||
originalUrl: input.originalUrl,
|
||||
title: input.title,
|
||||
excerpt: input.excerpt,
|
||||
content: input.content,
|
||||
htmlContent: input.htmlContent,
|
||||
author: input.author,
|
||||
siteName: input.siteName,
|
||||
sourceSlug: input.sourceSlug,
|
||||
imageUrl: input.imageUrl,
|
||||
categoryId: null,
|
||||
wordCount: input.wordCount,
|
||||
readingTimeMinutes: input.readingTimeMinutes,
|
||||
publishedAt: input.publishedAt,
|
||||
isArchived: false,
|
||||
isRead: false,
|
||||
isFavorite: false,
|
||||
};
|
||||
const snapshot = toArticle(newLocal);
|
||||
await encryptRecord('newsArticles', newLocal);
|
||||
await articleTable.add(newLocal);
|
||||
emitDomainEvent('ArticleSaved', 'news', 'newsArticles', newLocal.id, {
|
||||
articleId: newLocal.id,
|
||||
title: input.title ?? '',
|
||||
});
|
||||
return snapshot;
|
||||
},
|
||||
|
||||
async markRead(id: string, isRead = true): Promise<void> {
|
||||
await articleTable.update(id, {
|
||||
isRead,
|
||||
});
|
||||
},
|
||||
|
||||
async toggleFavorite(id: string): Promise<void> {
|
||||
const a = await articleTable.get(id);
|
||||
if (!a) return;
|
||||
await articleTable.update(id, {
|
||||
isFavorite: !a.isFavorite,
|
||||
});
|
||||
},
|
||||
|
||||
async archive(id: string): Promise<void> {
|
||||
await articleTable.update(id, {
|
||||
isArchived: true,
|
||||
});
|
||||
},
|
||||
|
||||
async setCategory(id: string, categoryId: string | null): Promise<void> {
|
||||
await articleTable.update(id, {
|
||||
categoryId,
|
||||
});
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await articleTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
/**
|
||||
* Categories store — user-defined folders for the saved reading list.
|
||||
*
|
||||
* Categories live in `newsCategories`. The link from an article to its
|
||||
* category is `LocalArticle.categoryId` (a plaintext FK index), set
|
||||
* via `articlesStore.setCategory`.
|
||||
*
|
||||
* Default seeds (Lese später / Recherche) are NOT created here — the
|
||||
* user starts with zero categories and adds them on demand. Empty is
|
||||
* a valid state and the saved-list view falls back to "Alle Artikel"
|
||||
* when no category is selected.
|
||||
*/
|
||||
|
||||
import { encryptRecord } from '$lib/data/crypto';
|
||||
import { categoryTable } from '../collections';
|
||||
import type { LocalCategory } from '../types';
|
||||
|
||||
const DEFAULT_COLORS = [
|
||||
'#3b82f6',
|
||||
'#22c55e',
|
||||
'#f59e0b',
|
||||
'#ec4899',
|
||||
'#8b5cf6',
|
||||
'#06b6d4',
|
||||
'#f43f5e',
|
||||
'#84cc16',
|
||||
];
|
||||
|
||||
function pickColor(existingCount: number): string {
|
||||
return DEFAULT_COLORS[existingCount % DEFAULT_COLORS.length];
|
||||
}
|
||||
|
||||
export const categoriesStore = {
|
||||
async create(input: { name: string; color?: string; icon?: string }): Promise<LocalCategory> {
|
||||
const count = await categoryTable.count();
|
||||
const newLocal: LocalCategory = {
|
||||
id: crypto.randomUUID(),
|
||||
name: input.name.trim() || 'Ohne Namen',
|
||||
color: input.color ?? pickColor(count),
|
||||
icon: input.icon ?? '📁',
|
||||
sortOrder: count,
|
||||
};
|
||||
await encryptRecord('newsCategories', newLocal);
|
||||
await categoryTable.add(newLocal);
|
||||
return newLocal;
|
||||
},
|
||||
|
||||
async rename(id: string, name: string): Promise<void> {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
const diff: Partial<LocalCategory> = {
|
||||
name: trimmed,
|
||||
};
|
||||
await encryptRecord('newsCategories', diff);
|
||||
await categoryTable.update(id, diff);
|
||||
},
|
||||
|
||||
async setColor(id: string, color: string): Promise<void> {
|
||||
await categoryTable.update(id, {
|
||||
color,
|
||||
});
|
||||
},
|
||||
|
||||
async setIcon(id: string, icon: string): Promise<void> {
|
||||
await categoryTable.update(id, {
|
||||
icon,
|
||||
});
|
||||
},
|
||||
|
||||
async reorder(ids: string[]): Promise<void> {
|
||||
// Bulk update via individual writes — Dexie has no native bulkUpdate
|
||||
// for partial diffs and the per-call cost is negligible at folder
|
||||
// counts (typically <20).
|
||||
const now = new Date().toISOString();
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
await categoryTable.update(ids[i], { sortOrder: i });
|
||||
}
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
// Soft-delete the category itself. Articles that referenced it
|
||||
// keep `categoryId` pointing at the tombstoned row — the saved
|
||||
// view treats unknown categoryIds as "uncategorized" so they
|
||||
// don't disappear. A subsequent re-categorize cleans them up.
|
||||
await categoryTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
/**
|
||||
* Feed-cache store — pulls the curated pool from /api/v1/news/feed
|
||||
* into the local-only `newsCachedFeed` table.
|
||||
*
|
||||
* Why a local mirror at all? The feed engine (scoreArticle, rankFeed)
|
||||
* runs against the cached pool every time the feed view re-renders.
|
||||
* Hitting the network on every render would be silly; hitting the
|
||||
* network on every preferences change would be worse. Caching also
|
||||
* gives us offline reading for the cards the user already saw.
|
||||
*
|
||||
* The cache is bounded: we keep at most CACHE_LIMIT rows, and prune
|
||||
* the oldest by ingestedAt before each refresh. The bounded size + the
|
||||
* fact that the cache is plaintext + not synced is what justifies
|
||||
* leaving it out of the encryption registry and the sync map.
|
||||
*
|
||||
* `start()` should be called once from the news +layout — it kicks an
|
||||
* immediate refresh and then polls on a 10-minute interval. The
|
||||
* interval is held in module scope on purpose so multiple route entries
|
||||
* can't accidentally double up.
|
||||
*/
|
||||
|
||||
import { cachedFeedTable } from '../collections';
|
||||
import { fetchFeed } from '../api';
|
||||
import type { FeedArticleDto } from '../api';
|
||||
import type { LocalCachedArticle } from '../types';
|
||||
|
||||
const POLL_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const CACHE_LIMIT = 400;
|
||||
|
||||
let pollHandle: ReturnType<typeof setInterval> | null = null;
|
||||
let inFlight = false;
|
||||
let lastError: string | null = null;
|
||||
let lastRefreshedAt: string | null = null;
|
||||
|
||||
function toLocal(dto: FeedArticleDto): LocalCachedArticle {
|
||||
return {
|
||||
id: dto.id,
|
||||
originalUrl: dto.originalUrl,
|
||||
title: dto.title,
|
||||
excerpt: dto.excerpt,
|
||||
content: dto.content,
|
||||
htmlContent: dto.htmlContent,
|
||||
author: dto.author,
|
||||
siteName: dto.siteName,
|
||||
sourceSlug: dto.sourceSlug,
|
||||
imageUrl: dto.imageUrl,
|
||||
topic: dto.topic,
|
||||
language: dto.language,
|
||||
wordCount: dto.wordCount,
|
||||
readingTimeMinutes: dto.readingTimeMinutes,
|
||||
publishedAt: dto.publishedAt,
|
||||
ingestedAt: dto.ingestedAt,
|
||||
cachedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function pruneToLimit(): Promise<void> {
|
||||
const count = await cachedFeedTable.count();
|
||||
if (count <= CACHE_LIMIT) return;
|
||||
// Keep the newest CACHE_LIMIT rows by ingestedAt. Dexie has no
|
||||
// LIMIT/OFFSET on plain table, so collect all PKs sorted and slice.
|
||||
const all = await cachedFeedTable.toArray();
|
||||
all.sort((a, b) => (b.ingestedAt ?? '').localeCompare(a.ingestedAt ?? ''));
|
||||
const toDelete = all.slice(CACHE_LIMIT).map((a) => a.id);
|
||||
if (toDelete.length > 0) await cachedFeedTable.bulkDelete(toDelete);
|
||||
}
|
||||
|
||||
export const feedCacheStore = {
|
||||
get lastError() {
|
||||
return lastError;
|
||||
},
|
||||
get lastRefreshedAt() {
|
||||
return lastRefreshedAt;
|
||||
},
|
||||
get inFlight() {
|
||||
return inFlight;
|
||||
},
|
||||
|
||||
async refresh(opts: { topics?: string[]; lang?: 'de' | 'en' | 'all' } = {}): Promise<void> {
|
||||
if (inFlight) return;
|
||||
inFlight = true;
|
||||
lastError = null;
|
||||
try {
|
||||
const dtos = await fetchFeed({
|
||||
limit: 200,
|
||||
topics: opts.topics,
|
||||
lang: opts.lang ?? 'all',
|
||||
});
|
||||
if (dtos.length > 0) {
|
||||
// bulkPut keeps existing rows for the same id and updates
|
||||
// them in place. New rows from the server replace the
|
||||
// previous mirror, old rows that fell out of the server's
|
||||
// 200-row window stay in the cache until pruneToLimit cuts
|
||||
// them. That's the behavior we want — the cache should
|
||||
// degrade gradually, not flush every refresh.
|
||||
await cachedFeedTable.bulkPut(dtos.map(toLocal));
|
||||
}
|
||||
await pruneToLimit();
|
||||
lastRefreshedAt = new Date().toISOString();
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
console.warn('[news] feed refresh failed:', lastError);
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
},
|
||||
|
||||
start(opts: { topics?: string[]; lang?: 'de' | 'en' | 'all' } = {}): void {
|
||||
if (pollHandle) return; // already started
|
||||
void this.refresh(opts);
|
||||
pollHandle = setInterval(() => void this.refresh(opts), POLL_INTERVAL_MS);
|
||||
},
|
||||
|
||||
stop(): void {
|
||||
if (pollHandle) {
|
||||
clearInterval(pollHandle);
|
||||
pollHandle = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
/**
|
||||
* Preferences store — singleton row keyed on `PREFERENCES_ID`.
|
||||
*
|
||||
* The first read of the preferences row is also the place that creates
|
||||
* it on disk, so the rest of the codebase can assume it always exists.
|
||||
* Onboarding then flips `onboardingCompleted` to true.
|
||||
*/
|
||||
|
||||
import { encryptRecord } from '$lib/data/crypto';
|
||||
import { preferencesTable, DEFAULT_PREFERENCES } from '../collections';
|
||||
import { toPreferences } from '../queries';
|
||||
import type { CustomFeed, LocalPreferences, Preferences, Topic, Language } from '../types';
|
||||
import { PREFERENCES_ID } from '../types';
|
||||
|
||||
async function ensureRow(): Promise<LocalPreferences> {
|
||||
const existing = await preferencesTable.get(PREFERENCES_ID);
|
||||
if (existing) return existing;
|
||||
const fresh: LocalPreferences = { ...DEFAULT_PREFERENCES };
|
||||
await encryptRecord('newsPreferences', fresh);
|
||||
await preferencesTable.add(fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
export const preferencesStore = {
|
||||
async get(): Promise<Preferences> {
|
||||
const row = await ensureRow();
|
||||
return toPreferences(row);
|
||||
},
|
||||
|
||||
async completeOnboarding(input: {
|
||||
topics: Topic[];
|
||||
languages: Language[];
|
||||
blockedSources?: string[];
|
||||
}): Promise<void> {
|
||||
await ensureRow();
|
||||
// Spread the input arrays — callers in onboarding pass Svelte 5
|
||||
// `$state` proxy arrays, which IndexedDB cannot structured-clone
|
||||
// (DataCloneError on the Dexie hook's _pendingChanges write).
|
||||
const diff: Partial<LocalPreferences> = {
|
||||
selectedTopics: [...input.topics],
|
||||
preferredLanguages: [...input.languages],
|
||||
blockedSources: [...(input.blockedSources ?? [])],
|
||||
onboardingCompleted: true,
|
||||
};
|
||||
await encryptRecord('newsPreferences', diff);
|
||||
await preferencesTable.update(PREFERENCES_ID, diff);
|
||||
},
|
||||
|
||||
async setTopics(topics: Topic[]): Promise<void> {
|
||||
await ensureRow();
|
||||
const diff: Partial<LocalPreferences> = {
|
||||
selectedTopics: [...topics],
|
||||
};
|
||||
await encryptRecord('newsPreferences', diff);
|
||||
await preferencesTable.update(PREFERENCES_ID, diff);
|
||||
},
|
||||
|
||||
async setLanguages(languages: Language[]): Promise<void> {
|
||||
await ensureRow();
|
||||
const diff: Partial<LocalPreferences> = {
|
||||
preferredLanguages: [...languages],
|
||||
};
|
||||
await encryptRecord('newsPreferences', diff);
|
||||
await preferencesTable.update(PREFERENCES_ID, diff);
|
||||
},
|
||||
|
||||
async toggleBlockedSource(slug: string): Promise<void> {
|
||||
const row = await ensureRow();
|
||||
const blocked = row.blockedSources ?? [];
|
||||
const next = blocked.includes(slug) ? blocked.filter((s) => s !== slug) : [...blocked, slug];
|
||||
const diff: Partial<LocalPreferences> = {
|
||||
blockedSources: next,
|
||||
};
|
||||
await encryptRecord('newsPreferences', diff);
|
||||
await preferencesTable.update(PREFERENCES_ID, diff);
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply a precomputed weight diff (from feed-engine.applyReaction).
|
||||
* Merges with existing weights — caller already did the math.
|
||||
*/
|
||||
async applyWeightDiff(diff: {
|
||||
topicWeights?: Record<string, number>;
|
||||
sourceWeights?: Record<string, number>;
|
||||
blockedSources?: string[];
|
||||
}): Promise<void> {
|
||||
await ensureRow();
|
||||
const update: Partial<LocalPreferences> = {
|
||||
...diff,
|
||||
};
|
||||
await encryptRecord('newsPreferences', update);
|
||||
await preferencesTable.update(PREFERENCES_ID, update);
|
||||
},
|
||||
|
||||
async pinCustomFeed(feed: { url: string; title: string; topic?: Topic }): Promise<void> {
|
||||
const row = await ensureRow();
|
||||
const existing = Array.isArray(row.customFeeds) ? row.customFeeds : [];
|
||||
if (existing.some((f) => f.url === feed.url)) return;
|
||||
const next: CustomFeed[] = [
|
||||
...existing,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
url: feed.url,
|
||||
title: feed.title,
|
||||
topic: feed.topic,
|
||||
pinnedAt: Date.now(),
|
||||
},
|
||||
];
|
||||
const diff: Partial<LocalPreferences> = {
|
||||
customFeeds: next,
|
||||
};
|
||||
await encryptRecord('newsPreferences', diff);
|
||||
await preferencesTable.update(PREFERENCES_ID, diff);
|
||||
},
|
||||
|
||||
async unpinCustomFeed(id: string): Promise<void> {
|
||||
const row = await ensureRow();
|
||||
const existing = Array.isArray(row.customFeeds) ? row.customFeeds : [];
|
||||
const next = existing.filter((f) => f.id !== id);
|
||||
if (next.length === existing.length) return;
|
||||
const diff: Partial<LocalPreferences> = {
|
||||
customFeeds: next,
|
||||
};
|
||||
await encryptRecord('newsPreferences', diff);
|
||||
await preferencesTable.update(PREFERENCES_ID, diff);
|
||||
},
|
||||
|
||||
async resetWeights(): Promise<void> {
|
||||
await ensureRow();
|
||||
const diff: Partial<LocalPreferences> = {
|
||||
topicWeights: {},
|
||||
sourceWeights: {},
|
||||
};
|
||||
await encryptRecord('newsPreferences', diff);
|
||||
await preferencesTable.update(PREFERENCES_ID, diff);
|
||||
},
|
||||
};
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
/**
|
||||
* Reactions store — records the user's per-article feedback and pipes
|
||||
* the matching weight delta into the preferences store in the same
|
||||
* call. Two writes, one logical operation:
|
||||
*
|
||||
* 1. add a `newsReactions` row (drops the article from `reactedIds`
|
||||
* so the feed engine stops surfacing it)
|
||||
* 2. apply the weight diff back to `newsPreferences`
|
||||
*
|
||||
* The reaction row stays around so undo / "show what I dismissed"
|
||||
* stays cheap. The preferences diff is what makes the suppression
|
||||
* persist across cache refreshes.
|
||||
*/
|
||||
|
||||
import { encryptRecord } from '$lib/data/crypto';
|
||||
import { reactionTable } from '../collections';
|
||||
import { applyReaction as computeWeightDiff } from '../feed-engine';
|
||||
import { preferencesStore } from './preferences.svelte';
|
||||
import type { LocalReaction, ReactionKind } from '../types';
|
||||
|
||||
async function loadCurrentPrefs() {
|
||||
return preferencesStore.get();
|
||||
}
|
||||
|
||||
export const reactionsStore = {
|
||||
async react(input: {
|
||||
articleId: string;
|
||||
reaction: ReactionKind;
|
||||
topic: string;
|
||||
sourceSlug: string;
|
||||
}): Promise<void> {
|
||||
const prefs = await loadCurrentPrefs();
|
||||
|
||||
// 1. Persist the reaction row.
|
||||
const row: LocalReaction = {
|
||||
id: crypto.randomUUID(),
|
||||
articleId: input.articleId,
|
||||
reaction: input.reaction,
|
||||
topic: input.topic,
|
||||
sourceSlug: input.sourceSlug,
|
||||
};
|
||||
await encryptRecord('newsReactions', row);
|
||||
await reactionTable.add(row);
|
||||
|
||||
// 2. Update preferences (weight + blocklist) in lockstep.
|
||||
const diff = computeWeightDiff(prefs, input.reaction, input.topic, input.sourceSlug);
|
||||
if (Object.keys(diff).length > 0) {
|
||||
await preferencesStore.applyWeightDiff(diff);
|
||||
}
|
||||
},
|
||||
|
||||
async undo(reactionId: string): Promise<void> {
|
||||
// Soft-delete: tombstone the reaction so the article shows up
|
||||
// again in the feed. Weights stay where they were — undoing a
|
||||
// thumbs-down doesn't *boost* the source, it just stops further
|
||||
// suppression.
|
||||
await reactionTable.update(reactionId, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
/**
|
||||
* News Tools — LLM-accessible operations for the news module.
|
||||
*
|
||||
* `save_news_article` is the agent's path into the user's reading list.
|
||||
* M5 moved the saved-article storage to the `articles` module; this
|
||||
* tool now routes through `articlesStore.saveFromUrl(url)` there. The
|
||||
* tool name stays `save_news_article` because historic AI mission
|
||||
* iterations in the DB reference it — renaming would break the audit
|
||||
* trail. A future `save_article` can be added as an alias in M6.
|
||||
*
|
||||
* `title` and `summary` are display hints for the approval dialog —
|
||||
* the canonical title/excerpt come from the extractor so the AI can't
|
||||
* lie about content.
|
||||
*/
|
||||
|
||||
import type { ModuleTool } from '$lib/data/tools/types';
|
||||
import { articlesStore } from '$lib/modules/articles/stores/articles.svelte';
|
||||
|
||||
export const newsTools: ModuleTool[] = [
|
||||
{
|
||||
name: 'save_news_article',
|
||||
module: 'news',
|
||||
description:
|
||||
'Speichert einen Artikel von einer URL in die Leseliste. URL wird serverseitig per Readability extrahiert.',
|
||||
parameters: [
|
||||
{ name: 'url', type: 'string', description: 'Die Artikel-URL', required: true },
|
||||
{
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
description: 'Anzeigetitel für den Approval-Dialog (informativ)',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'summary',
|
||||
type: 'string',
|
||||
description: 'Kurze Begründung warum dieser Artikel relevant ist',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
async execute(params) {
|
||||
const url = params.url as string;
|
||||
const { article, duplicate } = await articlesStore.saveFromUrl(url);
|
||||
return {
|
||||
success: true,
|
||||
message: duplicate
|
||||
? `Artikel bereits gespeichert: ${article.title}`
|
||||
: `Artikel gespeichert: ${article.title}`,
|
||||
data: { articleId: article.id, title: article.title, duplicate },
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
/**
|
||||
* News module types — local-first reading hub backed by the curated
|
||||
* pool from `news.curated_articles` (see services/news-ingester).
|
||||
*
|
||||
* Local data is split across five Dexie tables:
|
||||
*
|
||||
* newsArticles — saved reading list (encrypted)
|
||||
* newsCategories — user-defined folders for the reading list
|
||||
* newsPreferences — singleton row: topics, blocklist, weights
|
||||
* newsReactions — per-article feedback signals
|
||||
* newsCachedFeed — local mirror of the server's curated pool
|
||||
* (NOT synced, NOT encrypted)
|
||||
*/
|
||||
|
||||
import type { BaseRecord } from '@mana/local-store';
|
||||
|
||||
export type Topic =
|
||||
| 'tech'
|
||||
| 'wissenschaft'
|
||||
| 'weltgeschehen'
|
||||
| 'wirtschaft'
|
||||
| 'kultur'
|
||||
| 'gesundheit'
|
||||
| 'politik';
|
||||
|
||||
export const ALL_TOPICS: readonly Topic[] = [
|
||||
'tech',
|
||||
'wissenschaft',
|
||||
'weltgeschehen',
|
||||
'wirtschaft',
|
||||
'kultur',
|
||||
'gesundheit',
|
||||
'politik',
|
||||
];
|
||||
|
||||
export type Language = 'de' | 'en';
|
||||
|
||||
export type ReactionKind = 'interested' | 'not_interested' | 'source_blocked' | 'hidden';
|
||||
|
||||
// ─── Saved reading list ────────────────────────────────────
|
||||
|
||||
export interface LocalArticle extends BaseRecord {
|
||||
/** 'curated' = saved from the server pool, 'saved' = ad-hoc URL extract. */
|
||||
type: 'curated' | 'saved';
|
||||
/** Foreign key into the server's curated pool when type='curated'. */
|
||||
sourceCuratedId?: string | null;
|
||||
originalUrl: string;
|
||||
title: string;
|
||||
excerpt: string | null;
|
||||
content: string;
|
||||
htmlContent: string | null;
|
||||
author: string | null;
|
||||
siteName: string | null;
|
||||
sourceSlug: string | null;
|
||||
imageUrl: string | null;
|
||||
categoryId: string | null;
|
||||
wordCount: number | null;
|
||||
readingTimeMinutes: number | null;
|
||||
publishedAt: string | null;
|
||||
isArchived: boolean;
|
||||
isRead: boolean;
|
||||
isFavorite: boolean;
|
||||
}
|
||||
|
||||
export interface LocalCategory extends BaseRecord {
|
||||
name: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
// ─── Preferences (singleton) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* The single row id for the preferences singleton — there is exactly
|
||||
* one preferences row per user, so we use a stable string instead of a
|
||||
* uuid to make upserts idempotent.
|
||||
*/
|
||||
export const PREFERENCES_ID = 'singleton';
|
||||
|
||||
export interface CustomFeed {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
/** Optional topic tag from the standard taxonomy. */
|
||||
topic?: Topic;
|
||||
/** Epoch ms when the user pinned this feed. */
|
||||
pinnedAt: number;
|
||||
}
|
||||
|
||||
export interface LocalPreferences extends BaseRecord {
|
||||
id: string;
|
||||
selectedTopics: Topic[];
|
||||
blockedSources: string[];
|
||||
preferredLanguages: Language[];
|
||||
/** topic slug → weight (default 1.0, range ~0.1 to 3.0). */
|
||||
topicWeights: Record<string, number>;
|
||||
/** source slug → weight (default 1.0, range ~0.1 to 3.0). */
|
||||
sourceWeights: Record<string, number>;
|
||||
onboardingCompleted: boolean;
|
||||
/**
|
||||
* User-subscribed RSS feeds, populated from the News Research module's
|
||||
* "Pin feed" action. Not ingested centrally — the client fetches these
|
||||
* on its own schedule (see feed-cache).
|
||||
*/
|
||||
customFeeds?: CustomFeed[];
|
||||
}
|
||||
|
||||
// ─── Reactions ─────────────────────────────────────────────
|
||||
|
||||
export interface LocalReaction extends BaseRecord {
|
||||
/** The curated article id (server-side uuid from the pool). */
|
||||
articleId: string;
|
||||
reaction: ReactionKind;
|
||||
/** Denormalized for O(1) weight updates without a join. */
|
||||
sourceSlug: string;
|
||||
topic: string;
|
||||
}
|
||||
|
||||
// ─── Cached pool mirror (local only) ───────────────────────
|
||||
|
||||
export interface LocalCachedArticle {
|
||||
/** Server-side curated_articles.id. Used as the dedupe key. */
|
||||
id: string;
|
||||
originalUrl: string;
|
||||
title: string;
|
||||
excerpt: string | null;
|
||||
content: string;
|
||||
htmlContent: string | null;
|
||||
author: string | null;
|
||||
siteName: string;
|
||||
sourceSlug: string;
|
||||
imageUrl: string | null;
|
||||
topic: string;
|
||||
language: string;
|
||||
wordCount: number | null;
|
||||
readingTimeMinutes: number | null;
|
||||
publishedAt: string | null;
|
||||
ingestedAt: string;
|
||||
/** Local timestamp when this row entered the cache. */
|
||||
cachedAt: string;
|
||||
}
|
||||
|
||||
// ─── Public DTOs (rendered by views) ───────────────────────
|
||||
|
||||
export interface Article {
|
||||
id: string;
|
||||
type: 'curated' | 'saved';
|
||||
sourceCuratedId?: string;
|
||||
originalUrl: string;
|
||||
title: string;
|
||||
excerpt: string | null;
|
||||
content: string;
|
||||
htmlContent: string | null;
|
||||
author: string | null;
|
||||
siteName: string | null;
|
||||
sourceSlug: string | null;
|
||||
imageUrl: string | null;
|
||||
categoryId: string | null;
|
||||
wordCount: number | null;
|
||||
readingTimeMinutes: number | null;
|
||||
publishedAt: string | null;
|
||||
isArchived: boolean;
|
||||
isRead: boolean;
|
||||
isFavorite: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Preferences {
|
||||
id: string;
|
||||
selectedTopics: Topic[];
|
||||
blockedSources: string[];
|
||||
preferredLanguages: Language[];
|
||||
topicWeights: Record<string, number>;
|
||||
sourceWeights: Record<string, number>;
|
||||
onboardingCompleted: boolean;
|
||||
customFeeds: CustomFeed[];
|
||||
}
|
||||
|
||||
export interface Reaction {
|
||||
id: string;
|
||||
articleId: string;
|
||||
reaction: ReactionKind;
|
||||
sourceSlug: string;
|
||||
topic: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* NewsUnreadWidget — three top-ranked unread articles from the user's
|
||||
* curated feed, surfaced on the dashboard.
|
||||
*
|
||||
* Reads:
|
||||
* - newsCachedFeed (the local pool mirror — plaintext, no decrypt)
|
||||
* - newsPreferences singleton (decrypts to apply topic/lang filters)
|
||||
* - newsReactions (decrypts to skip already-rated articles)
|
||||
*
|
||||
* The widget intentionally does NOT trigger a feed refresh — that's
|
||||
* the news layout's job. If the user has never opened /news, the
|
||||
* pool is empty and the widget shows the empty state with a CTA.
|
||||
*/
|
||||
|
||||
import { liveQuery } from 'dexie';
|
||||
import {
|
||||
cachedFeedTable,
|
||||
preferencesTable,
|
||||
reactionTable,
|
||||
DEFAULT_PREFERENCES,
|
||||
} from '$lib/modules/news/collections';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { rankFeed, buildReactionSets } from '$lib/modules/news/feed-engine';
|
||||
import { toPreferences, toReaction, formatRelativeTime } from '$lib/modules/news/queries';
|
||||
import { PREFERENCES_ID, type LocalCachedArticle } from '$lib/modules/news/types';
|
||||
|
||||
let topThree = $state<LocalCachedArticle[]>([]);
|
||||
let loading = $state(true);
|
||||
let onboardingDone = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
const sub = liveQuery(async () => {
|
||||
const [pool, prefsRow, reactionsRows] = await Promise.all([
|
||||
cachedFeedTable.toArray(),
|
||||
preferencesTable.get(PREFERENCES_ID),
|
||||
reactionTable.toArray(),
|
||||
]);
|
||||
|
||||
// Decrypt prefs + reactions (cache stays plaintext).
|
||||
const prefs = prefsRow
|
||||
? toPreferences(
|
||||
(await decryptRecords('newsPreferences', [prefsRow]))[0] ?? DEFAULT_PREFERENCES
|
||||
)
|
||||
: toPreferences(DEFAULT_PREFERENCES);
|
||||
|
||||
const visibleReactions = reactionsRows.filter((r) => !r.deletedAt);
|
||||
const reactions = (await decryptRecords('newsReactions', visibleReactions)).map(toReaction);
|
||||
|
||||
return {
|
||||
prefs,
|
||||
ranked: rankFeed(pool, { prefs, ...buildReactionSets(reactions) }),
|
||||
};
|
||||
}).subscribe({
|
||||
next: ({ prefs, ranked }) => {
|
||||
onboardingDone = prefs.onboardingCompleted;
|
||||
topThree = ranked.slice(0, 3).map((s) => s.article);
|
||||
loading = false;
|
||||
},
|
||||
error: () => {
|
||||
loading = false;
|
||||
},
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="flex items-center gap-2 text-lg font-semibold">
|
||||
<span aria-hidden="true">📰</span>
|
||||
News
|
||||
</h3>
|
||||
<a href="/news" class="text-xs text-muted-foreground hover:text-foreground">Alle →</a>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-2">
|
||||
{#each Array(3) as _}
|
||||
<div class="h-12 animate-pulse rounded bg-surface-hover"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if !onboardingDone}
|
||||
<div class="py-4 text-center">
|
||||
<p class="text-sm text-muted-foreground">Richte deinen Newsfeed ein.</p>
|
||||
<a
|
||||
href="/news"
|
||||
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
|
||||
>
|
||||
Jetzt starten
|
||||
</a>
|
||||
</div>
|
||||
{:else if topThree.length === 0}
|
||||
<div class="py-4 text-center">
|
||||
<p class="text-sm text-muted-foreground">Keine neuen Artikel.</p>
|
||||
<a href="/news" class="mt-3 inline-block text-xs text-primary hover:underline">
|
||||
Feed öffnen
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each topThree as article (article.id)}
|
||||
<a
|
||||
href="/news/{article.id}"
|
||||
class="block rounded-lg p-2 transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
{#if article.imageUrl}
|
||||
<img
|
||||
src={article.imageUrl}
|
||||
alt=""
|
||||
class="h-12 w-16 flex-shrink-0 rounded object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="line-clamp-2 text-sm font-medium leading-snug">{article.title}</p>
|
||||
<div class="mt-0.5 flex gap-1 text-xs text-muted-foreground">
|
||||
<span class="font-medium">{article.siteName}</span>
|
||||
<span>·</span>
|
||||
<span>{formatRelativeTime(article.publishedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -27,7 +27,6 @@ export type WidgetType =
|
|||
| 'day-timeline' // TimeBlocks: chronological day timeline
|
||||
| 'activity-feed' // TimeBlocks: recent activity across modules
|
||||
| 'period' // Period: current phase + days until next period
|
||||
| 'news-unread' // News: latest unread curated articles
|
||||
| 'articles-unread' // Articles: saved read-it-later articles
|
||||
| 'body-stats' // Body: latest weight + active workout summary
|
||||
| 'invoices-open' // Invoices: open/overdue totals + oldest overdue
|
||||
|
|
@ -306,14 +305,6 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
|
|||
allowMultiple: false,
|
||||
requiredBackend: 'period',
|
||||
},
|
||||
{
|
||||
type: 'news-unread',
|
||||
nameKey: 'dashboard.widgets.news_unread.title',
|
||||
descriptionKey: 'dashboard.widgets.news_unread.description',
|
||||
icon: '📰',
|
||||
defaultSize: 'small',
|
||||
allowMultiple: false,
|
||||
},
|
||||
{
|
||||
type: 'articles-unread',
|
||||
nameKey: 'dashboard.widgets.articles_unread.title',
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@
|
|||
import { todoReminderSource } from '$lib/modules/todo/reminder-source';
|
||||
import { startEventStore, stopEventStore } from '$lib/data/events/event-store';
|
||||
import { startMissionTick, stopMissionTick } from '$lib/data/ai/missions/setup';
|
||||
import { runArticlesFromNewsMigration } from '$lib/modules/articles/migrations/from-news';
|
||||
import {
|
||||
startServerIterationExecutor,
|
||||
stopServerIterationExecutor,
|
||||
|
|
@ -599,10 +598,6 @@
|
|||
// Apply server-planned iterations locally on sync — see
|
||||
// data/ai/missions/server-iteration-executor.ts.
|
||||
startServerIterationExecutor();
|
||||
// One-off migration: legacy news `type='saved'` rows → new
|
||||
// articles module. Sentinel-gated so it runs once per device.
|
||||
// See modules/articles/migrations/from-news.ts.
|
||||
void runArticlesFromNewsMigration();
|
||||
});
|
||||
|
||||
// Restore nav collapsed state (cheap, keep inline)
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import { researchSessionStore } from '$lib/modules/news-research/stores/session.svelte';
|
||||
import { articlesStore } from '$lib/modules/articles/stores/articles.svelte';
|
||||
import { preferencesStore } from '$lib/modules/news/stores/preferences.svelte';
|
||||
import { usePreferences } from '$lib/modules/news/queries';
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
|
||||
let mode = $state<'query' | 'site'>('query');
|
||||
|
|
@ -22,20 +20,6 @@
|
|||
let saveError = $state<string | null>(null);
|
||||
|
||||
const store = researchSessionStore;
|
||||
const prefs$ = usePreferences();
|
||||
const pinnedUrls = $derived(new Set((prefs$.value?.customFeeds ?? []).map((f) => f.url)));
|
||||
|
||||
async function togglePin(feed: { url: string; title: string | null }) {
|
||||
if (pinnedUrls.has(feed.url)) {
|
||||
const existing = (prefs$.value?.customFeeds ?? []).find((f) => f.url === feed.url);
|
||||
if (existing) await preferencesStore.unpinCustomFeed(existing.id);
|
||||
} else {
|
||||
await preferencesStore.pinCustomFeed({
|
||||
url: feed.url,
|
||||
title: feed.title ?? feed.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function isUrl(s: string): boolean {
|
||||
try {
|
||||
|
|
@ -175,18 +159,6 @@
|
|||
<span class="feed-title">{feed.title ?? feed.url}</span>
|
||||
<span class="feed-type">{feed.type}</span>
|
||||
{#if feed.sourceHit}<span class="feed-src">{feed.sourceHit}</span>{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="pin"
|
||||
class:pinned={pinnedUrls.has(feed.url)}
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
togglePin(feed);
|
||||
}}
|
||||
title={pinnedUrls.has(feed.url) ? 'Abo entfernen' : 'Als Abo speichern'}
|
||||
>
|
||||
{pinnedUrls.has(feed.url) ? '★ Abonniert' : '☆ Abonnieren'}
|
||||
</button>
|
||||
</label>
|
||||
</li>
|
||||
{/each}
|
||||
|
|
@ -372,21 +344,6 @@
|
|||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.pin {
|
||||
margin-left: auto;
|
||||
background: transparent;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.15rem 0.55rem;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.pin.pinned {
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground, 0 0% 100%));
|
||||
border-color: transparent;
|
||||
}
|
||||
.result {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
<!--
|
||||
News layout — boots the feed-cache poll loop and tears it down on
|
||||
navigation away. The cached pool is shared across +page.svelte and
|
||||
[id]/+page.svelte (the reader), so it lives at the layout level.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { feedCacheStore } from '$lib/modules/news/stores/feed-cache.svelte';
|
||||
import { usePreferences } from '$lib/modules/news/queries';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
|
||||
const prefs$ = usePreferences();
|
||||
const prefs = $derived(prefs$.value);
|
||||
|
||||
// Refresh whenever the user's topic/lang selection changes — the
|
||||
// server filters server-side, so a different topic mix means a
|
||||
// different cache. The store dedupes concurrent refreshes via its
|
||||
// `inFlight` guard.
|
||||
$effect(() => {
|
||||
if (!prefs.onboardingCompleted) return;
|
||||
void feedCacheStore.refresh({
|
||||
topics: prefs.selectedTopics,
|
||||
lang: prefs.preferredLanguages.length === 1 ? prefs.preferredLanguages[0] : 'all',
|
||||
});
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
// Idempotent — start() is a no-op if the interval is already set.
|
||||
feedCacheStore.start();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
feedCacheStore.stop();
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
|
|
@ -1,777 +0,0 @@
|
|||
<!--
|
||||
News Feed — the main view.
|
||||
|
||||
Two render branches: if the user has not finished onboarding yet,
|
||||
show the topic + language picker inline. Otherwise, render the
|
||||
ranked feed with reaction buttons.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import {
|
||||
usePreferences,
|
||||
useCachedFeed,
|
||||
useReactions,
|
||||
formatRelativeTime,
|
||||
} from '$lib/modules/news/queries';
|
||||
import { rankFeed, buildReactionSets } from '$lib/modules/news/feed-engine';
|
||||
import { preferencesStore } from '$lib/modules/news/stores/preferences.svelte';
|
||||
import { reactionsStore } from '$lib/modules/news/stores/reactions.svelte';
|
||||
import { articlesStore } from '$lib/modules/news/stores/articles.svelte';
|
||||
import { feedCacheStore } from '$lib/modules/news/stores/feed-cache.svelte';
|
||||
import {
|
||||
ALL_TOPICS,
|
||||
type Topic,
|
||||
type Language,
|
||||
type LocalCachedArticle,
|
||||
} from '$lib/modules/news/types';
|
||||
import { TOPIC_LABELS, sourcesForTopic } from '$lib/modules/news/sources-meta';
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
const prefs$ = usePreferences();
|
||||
const pool$ = useCachedFeed();
|
||||
const reactions$ = useReactions();
|
||||
|
||||
const prefs = $derived(prefs$.value);
|
||||
const pool = $derived(pool$.value);
|
||||
const reactions = $derived(reactions$.value);
|
||||
|
||||
// ─── Onboarding state (only used in the onboarding branch) ─
|
||||
let pickedTopics = $state<Topic[]>([]);
|
||||
let pickedLanguages = $state<Language[]>(['de', 'en']);
|
||||
let pickedBlocked = $state<string[]>([]);
|
||||
let onboardingStep = $state<1 | 2 | 3>(1);
|
||||
// Local "just finished" override so the wizard hides immediately on
|
||||
// click instead of waiting for Dexie's liveQuery to debounce + emit
|
||||
// the new prefs.onboardingCompleted = true. Without this, the user
|
||||
// clicks "Fertig", the write goes through, but the UI re-renders
|
||||
// the same wizard step until the next liveQuery tick (~50-100ms),
|
||||
// so people instinctively click again before noticing the change.
|
||||
let onboardingJustFinished = $state(false);
|
||||
let onboardingSubmitting = $state(false);
|
||||
|
||||
function toggleTopic(t: Topic) {
|
||||
pickedTopics = pickedTopics.includes(t)
|
||||
? pickedTopics.filter((x) => x !== t)
|
||||
: [...pickedTopics, t];
|
||||
}
|
||||
function toggleLang(l: Language) {
|
||||
pickedLanguages = pickedLanguages.includes(l)
|
||||
? pickedLanguages.filter((x) => x !== l)
|
||||
: [...pickedLanguages, l];
|
||||
}
|
||||
function toggleBlocked(slug: string) {
|
||||
pickedBlocked = pickedBlocked.includes(slug)
|
||||
? pickedBlocked.filter((x) => x !== slug)
|
||||
: [...pickedBlocked, slug];
|
||||
}
|
||||
|
||||
async function finishOnboarding() {
|
||||
if (onboardingSubmitting) return;
|
||||
onboardingSubmitting = true;
|
||||
try {
|
||||
// $state.snapshot strips the Svelte 5 reactive proxies — without it
|
||||
// the arrays travel into Dexie hooks as proxies and trip
|
||||
// DataCloneError on the structured-clone into _pendingChanges.
|
||||
const topicsSnap = $state.snapshot(pickedTopics) as Topic[];
|
||||
const langsSnap = $state.snapshot(pickedLanguages) as Language[];
|
||||
const blockedSnap = $state.snapshot(pickedBlocked) as string[];
|
||||
|
||||
await preferencesStore.completeOnboarding({
|
||||
topics: topicsSnap,
|
||||
languages: langsSnap,
|
||||
blockedSources: blockedSnap,
|
||||
});
|
||||
|
||||
// Flip to the feed branch immediately. Without this we'd be at
|
||||
// the mercy of Dexie's liveQuery debounce — the prefs read
|
||||
// behind `prefs.onboardingCompleted` only updates a few ticks
|
||||
// after the write, so the wizard would re-render the same
|
||||
// step for ~50-100ms and the user would click "Fertig" twice.
|
||||
onboardingJustFinished = true;
|
||||
|
||||
// Eagerly trigger the first feed pull instead of waiting for
|
||||
// the layout's $effect to notice the prefs change. The layout
|
||||
// effect WILL also fire shortly after, but its refresh is a
|
||||
// no-op via the store's inFlight guard.
|
||||
void feedCacheStore.refresh({
|
||||
topics: topicsSnap,
|
||||
lang: langsSnap.length === 1 ? langsSnap[0] : 'all',
|
||||
});
|
||||
} finally {
|
||||
onboardingSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Feed branch ──────────────────────────────────────────
|
||||
const { dismissedIds, interestedIds } = $derived(buildReactionSets(reactions));
|
||||
// Treat the local "just finished" override as fully onboarded so the
|
||||
// feed renders immediately after the user clicks Fertig, before the
|
||||
// liveQuery has had a chance to refresh prefs.
|
||||
const isOnboarded = $derived(prefs.onboardingCompleted || onboardingJustFinished);
|
||||
const ranked = $derived(
|
||||
isOnboarded ? rankFeed(pool, { prefs, dismissedIds, interestedIds }) : []
|
||||
);
|
||||
|
||||
async function react(
|
||||
article: LocalCachedArticle,
|
||||
kind: 'interested' | 'not_interested' | 'source_blocked'
|
||||
) {
|
||||
await reactionsStore.react({
|
||||
articleId: article.id,
|
||||
reaction: kind,
|
||||
topic: article.topic,
|
||||
sourceSlug: article.sourceSlug,
|
||||
});
|
||||
// "Interessiert" is the implicit save — copy into reading list.
|
||||
if (kind === 'interested') {
|
||||
await articlesStore.saveFromCurated(article);
|
||||
}
|
||||
}
|
||||
|
||||
function openReader(article: LocalCachedArticle) {
|
||||
// We pass the curated id; the reader pulls the row from the
|
||||
// cached feed table itself (no prop drilling).
|
||||
goto(`/news/${article.id}`);
|
||||
}
|
||||
|
||||
async function manualRefresh() {
|
||||
await feedCacheStore.refresh({
|
||||
topics: prefs.selectedTopics,
|
||||
lang: prefs.preferredLanguages.length === 1 ? prefs.preferredLanguages[0] : 'all',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>News — Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<RoutePage appId="news">
|
||||
<div class="news-page">
|
||||
{#if !isOnboarded}
|
||||
<!-- ─── Onboarding ───────────────────────────────────── -->
|
||||
<header class="hero">
|
||||
<h1>{$_('news.onboarding.welcome')}</h1>
|
||||
<p>{$_('news.onboarding.intro')}</p>
|
||||
</header>
|
||||
|
||||
<div class="steps">
|
||||
<span class="step" class:active={onboardingStep === 1}
|
||||
>{$_('news.onboarding.stepTopics')}</span
|
||||
>
|
||||
<span class="step" class:active={onboardingStep === 2}
|
||||
>{$_('news.onboarding.stepLanguage')}</span
|
||||
>
|
||||
<span class="step" class:active={onboardingStep === 3}
|
||||
>{$_('news.onboarding.stepSources')}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if onboardingStep === 1}
|
||||
<section class="step-panel">
|
||||
<h2>{$_('news.onboarding.topicsTitle')}</h2>
|
||||
<p class="hint">{$_('news.onboarding.topicsHint')}</p>
|
||||
<div class="topic-grid">
|
||||
{#each ALL_TOPICS as topic}
|
||||
<button
|
||||
type="button"
|
||||
class="topic-pill"
|
||||
class:selected={pickedTopics.includes(topic)}
|
||||
onclick={() => toggleTopic(topic)}
|
||||
>
|
||||
<span class="topic-emoji">{TOPIC_LABELS[topic].emoji}</span>
|
||||
<span>{TOPIC_LABELS[topic].de}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
disabled={pickedTopics.length < 2}
|
||||
onclick={() => (onboardingStep = 2)}
|
||||
>
|
||||
{$_('news.onboarding.next')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
{:else if onboardingStep === 2}
|
||||
<section class="step-panel">
|
||||
<h2>{$_('news.onboarding.languageTitle')}</h2>
|
||||
<div class="lang-row">
|
||||
<button
|
||||
type="button"
|
||||
class="lang-pill"
|
||||
class:selected={pickedLanguages.includes('de')}
|
||||
onclick={() => toggleLang('de')}
|
||||
>
|
||||
🇩🇪 {$_('news.languages.de')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="lang-pill"
|
||||
class:selected={pickedLanguages.includes('en')}
|
||||
onclick={() => toggleLang('en')}
|
||||
>
|
||||
🇬🇧 {$_('news.languages.en')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-secondary" onclick={() => (onboardingStep = 1)}>
|
||||
{$_('news.onboarding.back')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
disabled={pickedLanguages.length === 0}
|
||||
onclick={() => (onboardingStep = 3)}
|
||||
>
|
||||
{$_('news.onboarding.next')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
<section class="step-panel">
|
||||
<h2>{$_('news.onboarding.sourcesTitle')}</h2>
|
||||
<p class="hint">
|
||||
{$_('news.onboarding.sourcesHint')}
|
||||
</p>
|
||||
<div class="sources-list">
|
||||
{#each pickedTopics as topic}
|
||||
<div class="topic-block">
|
||||
<h3>
|
||||
{TOPIC_LABELS[topic].emoji}
|
||||
{TOPIC_LABELS[topic].de}
|
||||
</h3>
|
||||
<div class="source-row">
|
||||
{#each sourcesForTopic(topic) as src}
|
||||
<button
|
||||
type="button"
|
||||
class="source-chip"
|
||||
class:blocked={pickedBlocked.includes(src.slug)}
|
||||
onclick={() => toggleBlocked(src.slug)}
|
||||
>
|
||||
{src.name}
|
||||
<span class="lang">·{src.language}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-secondary" onclick={() => (onboardingStep = 2)}>
|
||||
{$_('news.onboarding.back')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
onclick={finishOnboarding}
|
||||
disabled={onboardingSubmitting}
|
||||
>
|
||||
{onboardingSubmitting
|
||||
? $_('news.onboarding.finishLoading')
|
||||
: $_('news.onboarding.finish')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- ─── Feed ─────────────────────────────────────────── -->
|
||||
<header class="feed-header">
|
||||
<div>
|
||||
<h1>{$_('news.feed.title')}</h1>
|
||||
<div class="meta">
|
||||
{$_('news.feed.articles', { values: { count: ranked.length } })}
|
||||
{#if feedCacheStore.lastError}
|
||||
· <span class="error">{$_('news.feed.loadError')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
onclick={manualRefresh}
|
||||
disabled={feedCacheStore.inFlight}
|
||||
title={$_('news.feed.refresh')}
|
||||
>
|
||||
{feedCacheStore.inFlight ? '…' : '↻'}
|
||||
</button>
|
||||
<a class="icon-btn" href="/news/saved" title={$_('news.feed.savedLink')}>📑</a>
|
||||
<a class="icon-btn" href="/news/preferences" title={$_('news.feed.settingsLink')}>⚙</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Topic filter strip -->
|
||||
<div class="topic-strip">
|
||||
{#each prefs.selectedTopics as topic}
|
||||
<span class="topic-tag">
|
||||
{TOPIC_LABELS[topic].emoji}
|
||||
{TOPIC_LABELS[topic].de}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if ranked.length === 0}
|
||||
<div class="empty">
|
||||
{#if pool.length === 0}
|
||||
<p>{$_('news.feed.loading')}</p>
|
||||
{:else}
|
||||
<p>{$_('news.feed.empty')}</p>
|
||||
<p class="hint">{$_('news.feed.emptyHint')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card-grid">
|
||||
{#each ranked as scored (scored.article.id)}
|
||||
{@const article = scored.article}
|
||||
{@const isSaved = interestedIds.has(article.id)}
|
||||
<article class="card">
|
||||
{#if article.imageUrl}
|
||||
<button
|
||||
type="button"
|
||||
class="card-image-btn"
|
||||
onclick={() => openReader(article)}
|
||||
aria-label={$_('news.feed.openArticleAria')}
|
||||
>
|
||||
<img src={article.imageUrl} alt="" loading="lazy" />
|
||||
</button>
|
||||
{/if}
|
||||
<div class="card-body" class:is-saved={isSaved}>
|
||||
<div class="card-meta">
|
||||
<span class="source">{article.siteName}</span>
|
||||
<span class="dot">·</span>
|
||||
<span>{formatRelativeTime(article.publishedAt)}</span>
|
||||
{#if article.readingTimeMinutes}
|
||||
<span class="dot">·</span>
|
||||
<span
|
||||
>{$_('news.feed.readingTimeMin', {
|
||||
values: { n: article.readingTimeMinutes },
|
||||
})}</span
|
||||
>
|
||||
{/if}
|
||||
{#if isSaved}
|
||||
<span class="saved-badge" title={$_('news.feed.savedBadgeTitle')}
|
||||
>{$_('news.feed.savedBadgeText')}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<button type="button" class="card-title-btn" onclick={() => openReader(article)}>
|
||||
{article.title}
|
||||
</button>
|
||||
{#if article.excerpt}
|
||||
<p class="card-excerpt">{article.excerpt}</p>
|
||||
{/if}
|
||||
<div class="card-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="reaction-btn interested"
|
||||
class:active={isSaved}
|
||||
onclick={() => react(article, 'interested')}
|
||||
title={isSaved
|
||||
? $_('news.reactions.interestedSavedTitle')
|
||||
: $_('news.reactions.interestedTitle')}
|
||||
disabled={isSaved}
|
||||
>
|
||||
❤️ {isSaved
|
||||
? $_('news.reactions.interestedSaved')
|
||||
: $_('news.reactions.interested')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="reaction-btn not-interested"
|
||||
onclick={() => react(article, 'not_interested')}
|
||||
title={$_('news.reactions.notInterestedTitle')}
|
||||
>
|
||||
👎 {$_('news.reactions.notInterested')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="reaction-btn block"
|
||||
onclick={() => react(article, 'source_blocked')}
|
||||
title={$_('news.reactions.blockSource')}
|
||||
>
|
||||
🚫 {article.siteName}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</RoutePage>
|
||||
|
||||
<style>
|
||||
.news-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
padding: 0 1rem 4rem;
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ─── Onboarding ─── */
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding: 1.5rem 0 0.5rem;
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.hero p {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.steps {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.step {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.step.active {
|
||||
background: hsl(var(--color-primary) / 0.15);
|
||||
color: hsl(var(--color-primary));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
background: hsl(var(--color-muted));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 1rem;
|
||||
}
|
||||
.step-panel h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.step-panel h3 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.hint {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.topic-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.topic-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(var(--color-background));
|
||||
border: 2px solid hsl(var(--color-border));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.9375rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.topic-pill:hover {
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
.topic-pill.selected {
|
||||
border-color: hsl(var(--color-primary));
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
}
|
||||
.topic-emoji {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.lang-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.lang-pill {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(var(--color-background));
|
||||
border: 2px solid hsl(var(--color-border));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.lang-pill.selected {
|
||||
border-color: hsl(var(--color-primary));
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
}
|
||||
|
||||
.sources-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.topic-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.source-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.source-chip {
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--color-background));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.source-chip .lang {
|
||||
opacity: 0.55;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
.source-chip.blocked {
|
||||
opacity: 0.4;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
.btn-primary {
|
||||
background: hsl(var(--color-primary));
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
/* ─── Feed ─── */
|
||||
.feed-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.feed-header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.meta {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.meta .error {
|
||||
color: hsl(var(--color-destructive));
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-muted));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.icon-btn:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.topic-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.topic-tag {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 3rem 0;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: hsl(var(--color-muted));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-image-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: hsl(var(--color-background));
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-image-btn img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.875rem 1rem 1rem;
|
||||
}
|
||||
.card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.card-meta .source {
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.card-meta .dot {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.card-title-btn {
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.card-title-btn:hover {
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
.card-excerpt {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.reaction-btn {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--color-background));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
color: hsl(var(--color-foreground));
|
||||
cursor: pointer;
|
||||
}
|
||||
.reaction-btn:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.reaction-btn.interested:hover {
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
.reaction-btn.interested.active {
|
||||
background: hsl(var(--color-primary) / 0.18);
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
color: hsl(var(--color-primary));
|
||||
cursor: default;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.reaction-btn.interested.active:hover {
|
||||
filter: none;
|
||||
}
|
||||
.saved-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--color-primary) / 0.15);
|
||||
color: hsl(var(--color-primary));
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
margin-left: auto;
|
||||
}
|
||||
.card-body.is-saved {
|
||||
/* Subtle visual cue that this article is in the reading list,
|
||||
* but still readable as a feed card. */
|
||||
background: hsl(var(--color-primary) / 0.04);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,334 +0,0 @@
|
|||
<!--
|
||||
Article reader.
|
||||
|
||||
Resolves the [id] param two ways:
|
||||
1. If it matches a row in `newsCachedFeed` (curated pool), render
|
||||
that row directly.
|
||||
2. Otherwise, fall back to `newsArticles` (the saved reading list)
|
||||
and render through the decryption-aware `useArticle` hook.
|
||||
|
||||
This dual-source lookup keeps the URL stable across "I just saved
|
||||
this article" → reload — both points end up at /news/<curated-id>.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { cachedFeedTable, articleTable } from '$lib/modules/news/collections';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { toArticle, formatRelativeTime } from '$lib/modules/news/queries';
|
||||
import { articlesStore } from '$lib/modules/news/stores/articles.svelte';
|
||||
import { reactionsStore } from '$lib/modules/news/stores/reactions.svelte';
|
||||
import type { LocalCachedArticle, Article } from '$lib/modules/news/types';
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
|
||||
const id = $derived($page.params.id ?? '');
|
||||
|
||||
type Loaded =
|
||||
| { kind: 'curated'; article: LocalCachedArticle }
|
||||
| { kind: 'saved'; article: Article }
|
||||
| { kind: 'missing' };
|
||||
|
||||
let loaded = $state<Loaded | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const currentId = id;
|
||||
if (!currentId) {
|
||||
loaded = { kind: 'missing' };
|
||||
return;
|
||||
}
|
||||
const obs = liveQuery(async () => {
|
||||
// Curated pool first.
|
||||
const cached = await cachedFeedTable.get(currentId);
|
||||
if (cached) return { kind: 'curated' as const, article: cached };
|
||||
// Saved list — by sourceCuratedId first, then by primary key.
|
||||
const savedByCurated = await articleTable.where('sourceCuratedId').equals(currentId).first();
|
||||
if (savedByCurated) {
|
||||
const [decrypted] = await decryptRecords('newsArticles', [savedByCurated]);
|
||||
return { kind: 'saved' as const, article: toArticle(decrypted) };
|
||||
}
|
||||
const savedById = await articleTable.get(currentId);
|
||||
if (savedById) {
|
||||
const [decrypted] = await decryptRecords('newsArticles', [savedById]);
|
||||
return { kind: 'saved' as const, article: toArticle(decrypted) };
|
||||
}
|
||||
return { kind: 'missing' as const };
|
||||
});
|
||||
const sub = obs.subscribe((value) => (loaded = value));
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
|
||||
const html = $derived(
|
||||
loaded && loaded.kind !== 'missing'
|
||||
? loaded.kind === 'curated'
|
||||
? loaded.article.htmlContent
|
||||
: loaded.article.htmlContent
|
||||
: null
|
||||
);
|
||||
const plain = $derived(
|
||||
loaded && loaded.kind !== 'missing'
|
||||
? loaded.kind === 'curated'
|
||||
? loaded.article.content
|
||||
: loaded.article.content
|
||||
: null
|
||||
);
|
||||
const title = $derived(loaded && loaded.kind !== 'missing' ? loaded.article.title : '');
|
||||
const meta = $derived.by(() => {
|
||||
if (!loaded || loaded.kind === 'missing') return null;
|
||||
const a = loaded.article;
|
||||
return {
|
||||
siteName: a.siteName,
|
||||
author: a.author,
|
||||
publishedAt: a.publishedAt,
|
||||
readingTimeMinutes: a.readingTimeMinutes,
|
||||
originalUrl: a.originalUrl,
|
||||
imageUrl: a.imageUrl,
|
||||
};
|
||||
});
|
||||
|
||||
let fontSize = $state(1);
|
||||
|
||||
async function saveAndStay() {
|
||||
if (!loaded || loaded.kind !== 'curated') return;
|
||||
await articlesStore.saveFromCurated(loaded.article);
|
||||
await reactionsStore.react({
|
||||
articleId: loaded.article.id,
|
||||
reaction: 'interested',
|
||||
topic: loaded.article.topic,
|
||||
sourceSlug: loaded.article.sourceSlug,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{title || 'Lese-Ansicht'} — Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<RoutePage appId="news" backHref="/news" title="Artikel">
|
||||
<div class="reader-shell" style:--reader-font-size="{fontSize}rem">
|
||||
<header class="reader-bar">
|
||||
<button type="button" class="bar-btn" onclick={() => goto('/news')}>← Zurück</button>
|
||||
<div class="bar-spacer"></div>
|
||||
<button
|
||||
type="button"
|
||||
class="bar-btn"
|
||||
onclick={() => (fontSize = Math.max(0.875, fontSize - 0.0625))}
|
||||
title="Kleiner"
|
||||
>
|
||||
A−
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="bar-btn"
|
||||
onclick={() => (fontSize = Math.min(1.25, fontSize + 0.0625))}
|
||||
title="Größer"
|
||||
>
|
||||
A+
|
||||
</button>
|
||||
{#if loaded?.kind === 'curated'}
|
||||
<button type="button" class="bar-btn primary" onclick={saveAndStay}>❤️ Speichern</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if !loaded}
|
||||
<div class="placeholder">Lade…</div>
|
||||
{:else if loaded.kind === 'missing'}
|
||||
<div class="placeholder">
|
||||
<p>Artikel nicht gefunden.</p>
|
||||
<button type="button" class="bar-btn" onclick={() => goto('/news')}>Zurück zum Feed</button>
|
||||
</div>
|
||||
{:else}
|
||||
<article class="reader">
|
||||
{#if meta?.imageUrl}
|
||||
<img class="hero-image" src={meta.imageUrl} alt="" />
|
||||
{/if}
|
||||
<h1 class="reader-title">{title}</h1>
|
||||
<div class="reader-meta">
|
||||
{#if meta?.siteName}
|
||||
<span class="site">{meta.siteName}</span>
|
||||
{/if}
|
||||
{#if meta?.author}
|
||||
<span>·</span>
|
||||
<span>{meta.author}</span>
|
||||
{/if}
|
||||
{#if meta?.publishedAt}
|
||||
<span>·</span>
|
||||
<span>{formatRelativeTime(meta.publishedAt)}</span>
|
||||
{/if}
|
||||
{#if meta?.readingTimeMinutes}
|
||||
<span>·</span>
|
||||
<span>{meta.readingTimeMinutes} min</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if html}
|
||||
<!--
|
||||
Curated pool stores Mozilla Readability HTML which is
|
||||
already a stripped-down article DOM. We render it as-is
|
||||
through Svelte's @html. Source: news.curated_articles in
|
||||
our own backend, populated by the news-ingester service —
|
||||
so the trust boundary is "we trust our own ingester
|
||||
output", same as for chat/messages and notes/content.
|
||||
-->
|
||||
<div class="reader-content prose">{@html html}</div>
|
||||
{:else if plain}
|
||||
<div class="reader-content prose">
|
||||
{#each plain.split('\n\n') as para}
|
||||
<p>{para}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if meta?.originalUrl}
|
||||
<footer class="reader-footer">
|
||||
<a class="external-link" href={meta.originalUrl} target="_blank" rel="noreferrer">
|
||||
Original öffnen ↗
|
||||
</a>
|
||||
</footer>
|
||||
{/if}
|
||||
</article>
|
||||
{/if}
|
||||
</div>
|
||||
</RoutePage>
|
||||
|
||||
<style>
|
||||
.reader-shell {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem 4rem;
|
||||
}
|
||||
|
||||
.reader-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0;
|
||||
background: hsl(var(--color-background) / 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.bar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.bar-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-muted));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bar-btn.primary {
|
||||
background: hsl(var(--color-primary));
|
||||
color: white;
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
text-align: center;
|
||||
padding: 4rem 0;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.reader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.hero-image {
|
||||
width: 100%;
|
||||
max-height: 360px;
|
||||
object-fit: cover;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.reader-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.reader-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.reader-meta .site {
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.reader-content {
|
||||
font-size: var(--reader-font-size, 1rem);
|
||||
line-height: 1.7;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.reader-content :global(p) {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.reader-content :global(h2),
|
||||
.reader-content :global(h3) {
|
||||
margin-top: 1.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.reader-content :global(h2) {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
.reader-content :global(h3) {
|
||||
font-size: 1.15em;
|
||||
}
|
||||
.reader-content :global(a) {
|
||||
color: hsl(var(--color-primary));
|
||||
text-decoration: underline;
|
||||
}
|
||||
.reader-content :global(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.reader-content :global(blockquote) {
|
||||
border-left: 3px solid hsl(var(--color-primary));
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 1rem 0;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-style: italic;
|
||||
}
|
||||
.reader-content :global(pre) {
|
||||
background: hsl(var(--color-muted));
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.reader-content :global(code) {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.reader-footer {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
.external-link {
|
||||
color: hsl(var(--color-primary));
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.external-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
<!--
|
||||
Legacy redirect: /news/add → /articles/add.
|
||||
The ad-hoc URL-save flow moved to the articles module in M5.
|
||||
Kept here so existing bookmarks and cross-links keep working.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
|
||||
onMount(() => {
|
||||
goto('/articles/add', { replaceState: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
<RoutePage appId="news" backHref="/news">
|
||||
<p class="redirect">Verschoben nach <a href="/articles/add">/articles/add</a>…</p>
|
||||
</RoutePage>
|
||||
|
||||
<style>
|
||||
.redirect {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,242 +0,0 @@
|
|||
<!--
|
||||
/news/preferences — topics, languages, reset weights, onboarding rerun.
|
||||
Reached via the ⚙ button in the news module; not a workbench card.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { usePreferences } from '$lib/modules/news/queries';
|
||||
import { preferencesStore } from '$lib/modules/news/stores/preferences.svelte';
|
||||
import { ALL_TOPICS, type Topic, type Language } from '$lib/modules/news/types';
|
||||
import { TOPIC_LABELS } from '$lib/modules/news/sources-meta';
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
const prefs$ = usePreferences();
|
||||
const prefs = $derived(prefs$.value);
|
||||
|
||||
let topicWeightCount = $derived(Object.keys(prefs.topicWeights).length);
|
||||
let sourceWeightCount = $derived(Object.keys(prefs.sourceWeights).length);
|
||||
|
||||
async function toggleTopic(t: Topic) {
|
||||
const next = prefs.selectedTopics.includes(t)
|
||||
? prefs.selectedTopics.filter((x) => x !== t)
|
||||
: [...prefs.selectedTopics, t];
|
||||
await preferencesStore.setTopics(next);
|
||||
}
|
||||
async function toggleLang(l: Language) {
|
||||
const next = prefs.preferredLanguages.includes(l)
|
||||
? prefs.preferredLanguages.filter((x) => x !== l)
|
||||
: [...prefs.preferredLanguages, l];
|
||||
await preferencesStore.setLanguages(next);
|
||||
}
|
||||
async function resetWeights() {
|
||||
if (!confirm($_('news.preferences.weightsResetConfirm'))) return;
|
||||
await preferencesStore.resetWeights();
|
||||
}
|
||||
async function rerunOnboarding() {
|
||||
await preferencesStore.applyWeightDiff({});
|
||||
const { preferencesTable } = await import('$lib/modules/news/collections');
|
||||
const { encryptRecord } = await import('$lib/data/crypto');
|
||||
const { PREFERENCES_ID } = await import('$lib/modules/news/types');
|
||||
const diff = { onboardingCompleted: false, updatedAt: new Date().toISOString() };
|
||||
await encryptRecord('newsPreferences', diff);
|
||||
await preferencesTable.update(PREFERENCES_ID, diff);
|
||||
goto('/news');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('news.preferences.page_title_html')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<RoutePage appId="news" backHref="/news">
|
||||
<div class="pane">
|
||||
<header class="bar">
|
||||
<div class="title">
|
||||
<strong>{$_('news.preferences.title')}</strong>
|
||||
<span class="sub">{$_('news.preferences.subtitle')}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="card">
|
||||
<h2>{$_('news.preferences.topicsHeading')}</h2>
|
||||
<p class="hint">{$_('news.preferences.topicsHint')}</p>
|
||||
<div class="grid">
|
||||
{#each ALL_TOPICS as topic}
|
||||
<button
|
||||
type="button"
|
||||
class="pill"
|
||||
class:selected={prefs.selectedTopics.includes(topic)}
|
||||
onclick={() => toggleTopic(topic)}
|
||||
>
|
||||
<span class="emoji">{TOPIC_LABELS[topic].emoji}</span>
|
||||
<span>{TOPIC_LABELS[topic].de}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>{$_('news.preferences.languagesHeading')}</h2>
|
||||
<div class="row">
|
||||
<button
|
||||
type="button"
|
||||
class="pill"
|
||||
class:selected={prefs.preferredLanguages.includes('de')}
|
||||
onclick={() => toggleLang('de')}
|
||||
>
|
||||
🇩🇪 {$_('news.languages.de')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pill"
|
||||
class:selected={prefs.preferredLanguages.includes('en')}
|
||||
onclick={() => toggleLang('en')}
|
||||
>
|
||||
🇬🇧 {$_('news.languages.en')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>{$_('news.preferences.sourcesHeading')}</h2>
|
||||
<p class="hint">
|
||||
{@html $_('news.preferences.sourcesHintHtml', {
|
||||
values: { count: prefs.blockedSources.length },
|
||||
})}
|
||||
</p>
|
||||
<a class="btn-link" href="/news/sources">{$_('news.preferences.sourcesLinkArrow')}</a>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>{$_('news.preferences.weightsHeading')}</h2>
|
||||
<p class="hint">
|
||||
{$_('news.preferences.weightsHint', {
|
||||
values: { topics: topicWeightCount, sources: sourceWeightCount },
|
||||
})}
|
||||
</p>
|
||||
<button type="button" class="btn-secondary" onclick={resetWeights}
|
||||
>{$_('news.preferences.weightsReset')}</button
|
||||
>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>{$_('news.preferences.onboardingHeading')}</h2>
|
||||
<p class="hint">{$_('news.preferences.onboardingHint')}</p>
|
||||
<button type="button" class="btn-secondary" onclick={rerunOnboarding}>
|
||||
{$_('news.preferences.onboardingRerun')}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</RoutePage>
|
||||
|
||||
<style>
|
||||
.pane {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.bar .title strong {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bar .sub {
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1.125rem;
|
||||
background: hsl(var(--color-card));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 10px;
|
||||
background: hsl(var(--color-background, var(--color-card)));
|
||||
border: 2px solid hsl(var(--color-border));
|
||||
color: hsl(var(--color-foreground));
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 120ms ease,
|
||||
border-color 120ms ease;
|
||||
}
|
||||
|
||||
.pill.selected {
|
||||
border-color: hsl(var(--color-primary, 230 80% 55%));
|
||||
background: hsl(var(--color-primary, 230 80% 55%) / 0.12);
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
align-self: flex-start;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--color-background, var(--color-card)));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
color: hsl(var(--color-foreground));
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: hsl(var(--color-muted) / 0.5);
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
align-self: flex-start;
|
||||
color: hsl(var(--color-primary, 230 80% 55%));
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
<!--
|
||||
Legacy redirect: /news/saved → /articles.
|
||||
The "saved reading list" moved into the articles module in M5.
|
||||
Kept here so existing bookmarks and cross-links keep working.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
|
||||
onMount(() => {
|
||||
goto('/articles', { replaceState: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
<RoutePage appId="news" backHref="/news">
|
||||
<p class="redirect">Verschoben nach <a href="/articles">/articles</a>…</p>
|
||||
</RoutePage>
|
||||
|
||||
<style>
|
||||
.redirect {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
<!--
|
||||
/news/sources — list all known sources, grouped by topic, with a
|
||||
block toggle and a learned-weight indicator.
|
||||
|
||||
Source list is the static SOURCES_META mirror; the toggle hits
|
||||
preferencesStore.toggleBlockedSource which updates the singleton
|
||||
preferences row in lockstep with the feed engine's blocklist filter.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { usePreferences } from '$lib/modules/news/queries';
|
||||
import { preferencesStore } from '$lib/modules/news/stores/preferences.svelte';
|
||||
import { ALL_TOPICS, type Topic } from '$lib/modules/news/types';
|
||||
import { sourcesForTopic, TOPIC_LABELS } from '$lib/modules/news/sources-meta';
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
|
||||
const prefs$ = usePreferences();
|
||||
const prefs = $derived(prefs$.value);
|
||||
|
||||
function isBlocked(slug: string): boolean {
|
||||
return prefs.blockedSources.includes(slug);
|
||||
}
|
||||
function weightOf(slug: string): number {
|
||||
return prefs.sourceWeights[slug] ?? 1.0;
|
||||
}
|
||||
|
||||
function weightLabel(w: number): string {
|
||||
if (w >= 1.5) return '↑';
|
||||
if (w >= 1.1) return '↗';
|
||||
if (w <= 0.5) return '↓';
|
||||
if (w <= 0.9) return '↘';
|
||||
return '·';
|
||||
}
|
||||
|
||||
async function toggle(slug: string) {
|
||||
await preferencesStore.toggleBlockedSource(slug);
|
||||
}
|
||||
|
||||
const visibleTopics: Topic[] = ALL_TOPICS as unknown as Topic[];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Quellen — News — Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<RoutePage appId="news" backHref="/news">
|
||||
<div class="page">
|
||||
<header class="header">
|
||||
<button type="button" class="back" onclick={() => goto('/news/preferences')}
|
||||
>← Einstellungen</button
|
||||
>
|
||||
<h1>Quellen</h1>
|
||||
<p class="hint">
|
||||
{prefs.blockedSources.length} blockiert. Tippe auf eine Quelle um sie ein- oder auszublenden.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#each visibleTopics as topic}
|
||||
<section class="topic-section">
|
||||
<h2>
|
||||
{TOPIC_LABELS[topic].emoji}
|
||||
{TOPIC_LABELS[topic].de}
|
||||
</h2>
|
||||
<div class="source-grid">
|
||||
{#each sourcesForTopic(topic) as src}
|
||||
{@const blocked = isBlocked(src.slug)}
|
||||
{@const weight = weightOf(src.slug)}
|
||||
<button type="button" class="source" class:blocked onclick={() => toggle(src.slug)}>
|
||||
<span class="name">{src.name}</span>
|
||||
<span class="meta">
|
||||
<span class="lang">{src.language}</span>
|
||||
<span class="weight" title="Gewicht: {weight.toFixed(2)}"
|
||||
>{weightLabel(weight)}</span
|
||||
>
|
||||
{#if blocked}
|
||||
<span class="state">blockiert</span>
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
</RoutePage>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem 4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.header {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
.back {
|
||||
background: none;
|
||||
border: none;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.topic-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.topic-section h2 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.source-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.source {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
text-align: left;
|
||||
background: hsl(var(--color-muted));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.625rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.source.blocked {
|
||||
opacity: 0.5;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.name {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.weight {
|
||||
font-weight: 700;
|
||||
}
|
||||
.state {
|
||||
color: hsl(var(--color-destructive));
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1304,10 +1304,8 @@ services:
|
|||
MANA_CREDITS_URL: http://mana-credits:3002
|
||||
MANA_MEDIA_URL: http://mana-media:3011
|
||||
MANA_CRAWLER_URL: http://mana-crawler:3014
|
||||
# mana-news-pool — Plattform-Service (Lift-B 2026-05-16). Ersetzt
|
||||
# den ehemaligen news-ingester:3066-Container, der direkt in
|
||||
# mana_platform.news.curated_articles schrieb. apps/api/news/routes.ts
|
||||
# ist seit 2026-05-17 ein HTTP-Proxy auf diesen Endpoint.
|
||||
# mana-news-pool — Plattform-Service (Lift-B 2026-05-16). Wird
|
||||
# vom news-research-Modul + von der Pageta-Standalone konsumiert.
|
||||
MANA_NEWS_POOL_URL: http://mana-news-pool:3079
|
||||
MANA_LLM_DEFAULT_MODEL: ${MANA_LLM_DEFAULT_MODEL:-gemma3:4b}
|
||||
MANA_SERVICE_KEY: ${MANA_SERVICE_KEY}
|
||||
|
|
|
|||
|
|
@ -251,7 +251,6 @@ scrape_configs:
|
|||
- https://mana.how/finance
|
||||
- https://mana.how/places
|
||||
# mana.how/who: existiert nicht im unified-app — Who läuft als Standalone-Stack auf who.mana.how
|
||||
- https://mana.how/news
|
||||
- https://mana.how/mail
|
||||
- https://mana.how/playground
|
||||
# ─── Standalone Apps / Games (separate Container, eigene Tunnel-Hostnames) ───
|
||||
|
|
|
|||
|
|
@ -326,30 +326,6 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [
|
|||
],
|
||||
},
|
||||
|
||||
// ── News ──────────────────────────────────────────────────
|
||||
{
|
||||
name: 'save_news_article',
|
||||
module: 'news',
|
||||
description:
|
||||
'Speichert einen Artikel von einer URL in die Leseliste. URL wird serverseitig per Readability extrahiert.',
|
||||
defaultPolicy: 'propose',
|
||||
parameters: [
|
||||
{ name: 'url', type: 'string', description: 'Die Artikel-URL', required: true },
|
||||
{
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
description: 'Anzeigetitel für den Approval-Dialog (informativ)',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'summary',
|
||||
type: 'string',
|
||||
description: 'Kurze Begründung warum dieser Artikel relevant ist',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Articles (Pocket-style read-it-later) ───────────────
|
||||
{
|
||||
name: 'list_articles',
|
||||
|
|
|
|||
|
|
@ -107,9 +107,6 @@ export const APP_ICONS = {
|
|||
uload: svgToDataUrl(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="ug" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#6366f1"/><stop offset="100%" style="stop-color:#818cf8"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#ug)"/><path d="M35 45a10 10 0 0 1 10-10h0a10 10 0 0 1 0 20h0M65 55a10 10 0 0 1-10 10h0a10 10 0 0 1 0-20h0M42 58l16-16" stroke="white" stroke-width="5" stroke-linecap="round" fill="none"/></svg>`
|
||||
),
|
||||
news: svgToDataUrl(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="ng" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#10b981"/><stop offset="100%" style="stop-color:#34d399"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#ng)"/><rect x="22" y="25" width="56" height="50" rx="4" stroke="white" stroke-width="4" fill="none"/><line x1="30" y1="38" x2="55" y2="38" stroke="white" stroke-width="3" stroke-linecap="round"/><line x1="30" y1="48" x2="70" y2="48" stroke="white" stroke-width="3" stroke-linecap="round"/><line x1="30" y1="58" x2="65" y2="58" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>`
|
||||
),
|
||||
'news-research': svgToDataUrl(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="nr" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#0891b2"/><stop offset="100%" style="stop-color:#22d3ee"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#nr)"/><path d="M30 30a6 6 0 0 0-6 6v6M30 30a6 6 0 0 1 6 6v0M30 30v0" stroke="white" stroke-width="3" fill="none" stroke-linecap="round"/><circle cx="30" cy="30" r="3" fill="white"/><circle cx="52" cy="52" r="18" stroke="white" stroke-width="4" fill="none"/><line x1="65" y1="65" x2="78" y2="78" stroke="white" stroke-width="5" stroke-linecap="round"/><line x1="44" y1="50" x2="58" y2="50" stroke="white" stroke-width="3" stroke-linecap="round"/><line x1="44" y1="56" x2="54" y2="56" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>`
|
||||
),
|
||||
|
|
|
|||
|
|
@ -413,23 +413,6 @@ export const MANA_APPS: ManaApp[] = [
|
|||
status: 'beta',
|
||||
requiredTier: 'guest',
|
||||
},
|
||||
{
|
||||
id: 'news',
|
||||
name: 'News Hub',
|
||||
description: {
|
||||
de: 'Kuratierter Newsfeed',
|
||||
en: 'Curated News Feed',
|
||||
},
|
||||
longDescription: {
|
||||
de: 'Kuratierter Newsfeed aus öffentlichen Quellen mit persönlicher Leseliste — wähle Themen aus, blende Quellen aus und bau dir deinen eigenen Feed.',
|
||||
en: 'Curated news feed from public sources with a personal reading list — pick topics, hide sources, and shape your own feed.',
|
||||
},
|
||||
icon: APP_ICONS.news,
|
||||
color: '#10b981',
|
||||
comingSoon: false,
|
||||
status: 'development',
|
||||
requiredTier: 'guest',
|
||||
},
|
||||
{
|
||||
id: 'news-research',
|
||||
name: 'News Research',
|
||||
|
|
|
|||
|
|
@ -77,7 +77,6 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
|
|||
'cards',
|
||||
'picture',
|
||||
'quotes',
|
||||
'news',
|
||||
'news-research',
|
||||
'research-lab',
|
||||
'ai-agents',
|
||||
|
|
@ -99,7 +98,6 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
|
|||
'mail',
|
||||
'storage',
|
||||
'uload',
|
||||
'news',
|
||||
'research-lab',
|
||||
'club-members', // future — ClubDesk Paket A
|
||||
'club-finance', // future — ClubDesk Paket B
|
||||
|
|
@ -152,7 +150,6 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
|
|||
'mail',
|
||||
'uload',
|
||||
'website',
|
||||
'news',
|
||||
'news-research',
|
||||
'research-lab',
|
||||
'presi',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue