mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
de7e359580
commit
8167d265a7
12 changed files with 2918 additions and 0 deletions
130
apps/mana/apps/web/src/lib/i18n/locales/news/de.json
Normal file
130
apps/mana/apps/web/src/lib/i18n/locales/news/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||
130
apps/mana/apps/web/src/lib/i18n/locales/news/en.json
Normal file
130
apps/mana/apps/web/src/lib/i18n/locales/news/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
130
apps/mana/apps/web/src/lib/i18n/locales/news/es.json
Normal file
130
apps/mana/apps/web/src/lib/i18n/locales/news/es.json
Normal 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"
|
||||
}
|
||||
}
|
||||
130
apps/mana/apps/web/src/lib/i18n/locales/news/fr.json
Normal file
130
apps/mana/apps/web/src/lib/i18n/locales/news/fr.json
Normal 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"
|
||||
}
|
||||
}
|
||||
130
apps/mana/apps/web/src/lib/i18n/locales/news/it.json
Normal file
130
apps/mana/apps/web/src/lib/i18n/locales/news/it.json
Normal 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"
|
||||
}
|
||||
}
|
||||
39
apps/mana/apps/web/src/routes/(app)/news/+layout.svelte
Normal file
39
apps/mana/apps/web/src/routes/(app)/news/+layout.svelte
Normal 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()}
|
||||
672
apps/mana/apps/web/src/routes/(app)/news/+page.svelte
Normal file
672
apps/mana/apps/web/src/routes/(app)/news/+page.svelte
Normal 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>
|
||||
331
apps/mana/apps/web/src/routes/(app)/news/[id]/+page.svelte
Normal file
331
apps/mana/apps/web/src/routes/(app)/news/[id]/+page.svelte
Normal 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>
|
||||
137
apps/mana/apps/web/src/routes/(app)/news/add/+page.svelte
Normal file
137
apps/mana/apps/web/src/routes/(app)/news/add/+page.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
709
apps/mana/apps/web/src/routes/(app)/news/saved/+page.svelte
Normal file
709
apps/mana/apps/web/src/routes/(app)/news/saved/+page.svelte
Normal 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>
|
||||
163
apps/mana/apps/web/src/routes/(app)/news/sources/+page.svelte
Normal file
163
apps/mana/apps/web/src/routes/(app)/news/sources/+page.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue