mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:01:09 +02:00
feat(articles): M8 highlights view + stats + dashboard widget
useStats() live-query aggregates total / per-status / savedThisWeek /
finishedThisWeek / topSites / totalHighlights in one scoped Dexie pass.
useAllHighlights() joins cross-article highlights with article-header
info (title, siteName, originalUrl) for rendering.
/articles/highlights — HighlightsView groups chronologically-sorted
highlights per article with color-accented stripes, click-to-reader
jumps, and two export actions:
- Copy as Markdown (clipboard)
- Download .md (file)
Export logic lives in lib/markdown-export.ts as a pure function
(renderHighlightsMarkdown) so future snapshot tests don't need the
render tree.
Dashboard widget: ArticlesUnreadWidget mirrors NewsUnreadWidget's
pattern — self-contained live query, top-3 unread/reading, stats
strip ("N ungelesen · M diese Woche gespeichert"), empty state
CTA to /articles/add. Registered in:
- lib/types/dashboard.ts (WidgetType union + WIDGET_REGISTRY)
- lib/components/dashboard/widget-registry.ts (component map)
- lib/i18n/locales/dashboard/{de,en}.json (translations)
fr/it/es intentionally left untranslated — consistent with how
invoices_open and broadcasts are handled.
ListView gains a pencil button next to the settings gear linking
to /articles/highlights.
Also: plan doc marks M7 + M8 done with commit refs; M1–M8 scope is
now complete.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8a991f7c39
commit
7611d109be
12 changed files with 631 additions and 2 deletions
|
|
@ -32,6 +32,7 @@ import NutritionProgressWidget from '$lib/modules/core/widgets/NutritionProgress
|
|||
import PlantWateringWidget from '$lib/modules/core/widgets/PlantWateringWidget.svelte';
|
||||
import PeriodWidget from '$lib/modules/core/widgets/PeriodWidget.svelte';
|
||||
import NewsUnreadWidget from '$lib/modules/news/widgets/NewsUnreadWidget.svelte';
|
||||
import ArticlesUnreadWidget from '$lib/modules/articles/widgets/ArticlesUnreadWidget.svelte';
|
||||
import BodyStatsWidget from '$lib/modules/body/widgets/BodyStatsWidget.svelte';
|
||||
import InvoicesOpenWidget from '$lib/modules/invoices/widgets/InvoicesOpenWidget.svelte';
|
||||
import BroadcastsWidget from '$lib/modules/broadcast/widgets/BroadcastsWidget.svelte';
|
||||
|
|
@ -63,6 +64,7 @@ export const widgetComponents: Record<WidgetType, Component> = {
|
|||
'activity-feed': ActivityFeedWidget,
|
||||
period: PeriodWidget,
|
||||
'news-unread': NewsUnreadWidget,
|
||||
'articles-unread': ArticlesUnreadWidget,
|
||||
'body-stats': BodyStatsWidget,
|
||||
'invoices-open': InvoicesOpenWidget,
|
||||
broadcasts: BroadcastsWidget,
|
||||
|
|
|
|||
|
|
@ -155,6 +155,10 @@
|
|||
"title": "News",
|
||||
"description": "Top-Artikel aus deinem kuratierten Feed"
|
||||
},
|
||||
"articles_unread": {
|
||||
"title": "Artikel",
|
||||
"description": "Ungelesene Artikel aus deiner Leseliste"
|
||||
},
|
||||
"body_stats": {
|
||||
"title": "Body",
|
||||
"description": "Aktuelles Gewicht und Trainings-Status"
|
||||
|
|
|
|||
|
|
@ -155,6 +155,10 @@
|
|||
"title": "News",
|
||||
"description": "Top articles from your curated feed"
|
||||
},
|
||||
"articles_unread": {
|
||||
"title": "Articles",
|
||||
"description": "Unread articles from your reading list"
|
||||
},
|
||||
"body_stats": {
|
||||
"title": "Body",
|
||||
"description": "Latest weight and training status"
|
||||
|
|
|
|||
|
|
@ -80,6 +80,15 @@
|
|||
<p class="subtitle">Später lesen — gespeicherte Web-Artikel, offline verfügbar.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title="Highlights — alle markierten Stellen"
|
||||
aria-label="Highlights anzeigen"
|
||||
onclick={() => goto('/articles/highlights')}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
|
|
|
|||
|
|
@ -10,14 +10,18 @@ export {
|
|||
useAllArticles,
|
||||
useArticle,
|
||||
useArticleHighlights,
|
||||
useAllHighlights,
|
||||
useArticleTagIds,
|
||||
useArticleTagMap,
|
||||
useStats,
|
||||
toArticle,
|
||||
toHighlight,
|
||||
filterByStatus,
|
||||
searchArticles,
|
||||
} from './queries';
|
||||
|
||||
export type { ArticlesStats, SiteCount, HighlightWithArticle } from './queries';
|
||||
|
||||
export { articleTable, articleHighlightTable, articleTagTable } from './collections';
|
||||
|
||||
export type {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* Markdown export for the highlights collection view.
|
||||
*
|
||||
* Groups highlights by article, dumps them in the order
|
||||
* `useAllHighlights` returned them (chronological), and wraps the whole
|
||||
* thing in a small header with the export date so the user can paste
|
||||
* the result into Obsidian, Notion, a Markdown note — whatever.
|
||||
*
|
||||
* Kept in a standalone file so the export logic can be unit-tested
|
||||
* without needing the Svelte render tree.
|
||||
*/
|
||||
|
||||
import type { HighlightWithArticle } from '../queries';
|
||||
|
||||
/** Escape the minimum set of Markdown specials that show up in article
|
||||
* titles and highlight text so pasted output doesn't accidentally
|
||||
* format parts of the quote. We don't escape inside the quoted block
|
||||
* itself — the reader's expectation is "see what I highlighted". */
|
||||
function escapeTitle(text: string): string {
|
||||
return text.replace(/([\\*_`[\]<>])/g, '\\$1');
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toISOString().slice(0, 10);
|
||||
} catch {
|
||||
return iso.slice(0, 10);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderHighlightsMarkdown(
|
||||
rows: HighlightWithArticle[],
|
||||
now: Date = new Date()
|
||||
): string {
|
||||
const header = `# Mana Highlights — ${now.toISOString().slice(0, 10)}\n`;
|
||||
if (rows.length === 0) {
|
||||
return `${header}\n_Keine Highlights._\n`;
|
||||
}
|
||||
|
||||
// Preserve the incoming chronological order but group consecutive
|
||||
// rows for the same article together. Using a manual walk instead of
|
||||
// Map-groupBy keeps the per-section header below the most-recent row
|
||||
// for that article, matching what the UI shows.
|
||||
const blocks: string[] = [header];
|
||||
let currentArticleId: string | null = null;
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.article.id !== currentArticleId) {
|
||||
currentArticleId = row.article.id;
|
||||
blocks.push('');
|
||||
blocks.push(`## ${escapeTitle(row.article.title)}`);
|
||||
const subtitle = [row.article.siteName, row.article.originalUrl]
|
||||
.filter((s): s is string => !!s)
|
||||
.join(' · ');
|
||||
if (subtitle) blocks.push(`_${subtitle}_`);
|
||||
blocks.push('');
|
||||
}
|
||||
|
||||
const savedAt = row.highlight.createdAt ? ` _(${formatDate(row.highlight.createdAt)})_` : '';
|
||||
blocks.push(`- > ${row.highlight.text.replace(/\n+/g, ' ')}${savedAt}`);
|
||||
if (row.highlight.note) {
|
||||
blocks.push(` — ${row.highlight.note.replace(/\n+/g, ' ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
blocks.push('');
|
||||
return blocks.join('\n');
|
||||
}
|
||||
|
|
@ -106,6 +106,149 @@ export function useArticleTagMap(articleIds: string[]) {
|
|||
);
|
||||
}
|
||||
|
||||
export interface SiteCount {
|
||||
siteName: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ArticlesStats {
|
||||
total: number;
|
||||
unread: number;
|
||||
reading: number;
|
||||
finished: number;
|
||||
archived: number;
|
||||
favorites: number;
|
||||
savedThisWeek: number;
|
||||
finishedThisWeek: number;
|
||||
topSites: SiteCount[];
|
||||
totalHighlights: number;
|
||||
}
|
||||
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Aggregate stats for the dashboard widget + stats section. One live
|
||||
* query over scope-filtered articles + highlights; decrypts only the
|
||||
* articles (needed for title-based top-sites grouping).
|
||||
*/
|
||||
export function useStats() {
|
||||
return useLiveQueryWithDefault(
|
||||
async () => {
|
||||
const [articleRows, highlightRows] = await Promise.all([
|
||||
scopedForModule<LocalArticle, string>('articles', 'articles').toArray(),
|
||||
scopedForModule<LocalHighlight, string>('articles', 'articleHighlights').toArray(),
|
||||
]);
|
||||
const visible = articleRows.filter((a) => !a.deletedAt);
|
||||
const decrypted = await decryptRecords('articles', visible);
|
||||
|
||||
const now = Date.now();
|
||||
const weekAgo = now - WEEK_MS;
|
||||
|
||||
const byStatus: Record<ArticleStatus, number> = {
|
||||
unread: 0,
|
||||
reading: 0,
|
||||
finished: 0,
|
||||
archived: 0,
|
||||
};
|
||||
let favorites = 0;
|
||||
let savedThisWeek = 0;
|
||||
let finishedThisWeek = 0;
|
||||
const siteCounts = new Map<string, number>();
|
||||
|
||||
for (const a of decrypted) {
|
||||
byStatus[a.status] = (byStatus[a.status] ?? 0) + 1;
|
||||
if (a.isFavorite) favorites++;
|
||||
const savedTs = a.savedAt ? Date.parse(a.savedAt) : NaN;
|
||||
if (Number.isFinite(savedTs) && savedTs >= weekAgo) savedThisWeek++;
|
||||
const readTs = a.readAt ? Date.parse(a.readAt) : NaN;
|
||||
if (Number.isFinite(readTs) && readTs >= weekAgo) finishedThisWeek++;
|
||||
if (a.siteName) {
|
||||
siteCounts.set(a.siteName, (siteCounts.get(a.siteName) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const topSites: SiteCount[] = [...siteCounts.entries()]
|
||||
.map(([siteName, count]) => ({ siteName, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 5);
|
||||
|
||||
const totalHighlights = highlightRows.filter((h) => !h.deletedAt).length;
|
||||
|
||||
return {
|
||||
total: decrypted.length,
|
||||
unread: byStatus.unread,
|
||||
reading: byStatus.reading,
|
||||
finished: byStatus.finished,
|
||||
archived: byStatus.archived,
|
||||
favorites,
|
||||
savedThisWeek,
|
||||
finishedThisWeek,
|
||||
topSites,
|
||||
totalHighlights,
|
||||
} satisfies ArticlesStats;
|
||||
},
|
||||
{
|
||||
total: 0,
|
||||
unread: 0,
|
||||
reading: 0,
|
||||
finished: 0,
|
||||
archived: 0,
|
||||
favorites: 0,
|
||||
savedThisWeek: 0,
|
||||
finishedThisWeek: 0,
|
||||
topSites: [],
|
||||
totalHighlights: 0,
|
||||
} as ArticlesStats
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-article highlights query for `/articles/highlights`. Fetches
|
||||
* all articles + all highlights in the active scope, pairs them up,
|
||||
* and returns rows shaped for rendering as a chronological collection.
|
||||
* Articles without highlights are excluded.
|
||||
*/
|
||||
export interface HighlightWithArticle {
|
||||
highlight: Highlight;
|
||||
article: Pick<Article, 'id' | 'title' | 'siteName' | 'originalUrl'>;
|
||||
}
|
||||
|
||||
export function useAllHighlights() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const [articleRows, highlightRows] = await Promise.all([
|
||||
scopedForModule<LocalArticle, string>('articles', 'articles').toArray(),
|
||||
scopedForModule<LocalHighlight, string>('articles', 'articleHighlights').toArray(),
|
||||
]);
|
||||
const liveArticles = articleRows.filter((a) => !a.deletedAt);
|
||||
const liveHighlights = highlightRows.filter((h) => !h.deletedAt);
|
||||
if (liveHighlights.length === 0) return [] as HighlightWithArticle[];
|
||||
|
||||
const [decArticles, decHighlights] = await Promise.all([
|
||||
decryptRecords('articles', liveArticles),
|
||||
decryptRecords('articleHighlights', liveHighlights),
|
||||
]);
|
||||
|
||||
const byId = new Map(decArticles.map((a) => [a.id, toArticle(a)]));
|
||||
|
||||
return decHighlights
|
||||
.map((h) => {
|
||||
const art = byId.get(h.articleId);
|
||||
if (!art) return null;
|
||||
return {
|
||||
highlight: toHighlight(h),
|
||||
article: {
|
||||
id: art.id,
|
||||
title: art.title,
|
||||
siteName: art.siteName,
|
||||
originalUrl: art.originalUrl,
|
||||
},
|
||||
} satisfies HighlightWithArticle;
|
||||
})
|
||||
.filter((r): r is HighlightWithArticle => r !== null)
|
||||
.sort((a, b) => (b.highlight.createdAt ?? '').localeCompare(a.highlight.createdAt ?? ''));
|
||||
}, [] as HighlightWithArticle[]);
|
||||
}
|
||||
|
||||
export function useArticleHighlights(articleId: string) {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
// scopedForModule returns the scope-filtered Collection; we narrow
|
||||
|
|
|
|||
|
|
@ -0,0 +1,301 @@
|
|||
<!--
|
||||
HighlightsView — Sammelansicht über alle Highlights.
|
||||
|
||||
Gruppiert die chronologisch sortierten Highlights pro Artikel
|
||||
(gleiche Reihenfolge, die useAllHighlights liefert) und rendert sie
|
||||
mit Farb-Akzent + optionaler Notiz. "Export" kopiert die Sammlung
|
||||
als Markdown in die Zwischenablage; "Download" speichert sie als
|
||||
.md-Datei.
|
||||
|
||||
Klick auf ein Highlight oder auf den Artikel-Header springt zurück
|
||||
in den Reader.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { useAllHighlights, type HighlightWithArticle } from '../queries';
|
||||
import { renderHighlightsMarkdown } from '../lib/markdown-export';
|
||||
|
||||
const rows$ = useAllHighlights();
|
||||
const rows = $derived(rows$.value);
|
||||
|
||||
interface Group {
|
||||
articleId: string;
|
||||
article: HighlightWithArticle['article'];
|
||||
highlights: HighlightWithArticle['highlight'][];
|
||||
}
|
||||
|
||||
const groups = $derived.by<Group[]>(() => {
|
||||
const out: Group[] = [];
|
||||
let current: Group | null = null;
|
||||
for (const row of rows) {
|
||||
if (!current || current.articleId !== row.article.id) {
|
||||
current = {
|
||||
articleId: row.article.id,
|
||||
article: row.article,
|
||||
highlights: [row.highlight],
|
||||
};
|
||||
out.push(current);
|
||||
} else {
|
||||
current.highlights.push(row.highlight);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
let exportLabel = $state('Als Markdown kopieren');
|
||||
|
||||
async function copyMarkdown() {
|
||||
const md = renderHighlightsMarkdown(rows);
|
||||
try {
|
||||
await navigator.clipboard.writeText(md);
|
||||
exportLabel = 'Kopiert ✓';
|
||||
setTimeout(() => (exportLabel = 'Als Markdown kopieren'), 1500);
|
||||
} catch {
|
||||
exportLabel = 'Fehler — bitte manuell';
|
||||
}
|
||||
}
|
||||
|
||||
function downloadMarkdown() {
|
||||
const md = renderHighlightsMarkdown(rows);
|
||||
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `mana-highlights-${new Date().toISOString().slice(0, 10)}.md`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Highlights — Artikel — Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="shell">
|
||||
<header class="header">
|
||||
<div class="header-row">
|
||||
<div>
|
||||
<h1>Highlights</h1>
|
||||
<p class="subtitle">Alle markierten Stellen aus deinen gespeicherten Artikeln.</p>
|
||||
</div>
|
||||
<button type="button" class="back" onclick={() => goto('/articles')}>← Zurück</button>
|
||||
</div>
|
||||
{#if rows.length > 0}
|
||||
<div class="actions">
|
||||
<button type="button" class="ghost" onclick={copyMarkdown}>{exportLabel}</button>
|
||||
<button type="button" class="ghost" onclick={downloadMarkdown}>Als .md herunterladen</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if rows$.loading}
|
||||
<p class="muted center">Lädt…</p>
|
||||
{:else if groups.length === 0}
|
||||
<div class="empty">
|
||||
<p class="empty-headline">Noch keine Highlights.</p>
|
||||
<p class="empty-sub">
|
||||
Markier eine Textstelle in einem gespeicherten Artikel — sie erscheint hier automatisch.
|
||||
</p>
|
||||
<button type="button" class="cta" onclick={() => goto('/articles')}>Zur Leseliste</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="groups">
|
||||
{#each groups as group (group.articleId)}
|
||||
<section class="group">
|
||||
<header class="group-header">
|
||||
<button
|
||||
type="button"
|
||||
class="article-link"
|
||||
onclick={() => goto(`/articles/${group.articleId}`)}
|
||||
title="Artikel öffnen"
|
||||
>
|
||||
<span class="title">{group.article.title}</span>
|
||||
{#if group.article.siteName}
|
||||
<span class="site">{group.article.siteName}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</header>
|
||||
<ul class="hl-list">
|
||||
{#each group.highlights as h (h.id)}
|
||||
<li class="hl hl-{h.color}">
|
||||
<button
|
||||
type="button"
|
||||
class="hl-text"
|
||||
onclick={() => goto(`/articles/${group.articleId}`)}
|
||||
title="Im Artikel öffnen"
|
||||
>
|
||||
„{h.text}"
|
||||
</button>
|
||||
{#if h.note}
|
||||
<p class="hl-note">{h.note}</p>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.shell {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.header {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.back,
|
||||
.ghost,
|
||||
.cta {
|
||||
font: inherit;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.back,
|
||||
.ghost {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
.back:hover,
|
||||
.ghost:hover {
|
||||
border-color: var(--color-border-strong, rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
.actions {
|
||||
margin-top: 0.9rem;
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cta {
|
||||
border: 1px solid #f97316;
|
||||
background: #f97316;
|
||||
color: white;
|
||||
}
|
||||
.cta:hover {
|
||||
background: #ea580c;
|
||||
}
|
||||
.muted {
|
||||
color: var(--color-text-muted, #64748b);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.muted.center {
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.empty {
|
||||
margin-top: 2.5rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--color-border, rgba(0, 0, 0, 0.15));
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
.empty-headline {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.empty-sub {
|
||||
margin: 0 0 1.25rem 0;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
}
|
||||
.groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.article-link {
|
||||
font: inherit;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
padding: 0.2rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.article-link:hover .title {
|
||||
color: #f97316;
|
||||
}
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.site {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
}
|
||||
.hl-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.hl {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.45rem;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
.hl-yellow {
|
||||
background: color-mix(in srgb, #fde68a 60%, transparent);
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
.hl-green {
|
||||
background: color-mix(in srgb, #bbf7d0 60%, transparent);
|
||||
border-left-color: #10b981;
|
||||
}
|
||||
.hl-blue {
|
||||
background: color-mix(in srgb, #bfdbfe 60%, transparent);
|
||||
border-left-color: #3b82f6;
|
||||
}
|
||||
.hl-pink {
|
||||
background: color-mix(in srgb, #fbcfe8 60%, transparent);
|
||||
border-left-color: #ec4899;
|
||||
}
|
||||
.hl-text {
|
||||
font: inherit;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.hl-note {
|
||||
margin: 0.4rem 0 0 0;
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-text-muted, #334155);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* ArticlesUnreadWidget — dashboard tile for the articles module.
|
||||
*
|
||||
* Shows up to three unread articles + a one-line stats strip (saved
|
||||
* this week / total unread). Mirrors the NewsUnreadWidget pattern:
|
||||
* self-contained liveQuery, no props, renders its own tile chrome.
|
||||
*/
|
||||
|
||||
import { useAllArticles, useStats } from '../queries';
|
||||
|
||||
const articles$ = useAllArticles();
|
||||
const stats$ = useStats();
|
||||
const articles = $derived(articles$.value);
|
||||
const stats = $derived(stats$.value);
|
||||
|
||||
const topUnread = $derived(
|
||||
articles.filter((a) => a.status === 'unread' || a.status === 'reading').slice(0, 3)
|
||||
);
|
||||
</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>
|
||||
Artikel
|
||||
</h3>
|
||||
<a href="/articles" class="text-xs text-muted-foreground hover:text-foreground">Alle →</a>
|
||||
</div>
|
||||
|
||||
{#if articles$.loading}
|
||||
<div class="space-y-2">
|
||||
{#each Array(3) as _}
|
||||
<div class="h-10 animate-pulse rounded bg-surface-hover"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if articles.length === 0}
|
||||
<div class="py-4 text-center">
|
||||
<p class="text-sm text-muted-foreground">Noch keine Artikel gespeichert.</p>
|
||||
<a
|
||||
href="/articles/add"
|
||||
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
|
||||
>
|
||||
Erste URL speichern
|
||||
</a>
|
||||
</div>
|
||||
{:else if topUnread.length === 0}
|
||||
<div class="py-4 text-center">
|
||||
<p class="text-sm text-muted-foreground">Alles gelesen — stark.</p>
|
||||
<a href="/articles" class="mt-3 inline-block text-xs text-primary hover:underline">
|
||||
Leseliste öffnen
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-1.5">
|
||||
{#each topUnread as article (article.id)}
|
||||
<a
|
||||
href="/articles/{article.id}"
|
||||
class="block rounded-lg p-2 transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
<p class="line-clamp-2 text-sm font-medium leading-snug">{article.title}</p>
|
||||
<div class="mt-0.5 flex gap-1.5 text-xs text-muted-foreground">
|
||||
{#if article.siteName}
|
||||
<span class="font-medium">{article.siteName}</span>
|
||||
{/if}
|
||||
{#if article.readingTimeMinutes}
|
||||
<span>·</span>
|
||||
<span>{article.readingTimeMinutes} min</span>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mt-3 border-t border-border/50 pt-2 text-xs text-muted-foreground">
|
||||
{stats.unread} ungelesen · {stats.savedThisWeek} diese Woche gespeichert
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -32,6 +32,7 @@ export type WidgetType =
|
|||
| 'activity-feed' // TimeBlocks: recent activity across modules
|
||||
| 'period' // Period: current phase + days until next period
|
||||
| 'news-unread' // News: latest unread curated articles
|
||||
| 'articles-unread' // Articles: saved read-it-later articles
|
||||
| 'body-stats' // Body: latest weight + active workout summary
|
||||
| 'invoices-open' // Invoices: open/overdue totals + oldest overdue
|
||||
| 'broadcasts'; // Broadcast: YTD counts + last sent + next scheduled
|
||||
|
|
@ -355,6 +356,14 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
|
|||
defaultSize: 'small',
|
||||
allowMultiple: false,
|
||||
},
|
||||
{
|
||||
type: 'articles-unread',
|
||||
nameKey: 'dashboard.widgets.articles_unread.title',
|
||||
descriptionKey: 'dashboard.widgets.articles_unread.description',
|
||||
icon: '📚',
|
||||
defaultSize: 'small',
|
||||
allowMultiple: false,
|
||||
},
|
||||
{
|
||||
type: 'body-stats',
|
||||
nameKey: 'dashboard.widgets.body_stats.title',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
<script lang="ts">
|
||||
import HighlightsView from '$lib/modules/articles/views/HighlightsView.svelte';
|
||||
</script>
|
||||
|
||||
<HighlightsView />
|
||||
|
|
@ -16,11 +16,13 @@
|
|||
|
||||
**Hinweis AiProposalInbox:** Der apps/mana/CLAUDE.md-Abschnitt erwähnt `<AiProposalInbox module="articles" />` als Inline-Mount, aber die Komponente existiert im aktuellen Codebase nicht — nach dem `pendingProposals`-Table-Drop in Dexie v29 wurde die Proposal-Darstellung auf `server-iteration-staging` + den Cross-Module-Inbox im Mission-Detail-View umgestellt. Articles-Proposals tauchen dort automatisch auf. Falls die Inline-Komponente wieder reaktiviert wird, muss nur der Mount in `ListView.svelte` ergänzt werden.
|
||||
|
||||
**M7 Share-Target + Bookmarklet: DONE** (commit pending) — `@mana/shared-pwa` bekommt neue Types (`PWAShareTarget`, `PWAShareTargetParams`), `createPWAConfig` threadet `shareTarget` in den Manifest, `ManifestConfig.share_target?` ergänzt. Web-App: `vite.config.ts` setzt `shareTarget: { action: '/articles/add', method: 'GET', params: { title, text, url } }`; `AddUrlForm` liest Query-Params in `onMount` (inkl. URL-Regex-Fallback auf `text` weil Chrome Android / WhatsApp den Link dort reinstecken), triggert auto-Vorschau. Neue Route `/articles/settings` rendert Bookmarklet-Karte (Drag-to-Bookmark + Copy-Snippet + expandable Quellcode) und Share-Target-Erklärung. `ListView` bekommt Zahnrad-Button zum Settings-Aufruf.
|
||||
**M7 Share-Target + Bookmarklet: DONE** (commit `8a991f7c3`) — `@mana/shared-pwa` bekommt neue Types (`PWAShareTarget`, `PWAShareTargetParams`), `createPWAConfig` threadet `shareTarget` in den Manifest, `ManifestConfig.share_target?` ergänzt. Web-App: `vite.config.ts` setzt `shareTarget: { action: '/articles/add', method: 'GET', params: { title, text, url } }`; `AddUrlForm` liest Query-Params in `onMount` (inkl. URL-Regex-Fallback auf `text` weil Chrome Android / WhatsApp den Link dort reinstecken), triggert auto-Vorschau. Neue Route `/articles/settings` rendert Bookmarklet-Karte (Drag-to-Bookmark + Copy-Snippet + expandable Quellcode) und Share-Target-Erklärung. `ListView` bekommt Zahnrad-Button zum Settings-Aufruf.
|
||||
|
||||
Nicht im Scope (bewusst ausgelassen): die „optional" im Plan markierte `_pendingUrls`-Offline-Queue. Kann als M7b nachgereicht werden wenn das Problem auftaucht.
|
||||
|
||||
Nächster Schritt: M8 (HighlightsView + Stats + Dashboard-Widget).
|
||||
**M8 HighlightsView + Stats + Dashboard-Widget: DONE** (commit pending) — `queries.ts` bekommt `useStats()` (total, pro-Status, savedThisWeek, finishedThisWeek, topSites, totalHighlights) und `useAllHighlights()` (chronologisch, joined mit Artikel-Header-Info). Neue Route `/articles/highlights` mountet `HighlightsView.svelte`: Highlights pro Artikel gruppiert, farbige Akzent-Striche, Click-to-Reader, Copy-Markdown + Download-.md via `lib/markdown-export.ts` (pure Funktion, unit-testbar). Dashboard-Widget `ArticlesUnreadWidget` zeigt Top-3 unread + Stats-Strip; registriert in `widget-registry.ts`, `dashboard.ts` `WidgetType`-Union + `WIDGET_REGISTRY`; i18n-Keys in de.json + en.json (fr/it/es konsistent mit anderen Widgets weggelassen). ListView bekommt ✎-Button zum Highlights-Aufruf neben dem Settings-Zahnrad.
|
||||
|
||||
Damit ist der komplette M1–M8-Scope aus diesem Plan umgesetzt. Phase-3-Kandidaten (Highlight→Note-Export mit Backlink, Full-Text-Search, Mercury/archive.org-Fallback, Jahresrückblick) bleiben offen für spätere Iterationen.
|
||||
|
||||
## Ziel
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue