From c858f12592377a2488d7d8352ea216917daa4468 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sun, 19 Jul 2026 01:04:11 +0200 Subject: [PATCH] #134 Statuszeile zwischen Navigation und Artikel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue schlanke Statuszeile (letzte Aktualisierung · Wortzahl · geschätzte Lesezeit) zwischen Seitenkopf und Artikel — im authentifizierten Lesemodus und in der öffentlichen Ansicht. - Geteilte Komponente `PageStatusBar` (Datum via Intl in der aktiven Sprache, Lesezeit = ceil(Wörter/200), Singular/Plural, Lesezeit ausgeblendet bei 0 Wörtern). - `countWords`/`htmlToText`-Helfer in lib/word-count.ts. - Authentifiziert (`PageEditorPage`, nur Lesemodus): Wortzahl aus dem vorhandenen Markdown-Export (geteilter Query-Key ['page-markdown']), `updatedAt` direkt von `page.data`. - Öffentlich (`PublicPageView`): Wortzahl aus dem server-gerenderten HTML per DOMParser — kein Editor-Bundle nötig; kein Backend-Change. - i18n common.statusbar (de+en), CSS `.page-statusbar` (middot-getrennt, gedämpft). Gates grün (typecheck/lint/i18n:check); visuell verifiziert. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC --- apps/web/src/lib/word-count.ts | 22 ++++++++++++++ apps/web/src/pages/PageEditorPage.tsx | 19 +++++++++++- apps/web/src/pages/PageStatusBar.tsx | 44 +++++++++++++++++++++++++++ apps/web/src/pages/PublicPageView.tsx | 12 ++++++++ apps/web/src/styles/base.css | 23 ++++++++++++++ packages/shared/i18n/de/common.json | 7 +++++ packages/shared/i18n/en/common.json | 7 +++++ 7 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/lib/word-count.ts create mode 100644 apps/web/src/pages/PageStatusBar.tsx diff --git a/apps/web/src/lib/word-count.ts b/apps/web/src/lib/word-count.ts new file mode 100644 index 0000000..5eb2a30 --- /dev/null +++ b/apps/web/src/lib/word-count.ts @@ -0,0 +1,22 @@ +/** + * Approximate word count from plain text (issue #134). Splits on whitespace + * and keeps only tokens that contain a letter or digit, so markdown/HTML + * punctuation (`##`, `-`, `|`, `>`) never inflates the count. + */ +export function countWords(text: string): number { + if (!text) return 0; + return text + .trim() + .split(/\s+/) + .filter((token) => /[\p{L}\p{N}]/u.test(token)).length; +} + +/** + * Strip HTML to its visible text using the browser's parser (issue #134). Used + * by the public read view to derive a word count from the server-rendered HTML + * without loading the editor bundle. + */ +export function htmlToText(html: string): string { + if (!html) return ''; + return new DOMParser().parseFromString(html, 'text/html').body.textContent ?? ''; +} diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index 96d9d77..86918c3 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -32,9 +32,11 @@ import { WikilinkAutocomplete } from '../editor/WikilinkAutocomplete'; import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context'; import { usePageActionsSlot } from '../layout/page-actions'; import { useForceSidebarHidden } from '../layout/sidebar-chrome'; -import { ApiError, apiGet, apiPatch, apiPost } from '../lib/api'; +import { ApiError, apiGet, apiGetText, apiPatch, apiPost } from '../lib/api'; import { hasPrimaryModifier, isTypingTarget } from '../lib/keyboard'; +import { countWords } from '../lib/word-count'; import { PageActions } from './PageActions'; +import { PageStatusBar } from './PageStatusBar'; import { recallPage, rememberPage } from '../offline/page-cache'; import { PluginBlockContext } from '../editor/plugin-block-context'; import { hasPageTools, PageToolsPanel } from '../plugins/PageToolsPanel'; @@ -360,6 +362,16 @@ export function PageEditorPage(): React.JSX.Element { enabled: Boolean(pond.data), }); + // Word count for the read-mode status line (#134). Derived from the same + // Markdown export the history panel uses (shared query key), fetched only in + // reading mode so the editor session pays nothing. + const pageMarkdown = useQuery({ + queryKey: ['page-markdown', page.data?.id], + queryFn: () => apiGetText(`/pages/${page.data!.id}/export/markdown`), + enabled: mode === 'view' && Boolean(page.data?.id), + }); + const wordCount = useMemo(() => countWords(pageMarkdown.data ?? ''), [pageMarkdown.data]); + // Remember this page's metadata while online so it can be opened offline (#38). useEffect(() => { if (pond.data && page.data) { @@ -521,6 +533,11 @@ export function PageEditorPage(): React.JSX.Element { onBlur={() => void saveTitle()} /> + {/* Status line between the header and the article (#134): last update, + word count, reading time — reading mode only. */} + {mode === 'view' && page.data && ( + + )}
{ + const date = new Date(updatedAt); + if (Number.isNaN(date.getTime())) return null; + return new Intl.DateTimeFormat(i18n.language, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(date); + }, [updatedAt, i18n.language]); + + const minutes = Math.max(1, Math.ceil(wordCount / WORDS_PER_MINUTE)); + + return ( +
+ {updated && ( + {t('statusbar.updated', { date: updated })} + )} + {t('statusbar.words', { count: wordCount })} + {wordCount > 0 && ( + {t('statusbar.readingTime', { minutes })} + )} +
+ ); +} diff --git a/apps/web/src/pages/PublicPageView.tsx b/apps/web/src/pages/PublicPageView.tsx index 23d9981..1db8ecd 100644 --- a/apps/web/src/pages/PublicPageView.tsx +++ b/apps/web/src/pages/PublicPageView.tsx @@ -1,9 +1,12 @@ import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import { ApiError, apiGet } from '../lib/api'; +import { countWords, htmlToText } from '../lib/word-count'; import { NotFoundPage } from './NotFoundPage'; +import { PageStatusBar } from './PageStatusBar'; interface PublicPageContent { pondName: string; @@ -31,6 +34,14 @@ export function PublicPageView(): React.JSX.Element { retry: false, }); + // Word count for the status line (#134), derived from the server-rendered + // HTML — no editor bundle needed. Kept before the early returns so the hook + // order stays stable. + const wordCount = useMemo( + () => countWords(htmlToText(query.data?.html ?? '')), + [query.data?.html], + ); + if (query.error instanceof ApiError && query.error.status === 404) return ; if (query.isLoading || !query.data) return
; @@ -40,6 +51,7 @@ export function PublicPageView(): React.JSX.Element {

{t('readOnlyBadge')}

{page.pondName}

{page.title}

+ {/* The HTML comes from the server's content cache (issue #24), derived from the sanitized editor schema — safe to render. */}
diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index c10aac2..b09634f 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -979,6 +979,29 @@ button { outline-offset: 2px; } +/* Status line between page header and article (#134). Middot-separated, + muted; shared by the authenticated read view and the public view. */ +.page-statusbar { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-top: calc(-1 * var(--space-2)); + margin-bottom: var(--space-4); + color: var(--color-text-muted); + font-size: 0.8125rem; +} + +.page-statusbar__item { + display: inline-flex; + align-items: center; +} + +.page-statusbar__item:not(:first-child)::before { + content: '·'; + margin-right: var(--space-2); + color: var(--color-border); +} + /* The export entries flow inline with the other overflow-menu items. */ .editor-page__export { display: contents; diff --git a/packages/shared/i18n/de/common.json b/packages/shared/i18n/de/common.json index 10c9916..0117fa0 100644 --- a/packages/shared/i18n/de/common.json +++ b/packages/shared/i18n/de/common.json @@ -1,4 +1,11 @@ { + "statusbar": { + "label": "Seiteninformationen", + "updated": "Aktualisiert {{date}}", + "words_one": "{{count}} Wort", + "words_other": "{{count}} Wörter", + "readingTime": "ca. {{minutes}} Min. Lesezeit" + }, "layout": { "sidebar": { "expand": "Seitenleiste einblenden", diff --git a/packages/shared/i18n/en/common.json b/packages/shared/i18n/en/common.json index 919dcb8..b9e8576 100644 --- a/packages/shared/i18n/en/common.json +++ b/packages/shared/i18n/en/common.json @@ -1,4 +1,11 @@ { + "statusbar": { + "label": "Page information", + "updated": "Updated {{date}}", + "words_one": "{{count}} word", + "words_other": "{{count}} words", + "readingTime": "~{{minutes}} min read" + }, "layout": { "sidebar": { "expand": "Show sidebar",