mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
feat(mana/web/news): workbench ListView + dashboard widget
Surfaces News in two extra entry points beyond the dedicated /news route. The workbench ListView is a compact ranked-feed view designed for the AppPage carousel slot — it boots the same feed-cache poll, runs the same scoreArticle pipeline, but renders smaller cards and skips the onboarding wizard (un-onboarded users get a CTA pointing them at /news instead). The NewsUnreadWidget shows the top three ranked unread articles on the dashboard, sharing the exact same engine inputs so the ordering matches the main feed. WidgetType + WIDGET_REGISTRY get the new 'news-unread' entry, and dashboard.widgets.news_unread is added to all five locale files. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8167d265a7
commit
e579e292cc
9 changed files with 521 additions and 1 deletions
|
|
@ -31,6 +31,7 @@ import ActiveTimerWidget from '$lib/modules/core/widgets/ActiveTimerWidget.svelt
|
|||
import NutritionProgressWidget from '$lib/modules/core/widgets/NutritionProgressWidget.svelte';
|
||||
import PlantWateringWidget from '$lib/modules/core/widgets/PlantWateringWidget.svelte';
|
||||
import CyclesWidget from '$lib/modules/core/widgets/CyclesWidget.svelte';
|
||||
import NewsUnreadWidget from '$lib/modules/news/widgets/NewsUnreadWidget.svelte';
|
||||
import DayTimelineWidget from './widgets/DayTimelineWidget.svelte';
|
||||
import ActivityFeedWidget from './widgets/ActivityFeedWidget.svelte';
|
||||
|
||||
|
|
@ -58,4 +59,5 @@ export const widgetComponents: Record<WidgetType, Component> = {
|
|||
'day-timeline': DayTimelineWidget,
|
||||
'activity-feed': ActivityFeedWidget,
|
||||
cycles: CyclesWidget,
|
||||
'news-unread': NewsUnreadWidget,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -150,6 +150,10 @@
|
|||
"description": "Aktuelle Phase und Countdown zur nächsten Periode",
|
||||
"empty": "Noch kein Zyklus erfasst.",
|
||||
"open": "Öffnen"
|
||||
},
|
||||
"news_unread": {
|
||||
"title": "News",
|
||||
"description": "Top-Artikel aus deinem kuratierten Feed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,6 +150,10 @@
|
|||
"description": "Current phase and countdown to next period",
|
||||
"empty": "No cycle logged yet.",
|
||||
"open": "Open"
|
||||
},
|
||||
"news_unread": {
|
||||
"title": "News",
|
||||
"description": "Top articles from your curated feed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,6 +145,10 @@
|
|||
"description": "Fase actual y cuenta regresiva hasta el próximo período",
|
||||
"empty": "Ningún ciclo registrado.",
|
||||
"open": "Abrir"
|
||||
},
|
||||
"news_unread": {
|
||||
"title": "Noticias",
|
||||
"description": "Artículos destacados de tu feed curado"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,6 +145,10 @@
|
|||
"description": "Phase actuelle et compte à rebours jusqu'aux prochaines règles",
|
||||
"empty": "Aucun cycle enregistré.",
|
||||
"open": "Ouvrir"
|
||||
},
|
||||
"news_unread": {
|
||||
"title": "Actualités",
|
||||
"description": "Articles phares de ton fil personnalisé"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,6 +145,10 @@
|
|||
"description": "Fase attuale e conto alla rovescia per il prossimo ciclo",
|
||||
"empty": "Nessun ciclo registrato.",
|
||||
"open": "Apri"
|
||||
},
|
||||
"news_unread": {
|
||||
"title": "Notizie",
|
||||
"description": "Articoli in evidenza dal tuo feed curato"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
360
apps/mana/apps/web/src/lib/modules/news/ListView.svelte
Normal file
360
apps/mana/apps/web/src/lib/modules/news/ListView.svelte
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
<!--
|
||||
News — Workbench ListView.
|
||||
|
||||
This is the version that renders inside an AppPage carousel slot, not
|
||||
the dedicated /news route. Two important differences from the route:
|
||||
|
||||
1. We boot the feed-cache poll loop here too — a user might add the
|
||||
News card to a workbench scene without ever opening the /news route,
|
||||
and we don't want them to stare at an empty card.
|
||||
2. The onboarding wizard lives only on the /news route. Inside the
|
||||
compact workbench frame there's no room for a 3-step picker, so
|
||||
un-onboarded users get a CTA card pointing them at /news.
|
||||
|
||||
Header is intentionally bare — the workbench AppPage already supplies
|
||||
the title bar and close/move/minimize controls.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { ViewProps } from '$lib/app-registry';
|
||||
import {
|
||||
usePreferences,
|
||||
useCachedFeed,
|
||||
useReactions,
|
||||
formatRelativeTime,
|
||||
} from '$lib/modules/news/queries';
|
||||
import { rankFeed, buildReactedIds } from '$lib/modules/news/feed-engine';
|
||||
import { reactionsStore } from '$lib/modules/news/stores/reactions.svelte';
|
||||
import { articlesStore } from '$lib/modules/news/stores/articles.svelte';
|
||||
import { feedCacheStore } from '$lib/modules/news/stores/feed-cache.svelte';
|
||||
import type { LocalCachedArticle } from '$lib/modules/news/types';
|
||||
|
||||
// We accept ViewProps for protocol compatibility but the workbench
|
||||
// view doesn't navigate within itself — every "open" jumps to the
|
||||
// dedicated /news routes.
|
||||
let _props: ViewProps = $props();
|
||||
void _props;
|
||||
|
||||
const prefs$ = usePreferences();
|
||||
const pool$ = useCachedFeed();
|
||||
const reactions$ = useReactions();
|
||||
|
||||
const prefs = $derived(prefs$.value);
|
||||
const pool = $derived(pool$.value);
|
||||
const reactions = $derived(reactions$.value);
|
||||
|
||||
const reactedIds = $derived(buildReactedIds(reactions));
|
||||
const ranked = $derived(prefs.onboardingCompleted ? rankFeed(pool, { prefs, reactedIds }) : []);
|
||||
|
||||
onMount(() => {
|
||||
feedCacheStore.start();
|
||||
});
|
||||
onDestroy(() => {
|
||||
// Don't stop the poll — the /news layout uses it too and the
|
||||
// store dedupes via inFlight. Stopping here would race with a
|
||||
// concurrently-mounted /news route.
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!prefs.onboardingCompleted) return;
|
||||
void feedCacheStore.refresh({
|
||||
topics: prefs.selectedTopics,
|
||||
lang: prefs.preferredLanguages.length === 1 ? prefs.preferredLanguages[0] : 'all',
|
||||
});
|
||||
});
|
||||
|
||||
async function react(
|
||||
article: LocalCachedArticle,
|
||||
kind: 'interested' | 'not_interested' | 'source_blocked'
|
||||
) {
|
||||
await reactionsStore.react({
|
||||
articleId: article.id,
|
||||
reaction: kind,
|
||||
topic: article.topic,
|
||||
sourceSlug: article.sourceSlug,
|
||||
});
|
||||
if (kind === 'interested') {
|
||||
await articlesStore.saveFromCurated(article);
|
||||
}
|
||||
}
|
||||
|
||||
function open(article: LocalCachedArticle) {
|
||||
goto(`/news/${article.id}`);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await feedCacheStore.refresh({
|
||||
topics: prefs.selectedTopics,
|
||||
lang: prefs.preferredLanguages.length === 1 ? prefs.preferredLanguages[0] : 'all',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="wb-news">
|
||||
{#if !prefs.onboardingCompleted}
|
||||
<div class="cta">
|
||||
<p class="cta-title">News Hub einrichten</p>
|
||||
<p class="cta-hint">
|
||||
Wähle Themen, Sprachen und Quellen — danach erscheinen hier deine Artikel.
|
||||
</p>
|
||||
<a class="cta-btn" href="/news">Jetzt einrichten</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="toolbar">
|
||||
<div class="counts">
|
||||
{ranked.length} Artikel
|
||||
{#if feedCacheStore.lastError}
|
||||
· <span class="err">Fehler</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="tools">
|
||||
<button
|
||||
type="button"
|
||||
class="tool"
|
||||
onclick={refresh}
|
||||
disabled={feedCacheStore.inFlight}
|
||||
title="Neu laden"
|
||||
>
|
||||
{feedCacheStore.inFlight ? '…' : '↻'}
|
||||
</button>
|
||||
<a class="tool" href="/news/saved" title="Gespeichert">📑</a>
|
||||
<a class="tool" href="/news/preferences" title="Einstellungen">⚙</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if ranked.length === 0}
|
||||
<div class="empty">
|
||||
{#if pool.length === 0}
|
||||
<p>Lade Artikel…</p>
|
||||
{:else}
|
||||
<p>Keine neuen Artikel.</p>
|
||||
<button type="button" class="link" onclick={refresh}>Neu laden</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="list">
|
||||
{#each ranked.slice(0, 30) as { article } (article.id)}
|
||||
<li class="item">
|
||||
{#if article.imageUrl}
|
||||
<button type="button" class="thumb" onclick={() => open(article)} aria-label="Öffnen">
|
||||
<img src={article.imageUrl} alt="" loading="lazy" />
|
||||
</button>
|
||||
{/if}
|
||||
<div class="body">
|
||||
<div class="meta">
|
||||
<span class="site">{article.siteName}</span>
|
||||
<span>·</span>
|
||||
<span>{formatRelativeTime(article.publishedAt)}</span>
|
||||
</div>
|
||||
<button type="button" class="title" onclick={() => open(article)}>
|
||||
{article.title}
|
||||
</button>
|
||||
<div class="actions">
|
||||
<button
|
||||
type="button"
|
||||
class="rxn"
|
||||
onclick={() => react(article, 'interested')}
|
||||
title="Interessiert"
|
||||
>
|
||||
❤️
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rxn"
|
||||
onclick={() => react(article, 'not_interested')}
|
||||
title="Nicht für mich"
|
||||
>
|
||||
👎
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rxn"
|
||||
onclick={() => react(article, 'source_blocked')}
|
||||
title="Quelle ausblenden"
|
||||
>
|
||||
🚫
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wb-news {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
padding: 0.5rem 0.25rem;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1.5rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.cta-title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.cta-hint {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.cta-btn {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-primary));
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
.counts {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.counts .err {
|
||||
color: hsl(var(--color-destructive));
|
||||
}
|
||||
.tools {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.tool {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.625rem;
|
||||
height: 1.625rem;
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-muted));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.tool:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 1.5rem 0;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: hsl(var(--color-primary));
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
font-size: 0.8125rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.625rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-muted));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
.thumb {
|
||||
width: 64px;
|
||||
height: 48px;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: hsl(var(--color-background));
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
.thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.meta .site {
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.title {
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: hsl(var(--color-foreground));
|
||||
cursor: pointer;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.title:hover {
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
.rxn {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 0.25rem;
|
||||
background: hsl(var(--color-background));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* NewsUnreadWidget — three top-ranked unread articles from the user's
|
||||
* curated feed, surfaced on the dashboard.
|
||||
*
|
||||
* Reads:
|
||||
* - newsCachedFeed (the local pool mirror — plaintext, no decrypt)
|
||||
* - newsPreferences singleton (decrypts to apply topic/lang filters)
|
||||
* - newsReactions (decrypts to skip already-rated articles)
|
||||
*
|
||||
* The widget intentionally does NOT trigger a feed refresh — that's
|
||||
* the news layout's job. If the user has never opened /news, the
|
||||
* pool is empty and the widget shows the empty state with a CTA.
|
||||
*/
|
||||
|
||||
import { liveQuery } from 'dexie';
|
||||
import {
|
||||
cachedFeedTable,
|
||||
preferencesTable,
|
||||
reactionTable,
|
||||
DEFAULT_PREFERENCES,
|
||||
} from '$lib/modules/news/collections';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { rankFeed, buildReactedIds } from '$lib/modules/news/feed-engine';
|
||||
import { toPreferences, toReaction, formatRelativeTime } from '$lib/modules/news/queries';
|
||||
import { PREFERENCES_ID, type LocalCachedArticle } from '$lib/modules/news/types';
|
||||
|
||||
let topThree = $state<LocalCachedArticle[]>([]);
|
||||
let loading = $state(true);
|
||||
let onboardingDone = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
const sub = liveQuery(async () => {
|
||||
const [pool, prefsRow, reactionsRows] = await Promise.all([
|
||||
cachedFeedTable.toArray(),
|
||||
preferencesTable.get(PREFERENCES_ID),
|
||||
reactionTable.toArray(),
|
||||
]);
|
||||
|
||||
// Decrypt prefs + reactions (cache stays plaintext).
|
||||
const prefs = prefsRow
|
||||
? toPreferences(
|
||||
(await decryptRecords('newsPreferences', [prefsRow]))[0] ?? DEFAULT_PREFERENCES
|
||||
)
|
||||
: toPreferences(DEFAULT_PREFERENCES);
|
||||
|
||||
const visibleReactions = reactionsRows.filter((r) => !r.deletedAt);
|
||||
const reactions = (await decryptRecords('newsReactions', visibleReactions)).map(toReaction);
|
||||
|
||||
return {
|
||||
prefs,
|
||||
ranked: rankFeed(pool, { prefs, reactedIds: buildReactedIds(reactions) }),
|
||||
};
|
||||
}).subscribe({
|
||||
next: ({ prefs, ranked }) => {
|
||||
onboardingDone = prefs.onboardingCompleted;
|
||||
topThree = ranked.slice(0, 3).map((s) => s.article);
|
||||
loading = false;
|
||||
},
|
||||
error: () => {
|
||||
loading = false;
|
||||
},
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="flex items-center gap-2 text-lg font-semibold">
|
||||
<span aria-hidden="true">📰</span>
|
||||
News
|
||||
</h3>
|
||||
<a href="/news" class="text-xs text-muted-foreground hover:text-foreground">Alle →</a>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-2">
|
||||
{#each Array(3) as _}
|
||||
<div class="h-12 animate-pulse rounded bg-surface-hover"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if !onboardingDone}
|
||||
<div class="py-4 text-center">
|
||||
<p class="text-sm text-muted-foreground">Richte deinen Newsfeed ein.</p>
|
||||
<a
|
||||
href="/news"
|
||||
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
|
||||
>
|
||||
Jetzt starten
|
||||
</a>
|
||||
</div>
|
||||
{:else if topThree.length === 0}
|
||||
<div class="py-4 text-center">
|
||||
<p class="text-sm text-muted-foreground">Keine neuen Artikel.</p>
|
||||
<a href="/news" class="mt-3 inline-block text-xs text-primary hover:underline">
|
||||
Feed öffnen
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each topThree as article (article.id)}
|
||||
<a
|
||||
href="/news/{article.id}"
|
||||
class="block rounded-lg p-2 transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
{#if article.imageUrl}
|
||||
<img
|
||||
src={article.imageUrl}
|
||||
alt=""
|
||||
class="h-12 w-16 flex-shrink-0 rounded object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="line-clamp-2 text-sm font-medium leading-snug">{article.title}</p>
|
||||
<div class="mt-0.5 flex gap-1 text-xs text-muted-foreground">
|
||||
<span class="font-medium">{article.siteName}</span>
|
||||
<span>·</span>
|
||||
<span>{formatRelativeTime(article.publishedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -30,7 +30,8 @@ export type WidgetType =
|
|||
| 'plant-watering' // Planta: plants due for watering
|
||||
| 'day-timeline' // TimeBlocks: chronological day timeline
|
||||
| 'activity-feed' // TimeBlocks: recent activity across modules
|
||||
| 'cycles'; // Cycles: current phase + days until next period
|
||||
| 'cycles' // Cycles: current phase + days until next period
|
||||
| 'news-unread'; // News: latest unread curated articles
|
||||
|
||||
/**
|
||||
* Widget size - maps to CSS Grid columns
|
||||
|
|
@ -342,6 +343,14 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
|
|||
allowMultiple: false,
|
||||
requiredBackend: 'cycles',
|
||||
},
|
||||
{
|
||||
type: 'news-unread',
|
||||
nameKey: 'dashboard.widgets.news_unread.title',
|
||||
descriptionKey: 'dashboard.widgets.news_unread.description',
|
||||
icon: '📰',
|
||||
defaultSize: 'small',
|
||||
allowMultiple: false,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue