feat(mana/web/news): web routes + i18n locales

Adds the seven (app)/news/* routes: layout that boots the feed-cache
poll, main page with the 3-step onboarding wizard and the ranked feed
with reaction buttons, dual-source reader at /news/[id], saved reading
list with category filter strip + inline category editor + 3 tabs
(unread/favorites/archive), /news/add for ad-hoc URL paste,
/news/preferences for topics/languages/weight reset, /news/sources
for per-source block toggles. Five locale JSON files (de/en/es/fr/it,
~60 keys each) for the eventual $_('news.…') refactor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 15:54:25 +02:00
parent de7e359580
commit 8167d265a7
12 changed files with 2918 additions and 0 deletions

View file

@ -0,0 +1,130 @@
{
"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"
},
"reactions": {
"interested": "Interessiert",
"interestedTitle": "Speichern + mehr davon",
"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"
},
"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",
"backToFeed": "Feed",
"topicsHeading": "Themen",
"topicsHint": "Welche Themen sollen im Feed auftauchen?",
"languagesHeading": "Sprachen",
"sourcesHeading": "Quellen",
"sourcesHint": "Du blockst aktuell {count} Quellen.",
"sourcesLink": "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"
}
}

View file

@ -0,0 +1,130 @@
{
"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"
},
"reactions": {
"interested": "Interested",
"interestedTitle": "Save and see more like this",
"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"
},
"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",
"backToFeed": "Feed",
"topicsHeading": "Topics",
"topicsHint": "Which topics should appear in the feed?",
"languagesHeading": "Languages",
"sourcesHeading": "Sources",
"sourcesHint": "You're currently blocking {count} sources.",
"sourcesLink": "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"
}
}

View file

@ -0,0 +1,130 @@
{
"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"
},
"reactions": {
"interested": "Me interesa",
"interestedTitle": "Guardar y ver más como esto",
"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"
},
"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",
"backToFeed": "Feed",
"topicsHeading": "Temas",
"topicsHint": "¿Qué temas deben aparecer en el feed?",
"languagesHeading": "Idiomas",
"sourcesHeading": "Fuentes",
"sourcesHint": "Estás bloqueando {count} fuentes.",
"sourcesLink": "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"
}
}

View file

@ -0,0 +1,130 @@
{
"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"
},
"reactions": {
"interested": "Intéressant",
"interestedTitle": "Enregistrer et en voir plus",
"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é"
},
"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",
"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.",
"sourcesLink": "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"
}
}

View file

@ -0,0 +1,130 @@
{
"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"
},
"reactions": {
"interested": "Mi interessa",
"interestedTitle": "Salva e mostra di più di questo",
"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"
},
"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",
"backToFeed": "Feed",
"topicsHeading": "Temi",
"topicsHint": "Quali temi devono apparire nel feed?",
"languagesHeading": "Lingue",
"sourcesHeading": "Fonti",
"sourcesHint": "Stai bloccando {count} fonti.",
"sourcesLink": "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"
}
}

View file

@ -0,0 +1,39 @@
<!--
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()}

View file

@ -0,0 +1,672 @@
<!--
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, buildReactedIds } 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';
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);
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() {
await preferencesStore.completeOnboarding({
topics: pickedTopics,
languages: pickedLanguages,
blockedSources: pickedBlocked,
});
// The +layout effect will pick up the new prefs and refresh.
}
// ─── Feed branch ──────────────────────────────────────────
const reactedIds = $derived(buildReactedIds(reactions));
const ranked = $derived(prefs.onboardingCompleted ? rankFeed(pool, { prefs, reactedIds }) : []);
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>
<div class="news-page">
{#if !prefs.onboardingCompleted}
<!-- ─── Onboarding ───────────────────────────────────── -->
<header class="hero">
<h1>Willkommen beim News Hub</h1>
<p>In drei Schritten baust du dir deinen persönlichen Newsfeed.</p>
</header>
<div class="steps">
<span class="step" class:active={onboardingStep === 1}>1. Themen</span>
<span class="step" class:active={onboardingStep === 2}>2. Sprache</span>
<span class="step" class:active={onboardingStep === 3}>3. Quellen</span>
</div>
{#if onboardingStep === 1}
<section class="step-panel">
<h2>Was interessiert dich?</h2>
<p class="hint">Wähle mindestens zwei Themen.</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)}
>
Weiter
</button>
</div>
</section>
{:else if onboardingStep === 2}
<section class="step-panel">
<h2>In welchen Sprachen liest du?</h2>
<div class="lang-row">
<button
type="button"
class="lang-pill"
class:selected={pickedLanguages.includes('de')}
onclick={() => toggleLang('de')}
>
🇩🇪 Deutsch
</button>
<button
type="button"
class="lang-pill"
class:selected={pickedLanguages.includes('en')}
onclick={() => toggleLang('en')}
>
🇬🇧 English
</button>
</div>
<div class="actions">
<button type="button" class="btn-secondary" onclick={() => (onboardingStep = 1)}>
Zurück
</button>
<button
type="button"
class="btn-primary"
disabled={pickedLanguages.length === 0}
onclick={() => (onboardingStep = 3)}
>
Weiter
</button>
</div>
</section>
{:else}
<section class="step-panel">
<h2>Quellen aus deinen Themen</h2>
<p class="hint">
Tippe eine Quelle an um sie auszublenden. Du kannst das jederzeit ändern.
</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)}>
Zurück
</button>
<button type="button" class="btn-primary" onclick={finishOnboarding}> Fertig </button>
</div>
</section>
{/if}
{:else}
<!-- ─── Feed ─────────────────────────────────────────── -->
<header class="feed-header">
<div>
<h1>News</h1>
<div class="meta">
{ranked.length} Artikel
{#if feedCacheStore.lastError}
· <span class="error">Fehler beim Laden</span>
{/if}
</div>
</div>
<div class="header-actions">
<button
type="button"
class="icon-btn"
onclick={manualRefresh}
disabled={feedCacheStore.inFlight}
title="Neu laden"
>
{feedCacheStore.inFlight ? '…' : '↻'}
</button>
<a class="icon-btn" href="/news/saved" title="Gespeichert">📑</a>
<a class="icon-btn" href="/news/preferences" title="Einstellungen"></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>Lade Artikel…</p>
{:else}
<p>Keine neuen Artikel zu deinen Themen.</p>
<p class="hint">Probiere "↻" oder erweitere deine Themen.</p>
{/if}
</div>
{:else}
<div class="card-grid">
{#each ranked as { article } (article.id)}
<article class="card">
{#if article.imageUrl}
<button
type="button"
class="card-image-btn"
onclick={() => openReader(article)}
aria-label="Artikel öffnen"
>
<img src={article.imageUrl} alt="" loading="lazy" />
</button>
{/if}
<div class="card-body">
<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>{article.readingTimeMinutes} min</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"
onclick={() => react(article, 'interested')}
title="Speichern + mehr davon"
>
❤️ Interessiert
</button>
<button
type="button"
class="reaction-btn not-interested"
onclick={() => react(article, 'not_interested')}
title="Weniger davon"
>
👎 Nicht für mich
</button>
<button
type="button"
class="reaction-btn block"
onclick={() => react(article, 'source_blocked')}
title="Quelle ausblenden"
>
🚫 {article.siteName}
</button>
</div>
</div>
</article>
{/each}
</div>
{/if}
{/if}
</div>
<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));
}
</style>

View file

@ -0,0 +1,331 @@
<!--
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';
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>
<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>
<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>

View file

@ -0,0 +1,137 @@
<!--
/news/add — paste an arbitrary URL, hit save, get a saved article.
Calls POST /api/v1/news/extract/save which runs Mozilla Readability
on the server, returns the cleaned article shape, and we drop it
into the encrypted reading list via articlesStore.saveFromUrl.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { articlesStore } from '$lib/modules/news/stores/articles.svelte';
let url = $state('');
let busy = $state(false);
let error = $state<string | null>(null);
function looksLikeUrl(s: string): boolean {
try {
const u = new URL(s.trim());
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
async function submit(e: Event) {
e.preventDefault();
if (busy || !looksLikeUrl(url)) return;
busy = true;
error = null;
try {
const article = await articlesStore.saveFromUrl(url.trim());
goto(`/news/${article.id}`);
} catch (err) {
error = err instanceof Error ? err.message : 'Speichern fehlgeschlagen';
} finally {
busy = false;
}
}
</script>
<svelte:head>
<title>URL hinzufügen — News — Mana</title>
</svelte:head>
<div class="page">
<header class="header">
<button type="button" class="back" onclick={() => goto('/news/saved')}> Gespeichert</button>
<h1>Artikel speichern</h1>
<p class="hint">
Füge eine URL ein. Wir extrahieren den Volltext (Mozilla Readability) und legen ihn in deine
verschlüsselte Leseliste.
</p>
</header>
<form class="form" onsubmit={submit}>
<!-- svelte-ignore a11y_autofocus -->
<input type="url" placeholder="https://…" bind:value={url} disabled={busy} autofocus required />
<button type="submit" disabled={busy || !looksLikeUrl(url)}>
{busy ? 'Lade…' : 'Speichern'}
</button>
</form>
{#if error}
<div class="error">{error}</div>
{/if}
</div>
<style>
.page {
max-width: 640px;
margin: 0 auto;
padding: 0 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.header {
padding-top: 0.5rem;
}
.back {
background: none;
border: none;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
font-size: 0.875rem;
}
.header h1 {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--color-foreground));
margin-top: 0.25rem;
}
.hint {
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
margin-top: 0.5rem;
}
.form {
display: flex;
gap: 0.5rem;
}
.form input {
flex: 1;
padding: 0.625rem 0.875rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
color: hsl(var(--color-foreground));
font-size: 0.9375rem;
outline: none;
}
.form input:focus {
border-color: hsl(var(--color-primary));
}
.form button {
padding: 0.625rem 1rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary));
color: white;
border: none;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
}
.form button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.error {
padding: 0.625rem 0.875rem;
border-radius: 0.5rem;
background: hsl(var(--color-destructive) / 0.15);
border: 1px solid hsl(var(--color-destructive) / 0.4);
color: hsl(var(--color-destructive));
font-size: 0.875rem;
}
</style>

View file

@ -0,0 +1,217 @@
<!--
/news/preferences — adjust topics, languages, and reset learned weights.
Source-level blocking lives at /news/sources. Onboarding can be
re-run from here too.
-->
<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';
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('Alle gelernten Gewichtungen zurücksetzen?')) return;
await preferencesStore.resetWeights();
}
async function rerunOnboarding() {
// Flip the flag back so the main +page.svelte renders the wizard.
await preferencesStore.applyWeightDiff({});
// Need a separate setter — re-using completeOnboarding inverted.
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>Einstellungen — News — Mana</title>
</svelte:head>
<div class="page">
<header class="header">
<button type="button" class="back" onclick={() => goto('/news')}> Feed</button>
<h1>News-Einstellungen</h1>
</header>
<section class="card">
<h2>Themen</h2>
<p class="hint">Welche Themen sollen im Feed auftauchen?</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>Sprachen</h2>
<div class="row">
<button
type="button"
class="pill"
class:selected={prefs.preferredLanguages.includes('de')}
onclick={() => toggleLang('de')}
>
🇩🇪 Deutsch
</button>
<button
type="button"
class="pill"
class:selected={prefs.preferredLanguages.includes('en')}
onclick={() => toggleLang('en')}
>
🇬🇧 English
</button>
</div>
</section>
<section class="card">
<h2>Quellen</h2>
<p class="hint">
Du blockst aktuell <strong>{prefs.blockedSources.length}</strong> Quellen.
</p>
<a class="btn-link" href="/news/sources">Quellen verwalten →</a>
</section>
<section class="card">
<h2>Gelernte Gewichtungen</h2>
<p class="hint">
Über Reaktionen lernt der Feed deine Vorlieben:
{topicWeightCount} Themen-Gewichte, {sourceWeightCount} Quellen-Gewichte.
</p>
<button type="button" class="btn-secondary" onclick={resetWeights}> Zurücksetzen </button>
</section>
<section class="card">
<h2>Onboarding</h2>
<p class="hint">Themen, Sprachen und Quellen neu wählen.</p>
<button type="button" class="btn-secondary" onclick={rerunOnboarding}>
Onboarding neu starten
</button>
</section>
</div>
<style>
.page {
max-width: 720px;
margin: 0 auto;
padding: 0 1rem 4rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.header {
padding-top: 0.5rem;
}
.back {
background: none;
border: none;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
font-size: 0.875rem;
}
.header h1 {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--color-foreground));
margin-top: 0.25rem;
}
.card {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.25rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
}
.card h2 {
font-size: 1.0625rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.hint {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.5rem;
}
.row {
display: flex;
gap: 0.5rem;
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 0.875rem;
border-radius: 0.625rem;
background: hsl(var(--color-background));
border: 2px solid hsl(var(--color-border));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
cursor: pointer;
}
.pill.selected {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.12);
}
.emoji {
font-size: 1.125rem;
}
.btn-secondary {
align-self: flex-start;
padding: 0.5rem 0.875rem;
border-radius: 0.5rem;
background: hsl(var(--color-background));
border: 1px solid hsl(var(--color-border));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
cursor: pointer;
}
.btn-link {
align-self: flex-start;
color: hsl(var(--color-primary));
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
}
.btn-link:hover {
text-decoration: underline;
}
</style>

View file

@ -0,0 +1,709 @@
<!--
Saved articles — the user's personal reading list.
Three tabs: Ungelesen / Favoriten / Archiv. Each card opens the
shared reader at /news/[id]; the reader's dual-source lookup means
the same URL works whether the article was saved from the curated
pool or pasted as an ad-hoc URL.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import {
useSavedArticles,
useCategories,
formatRelativeTime,
toArticle,
} from '$lib/modules/news/queries';
import { articlesStore } from '$lib/modules/news/stores/articles.svelte';
import { categoriesStore } from '$lib/modules/news/stores/categories.svelte';
import { articleTable } from '$lib/modules/news/collections';
import { decryptRecords } from '$lib/data/crypto';
import type { Article } from '$lib/modules/news/types';
const saved$ = useSavedArticles();
const categories$ = useCategories();
const all = $derived(saved$.value);
const categories = $derived(categories$.value);
type Tab = 'unread' | 'favorites' | 'archive';
let tab = $state<Tab>('unread');
// `null` = no filter (show all in the active tab). Otherwise the
// categoryId we're scoped to.
let activeCategoryId = $state<string | null>(null);
let showCategoryEditor = $state(false);
let newCategoryName = $state('');
let renamingId = $state<string | null>(null);
let renamingName = $state('');
const filtered = $derived.by(() => {
const base = (() => {
switch (tab) {
case 'unread':
return all.filter((a) => !a.isRead && !a.isArchived);
case 'favorites':
return all.filter((a) => a.isFavorite && !a.isArchived);
case 'archive':
// `archived` is filled by the effect below.
return [] as Article[];
}
})();
if (activeCategoryId == null) return base;
return base.filter((a) => a.categoryId === activeCategoryId);
});
// For "archive" tab: read isArchived rows directly from Dexie. Kept
// minimal — not worth a second liveQuery hook for the MVP.
let archived = $state<Article[]>([]);
$effect(() => {
if (tab !== 'archive') return;
void (async () => {
const rows = (await articleTable.toArray()).filter((a) => !a.deletedAt && a.isArchived);
const decrypted = await decryptRecords('newsArticles', rows);
archived = decrypted.map(toArticle).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
})();
});
const visible = $derived(
tab === 'archive'
? activeCategoryId == null
? archived
: archived.filter((a) => a.categoryId === activeCategoryId)
: filtered
);
// Counts per category for the filter pills, computed against the
// currently visible base set (so the numbers reflect the active tab).
const baseSet = $derived(tab === 'archive' ? archived : filtered);
const countByCategory = $derived.by(() => {
const map: Record<string, number> = {};
// `filtered` already applies the category filter, so we need a
// pre-filter base. Re-derive it here.
const pre =
tab === 'unread'
? all.filter((a) => !a.isRead && !a.isArchived)
: tab === 'favorites'
? all.filter((a) => a.isFavorite && !a.isArchived)
: archived;
for (const a of pre) {
const key = a.categoryId ?? '__none__';
map[key] = (map[key] ?? 0) + 1;
}
map.__all__ = pre.length;
return map;
});
// Touch baseSet to silence the unused-binding linter — it's used to
// keep the derivation of countByCategory reactive in case the upstream
// query refreshes during a tab switch.
$effect(() => {
void baseSet.length;
});
function open(article: Article) {
// Curated saves keep their server uuid in sourceCuratedId, but
// the local id is the primary key the reader prefers. Either id
// works — the reader resolves both.
goto(`/news/${article.sourceCuratedId ?? article.id}`);
}
async function toggleFav(e: Event, id: string) {
e.stopPropagation();
await articlesStore.toggleFavorite(id);
}
async function archive(e: Event, id: string) {
e.stopPropagation();
await articlesStore.archive(id);
}
async function unarchive(e: Event, id: string) {
e.stopPropagation();
await articleTable.update(id, {
isArchived: false,
updatedAt: new Date().toISOString(),
});
}
async function remove(e: Event, id: string) {
e.stopPropagation();
await articlesStore.delete(id);
}
async function setCategory(articleId: string, categoryId: string | null) {
await articlesStore.setCategory(articleId, categoryId);
}
async function createCategory() {
const name = newCategoryName.trim();
if (!name) return;
const created = await categoriesStore.create({ name });
newCategoryName = '';
activeCategoryId = created.id;
}
function startRename(id: string, currentName: string) {
renamingId = id;
renamingName = currentName;
}
async function commitRename() {
if (!renamingId) return;
await categoriesStore.rename(renamingId, renamingName);
renamingId = null;
}
async function deleteCategory(id: string) {
if (!confirm('Kategorie löschen? Artikel bleiben erhalten.')) return;
await categoriesStore.delete(id);
if (activeCategoryId === id) activeCategoryId = null;
}
</script>
<svelte:head>
<title>Gespeichert — News — Mana</title>
</svelte:head>
<div class="page">
<header class="header">
<div>
<button type="button" class="back" onclick={() => goto('/news')}> Feed</button>
<h1>Gespeichert</h1>
</div>
<a class="add-link" href="/news/add">+ URL hinzufügen</a>
</header>
<nav class="tabs">
<button
type="button"
class="tab"
class:active={tab === 'unread'}
onclick={() => (tab = 'unread')}
>
Ungelesen
</button>
<button
type="button"
class="tab"
class:active={tab === 'favorites'}
onclick={() => (tab = 'favorites')}
>
Favoriten
</button>
<button
type="button"
class="tab"
class:active={tab === 'archive'}
onclick={() => (tab = 'archive')}
>
Archiv
</button>
</nav>
<!-- Category filter strip -->
<div class="categories-bar">
<div class="cat-pills">
<button
type="button"
class="cat-pill"
class:active={activeCategoryId === null}
onclick={() => (activeCategoryId = null)}
>
Alle
<span class="count">{countByCategory.__all__ ?? 0}</span>
</button>
{#each categories as cat (cat.id)}
<button
type="button"
class="cat-pill"
class:active={activeCategoryId === cat.id}
style:--cat-color={cat.color}
onclick={() => (activeCategoryId = cat.id)}
>
<span class="dot" style:background={cat.color}></span>
{#if renamingId === cat.id}
<input
type="text"
bind:value={renamingName}
onblur={commitRename}
onkeydown={(e) => {
if (e.key === 'Enter') commitRename();
if (e.key === 'Escape') renamingId = null;
}}
/>
{:else}
<span ondblclick={() => startRename(cat.id, cat.name)} role="button" tabindex="0">
{cat.name}
</span>
{/if}
<span class="count">{countByCategory[cat.id] ?? 0}</span>
</button>
{/each}
<button
type="button"
class="cat-edit"
onclick={() => (showCategoryEditor = !showCategoryEditor)}
title="Kategorien verwalten"
>
{showCategoryEditor ? '✕' : ''}
</button>
</div>
{#if showCategoryEditor}
<div class="cat-editor">
<form
class="cat-add"
onsubmit={(e) => {
e.preventDefault();
void createCategory();
}}
>
<input
type="text"
placeholder="Neue Kategorie…"
bind:value={newCategoryName}
maxlength="40"
/>
<button type="submit" disabled={!newCategoryName.trim()}>Hinzufügen</button>
</form>
{#if categories.length > 0}
<ul class="cat-list">
{#each categories as cat (cat.id)}
<li>
<span class="dot" style:background={cat.color}></span>
<span class="cat-name">{cat.name}</span>
<button type="button" class="link" onclick={() => startRename(cat.id, cat.name)}>
umbenennen
</button>
<button type="button" class="link danger" onclick={() => deleteCategory(cat.id)}>
löschen
</button>
</li>
{/each}
</ul>
{:else}
<p class="hint">Noch keine Kategorien. Erstelle eine oben.</p>
{/if}
</div>
{/if}
</div>
{#if visible.length === 0}
<div class="empty">
{#if tab === 'unread'}
<p>Keine ungelesenen Artikel.</p>
<p class="hint">Reagiere im Feed mit „❤️ Interessiert" um Artikel hier zu sammeln.</p>
{:else if tab === 'favorites'}
<p>Noch keine Favoriten.</p>
{:else}
<p>Archiv ist leer.</p>
{/if}
</div>
{:else}
<div class="list">
{#each visible as article (article.id)}
<article class="row">
{#if article.imageUrl}
<button
type="button"
class="thumb-btn"
onclick={() => open(article)}
aria-label="Öffnen"
>
<img src={article.imageUrl} alt="" loading="lazy" />
</button>
{/if}
<div class="row-body">
<div class="row-meta">
<span class="site">{article.siteName ?? 'Eigener Link'}</span>
{#if article.publishedAt}
<span>·</span>
<span>{formatRelativeTime(article.publishedAt)}</span>
{/if}
{#if article.readingTimeMinutes}
<span>·</span>
<span>{article.readingTimeMinutes} min</span>
{/if}
{#if article.type === 'saved'}
<span class="badge">eigen</span>
{/if}
</div>
<button type="button" class="row-title" onclick={() => open(article)}>
{article.title}
</button>
{#if article.excerpt}
<p class="row-excerpt">{article.excerpt}</p>
{/if}
</div>
<div class="row-actions">
<select
class="cat-select"
value={article.categoryId ?? ''}
onchange={(e) => {
const v = (e.currentTarget as HTMLSelectElement).value;
void setCategory(article.id, v === '' ? null : v);
}}
onclick={(e) => e.stopPropagation()}
title="Kategorie"
>
<option value="">— Keine —</option>
{#each categories as cat (cat.id)}
<option value={cat.id}>{cat.name}</option>
{/each}
</select>
<button
type="button"
class="icon"
class:active={article.isFavorite}
onclick={(e) => toggleFav(e, article.id)}
title="Favorit"
>
</button>
{#if tab === 'archive'}
<button
type="button"
class="icon"
onclick={(e) => unarchive(e, article.id)}
title="Wiederherstellen"
>
↩︎
</button>
{:else}
<button
type="button"
class="icon"
onclick={(e) => archive(e, article.id)}
title="Archivieren"
>
📦
</button>
{/if}
<button
type="button"
class="icon danger"
onclick={(e) => remove(e, article.id)}
title="Löschen"
>
🗑
</button>
</div>
</article>
{/each}
</div>
{/if}
</div>
<style>
.page {
max-width: 880px;
margin: 0 auto;
padding: 0 1rem 4rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-end;
padding-top: 0.5rem;
}
.back {
background: none;
border: none;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
font-size: 0.875rem;
}
.header h1 {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--color-foreground));
margin-top: 0.25rem;
}
.add-link {
padding: 0.5rem 0.875rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary));
color: white;
text-decoration: none;
font-size: 0.8125rem;
font-weight: 500;
}
.tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid hsl(var(--color-border));
}
.tab {
padding: 0.625rem 0.875rem;
background: none;
border: none;
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tab.active {
color: hsl(var(--color-foreground));
border-bottom-color: hsl(var(--color-primary));
}
.categories-bar {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.cat-pills {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
align-items: center;
}
.cat-pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.625rem;
border-radius: 999px;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
cursor: pointer;
}
.cat-pill.active {
background: hsl(var(--color-primary) / 0.18);
border-color: hsl(var(--color-primary) / 0.5);
}
.cat-pill .dot {
display: inline-block;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
}
.cat-pill .count {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
background: hsl(var(--color-background));
padding: 0 0.35rem;
border-radius: 999px;
min-width: 1.1rem;
text-align: center;
}
.cat-pill input {
background: transparent;
border: none;
color: hsl(var(--color-foreground));
font: inherit;
min-width: 4rem;
outline: none;
}
.cat-edit {
width: 1.75rem;
height: 1.75rem;
border-radius: 999px;
background: hsl(var(--color-background));
border: 1px dashed hsl(var(--color-border));
color: hsl(var(--color-muted-foreground));
cursor: pointer;
font-size: 0.875rem;
}
.cat-editor {
padding: 0.875rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
border-radius: 0.625rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.cat-add {
display: flex;
gap: 0.375rem;
}
.cat-add input {
flex: 1;
padding: 0.4rem 0.625rem;
background: hsl(var(--color-background));
border: 1px solid hsl(var(--color-border));
border-radius: 0.4rem;
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
outline: none;
}
.cat-add button {
padding: 0.4rem 0.75rem;
border-radius: 0.4rem;
background: hsl(var(--color-primary));
color: white;
border: none;
font-size: 0.8125rem;
cursor: pointer;
}
.cat-add button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.cat-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.cat-list li {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
}
.cat-list .dot {
display: inline-block;
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
}
.cat-list .cat-name {
flex: 1;
color: hsl(var(--color-foreground));
}
.cat-list .link {
background: none;
border: none;
padding: 0;
color: hsl(var(--color-muted-foreground));
font-size: 0.75rem;
cursor: pointer;
text-decoration: underline;
}
.cat-list .link.danger {
color: hsl(var(--color-destructive));
}
.cat-editor .hint {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.cat-select {
max-width: 9rem;
padding: 0.25rem 0.4rem;
background: hsl(var(--color-background));
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
color: hsl(var(--color-foreground));
font-size: 0.75rem;
cursor: pointer;
}
.empty {
text-align: center;
padding: 4rem 0;
color: hsl(var(--color-muted-foreground));
}
.empty .hint {
font-size: 0.875rem;
margin-top: 0.5rem;
}
.list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.row {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.875rem;
padding: 0.875rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
}
.thumb-btn {
width: 96px;
height: 64px;
border: none;
padding: 0;
background: hsl(var(--color-background));
border-radius: 0.5rem;
overflow: hidden;
cursor: pointer;
}
.thumb-btn img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.row-body {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.row-meta {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.row-meta .site {
font-weight: 600;
color: hsl(var(--color-foreground));
}
.row-meta .badge {
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
padding: 0 0.4rem;
border-radius: 999px;
font-weight: 600;
}
.row-title {
text-align: left;
background: none;
border: none;
padding: 0;
font-size: 0.9375rem;
font-weight: 600;
color: hsl(var(--color-foreground));
cursor: pointer;
line-height: 1.35;
}
.row-title:hover {
color: hsl(var(--color-primary));
}
.row-excerpt {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
}
.row-actions {
display: flex;
flex-direction: column;
gap: 0.25rem;
align-self: center;
}
.icon {
width: 1.875rem;
height: 1.875rem;
border-radius: 0.375rem;
background: hsl(var(--color-background));
border: 1px solid hsl(var(--color-border));
cursor: pointer;
font-size: 0.875rem;
}
.icon.active {
background: hsl(var(--color-primary) / 0.15);
border-color: hsl(var(--color-primary) / 0.4);
}
.icon.danger:hover {
background: hsl(var(--color-destructive) / 0.15);
border-color: hsl(var(--color-destructive) / 0.4);
}
</style>

View file

@ -0,0 +1,163 @@
<!--
/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';
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>
<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>
<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>