#134 Statuszeile zwischen Navigation und Artikel
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
This commit is contained in:
parent
2974a54ef8
commit
c858f12592
22
apps/web/src/lib/word-count.ts
Normal file
22
apps/web/src/lib/word-count.ts
Normal file
@ -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 ?? '';
|
||||
}
|
||||
@ -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()}
|
||||
/>
|
||||
</div>
|
||||
{/* Status line between the header and the article (#134): last update,
|
||||
word count, reading time — reading mode only. */}
|
||||
{mode === 'view' && page.data && (
|
||||
<PageStatusBar updatedAt={page.data.updatedAt} wordCount={wordCount} />
|
||||
)}
|
||||
<div className="editor-page__body">
|
||||
<PageEditor
|
||||
page={resolved}
|
||||
|
||||
44
apps/web/src/pages/PageStatusBar.tsx
Normal file
44
apps/web/src/pages/PageStatusBar.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/** Average silent reading speed; reading time = ceil(words / WPM). */
|
||||
const WORDS_PER_MINUTE = 200;
|
||||
|
||||
/**
|
||||
* A slim status line shown between the page header and the article body
|
||||
* (issue #134): last update, word count, and estimated reading time. Rendered
|
||||
* both in the authenticated read view and the anonymous public view, so it
|
||||
* takes already-derived values as props rather than reaching into the editor.
|
||||
*/
|
||||
export function PageStatusBar({
|
||||
updatedAt,
|
||||
wordCount,
|
||||
}: {
|
||||
updatedAt: string;
|
||||
wordCount: number;
|
||||
}): React.JSX.Element {
|
||||
const { t, i18n } = useTranslation('common');
|
||||
|
||||
const updated = useMemo(() => {
|
||||
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 (
|
||||
<div className="page-statusbar" aria-label={t('statusbar.label')}>
|
||||
{updated && (
|
||||
<span className="page-statusbar__item">{t('statusbar.updated', { date: updated })}</span>
|
||||
)}
|
||||
<span className="page-statusbar__item">{t('statusbar.words', { count: wordCount })}</span>
|
||||
{wordCount > 0 && (
|
||||
<span className="page-statusbar__item">{t('statusbar.readingTime', { minutes })}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 <NotFoundPage />;
|
||||
if (query.isLoading || !query.data) return <div aria-busy="true" />;
|
||||
|
||||
@ -40,6 +51,7 @@ export function PublicPageView(): React.JSX.Element {
|
||||
<p className="public-page__badge">{t('readOnlyBadge')}</p>
|
||||
<p className="public-page__pond">{page.pondName}</p>
|
||||
<h1 className="public-page__title">{page.title}</h1>
|
||||
<PageStatusBar updatedAt={page.updatedAt} wordCount={wordCount} />
|
||||
{/* The HTML comes from the server's content cache (issue #24), derived
|
||||
from the sanitized editor schema — safe to render. */}
|
||||
<div className="public-page__body" dangerouslySetInnerHTML={{ __html: page.html }} />
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user