From 1eca7c334c0bc3b90777baac2f5087eb7ec8855d Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Tue, 21 Jul 2026 13:52:59 +0200 Subject: [PATCH 1/2] =?UTF-8?q?#162:=20Fokus-Management=20f=C3=BCr=20Dialo?= =?UTF-8?q?ge=20und=20Such-Palette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemeinsamer useModalFocus-Hook: Initialfokus in den Dialog, Tab/Shift-Tab zyklisch gefangen, Fokus-Rückgabe an den Auslöser (bzw. returnFocusRef, wenn der öffnende Menüpunkt mit dem Menü unmountet). Dialoge tragen jetzt aria-labelledby auf ihre Überschrift und tabindex=-1 als Fokus-Fallback. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq --- apps/web/src/import/VaultImportSection.tsx | 18 +++++- apps/web/src/lib/use-modal-focus.ts | 69 ++++++++++++++++++++++ apps/web/src/pages/DeletePageDialog.tsx | 21 ++++++- apps/web/src/pages/MovePageDialog.tsx | 21 ++++++- apps/web/src/pages/PageActions.tsx | 8 ++- apps/web/src/search/SearchPalette.tsx | 11 +++- 6 files changed, 137 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/lib/use-modal-focus.ts diff --git a/apps/web/src/import/VaultImportSection.tsx b/apps/web/src/import/VaultImportSection.tsx index acc87f2..78ed69f 100644 --- a/apps/web/src/import/VaultImportSection.tsx +++ b/apps/web/src/import/VaultImportSection.tsx @@ -9,7 +9,7 @@ import type { } from '@dorfteich/shared'; import { buildTree, labelDepth } from '@dorfteich/shared'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useRef, useState } from 'react'; +import { useId, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; @@ -17,6 +17,7 @@ import { FormError } from '../components/forms'; import { labelsKey, usePondLabels } from '../labels/use-pond-labels'; import { ApiError, apiGet, apiPost, apiUploadFile } from '../lib/api'; import { useDismissable } from '../lib/use-dismissable'; +import { useModalFocus } from '../lib/use-modal-focus'; const POLL_INTERVAL_MS = 1000; // Vault jobs process many notes and files — budget well past a single @@ -83,6 +84,8 @@ function VaultImportDialog({ const [frontmatter, setFrontmatter] = useState('strip'); const running = phase.kind === 'running'; useDismissable(dialogRef, !running, onClose); + useModalFocus(dialogRef); + const titleId = useId(); const pond = useQuery({ queryKey: ['pond', pondSlug], @@ -182,8 +185,17 @@ function VaultImportDialog({ return (
-
-

{t('vault.title')}

+
+

+ {t('vault.title')} +

{phase.kind === 'done' ? ( diff --git a/apps/web/src/lib/use-modal-focus.ts b/apps/web/src/lib/use-modal-focus.ts new file mode 100644 index 0000000..5c7ac96 --- /dev/null +++ b/apps/web/src/lib/use-modal-focus.ts @@ -0,0 +1,69 @@ +import { useEffect, type RefObject } from 'react'; + +const FOCUSABLE = + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), ' + + 'textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +function visibleFocusables(container: HTMLElement): HTMLElement[] { + return [...container.querySelectorAll(FOCUSABLE)].filter( + (el) => el.getClientRects().length > 0, + ); +} + +/** + * Focus management for modal dialogs (issue #162, WCAG 2.4.3): moves focus + * into the dialog on mount (unless an autofocused child already holds it), + * keeps Tab/Shift+Tab cycling inside, and returns focus on unmount — to + * `restoreRef` if given (an element, or a container whose first focusable + * child is used; needed when the opening control unmounts with its menu), + * otherwise to the element focused when the dialog appeared. + */ +export function useModalFocus( + ref: RefObject, + restoreRef?: RefObject, +): void { + useEffect(() => { + const dialog = ref.current; + if (!dialog) return undefined; + const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null; + + if (!dialog.contains(document.activeElement)) { + (visibleFocusables(dialog)[0] ?? dialog).focus(); + } + + function onKeyDown(event: KeyboardEvent): void { + if (event.key !== 'Tab') return; + const focusables = visibleFocusables(dialog!); + if (focusables.length === 0) { + event.preventDefault(); + return; + } + const first = focusables[0]!; + const last = focusables[focusables.length - 1]!; + const active = document.activeElement; + const inside = dialog!.contains(active); + if (event.shiftKey && (active === first || !inside)) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && (active === last || !inside)) { + event.preventDefault(); + first.focus(); + } + } + + document.addEventListener('keydown', onKeyDown, true); + return () => { + document.removeEventListener('keydown', onKeyDown, true); + const target = restoreRef?.current; + const restore = + target && target.isConnected + ? target.matches(FOCUSABLE) + ? target + : visibleFocusables(target)[0] + : opener && opener.isConnected + ? opener + : undefined; + restore?.focus(); + }; + }, [ref, restoreRef]); +} diff --git a/apps/web/src/pages/DeletePageDialog.tsx b/apps/web/src/pages/DeletePageDialog.tsx index 3e4d446..8da0ca9 100644 --- a/apps/web/src/pages/DeletePageDialog.tsx +++ b/apps/web/src/pages/DeletePageDialog.tsx @@ -1,9 +1,10 @@ -import { useRef, useState } from 'react'; +import { useId, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { FormError } from '../components/forms'; import { apiDelete } from '../lib/api'; import { useDismissable } from '../lib/use-dismissable'; +import { useModalFocus } from '../lib/use-modal-focus'; /** * The per-case delete decision for a page with subpages (issue #107/#109): @@ -17,18 +18,23 @@ export function DeletePageDialog({ childCount, onDeleted, onClose, + returnFocusRef, }: { pageId: string; title: string; childCount: number; onDeleted: () => void; onClose: () => void; + /** Where focus goes after closing when the opening control unmounted. */ + returnFocusRef?: React.RefObject; }): React.JSX.Element { const { t } = useTranslation('editor'); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const dialogRef = useRef(null); + const titleId = useId(); useDismissable(dialogRef, true, onClose); + useModalFocus(dialogRef, returnFocusRef); async function remove(mode: 'promote' | 'subtree'): Promise { setError(null); @@ -44,8 +50,17 @@ export function DeletePageDialog({ return (
-
-

{t('page.deleteChildrenTitle')}

+
+

+ {t('page.deleteChildrenTitle')} +

{t('page.deleteChildrenHint', { title, count: childCount })}

diff --git a/apps/web/src/pages/MovePageDialog.tsx b/apps/web/src/pages/MovePageDialog.tsx index 0d09ee6..88a85fa 100644 --- a/apps/web/src/pages/MovePageDialog.tsx +++ b/apps/web/src/pages/MovePageDialog.tsx @@ -1,12 +1,13 @@ import type { PageListItemView, PondView, TreeNode } from '@dorfteich/shared'; import { buildTree, collectSubtreeIds } from '@dorfteich/shared'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useRef, useState } from 'react'; +import { useId, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { FormError } from '../components/forms'; import { apiGet, apiPatch } from '../lib/api'; import { useDismissable } from '../lib/use-dismissable'; +import { useModalFocus } from '../lib/use-modal-focus'; /** * "Move to…" dialog (issue #109): an indented parent picker over the page @@ -19,17 +20,22 @@ export function MovePageDialog({ pageId, pondSlug, onClose, + returnFocusRef, }: { pageId: string; pondSlug: string; onClose: () => void; + /** Where focus goes after closing when the opening control unmounted. */ + returnFocusRef?: React.RefObject; }): React.JSX.Element { const { t } = useTranslation('editor'); const queryClient = useQueryClient(); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const dialogRef = useRef(null); + const titleId = useId(); useDismissable(dialogRef, true, onClose); + useModalFocus(dialogRef, returnFocusRef); const pond = useQuery({ queryKey: ['pond', pondSlug], @@ -81,8 +87,17 @@ export function MovePageDialog({ return (
-
-

{t('page.moveTitle')}

+
+

+ {t('page.moveTitle')} +

  • diff --git a/apps/web/src/pages/PageActions.tsx b/apps/web/src/pages/PageActions.tsx index d8ab604..20fc7e8 100644 --- a/apps/web/src/pages/PageActions.tsx +++ b/apps/web/src/pages/PageActions.tsx @@ -284,13 +284,19 @@ function PageOverflowMenu({
)} {moving && ( - setMoving(false)} /> + setMoving(false)} + returnFocusRef={menuRef} + /> )} {deleting && ( { setDeleting(null); void queryClient.invalidateQueries({ queryKey: ['pages'] }); diff --git a/apps/web/src/search/SearchPalette.tsx b/apps/web/src/search/SearchPalette.tsx index 6cbab8e..f5265c9 100644 --- a/apps/web/src/search/SearchPalette.tsx +++ b/apps/web/src/search/SearchPalette.tsx @@ -8,6 +8,7 @@ import { LabelChips } from '../labels/LabelChips'; import { usePondLabels } from '../labels/use-pond-labels'; import { useCurrentPondRoute } from '../layout/use-pond-route'; import { apiGet } from '../lib/api'; +import { useModalFocus } from '../lib/use-modal-focus'; import { HighlightedSnippet } from './highlight'; const RECENT_KEY = 'dorfteich.recentSearches'; @@ -45,6 +46,8 @@ export function SearchPalette({ onClose }: { onClose: () => void }): React.JSX.E const navigate = useNavigate(); const { pondSlug } = useCurrentPondRoute(); const inputRef = useRef(null); + const overlayRef = useRef(null); + useModalFocus(overlayRef); const pond = useQuery({ queryKey: ['pond', pondSlug], @@ -111,7 +114,13 @@ export function SearchPalette({ onClose }: { onClose: () => void }): React.JSX.E } return ( -
+
Date: Tue, 21 Jul 2026 13:52:59 +0200 Subject: [PATCH 2/2] #163: Dokumentsprache und Seitentitel der SPA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit i18n spiegelt die aktive Sprache auf (Init + languageChanged; der User-Locale-Wechsel in auth-context läuft über dasselbe Event). Neuer useDocumentTitle-Hook setzt je Route einen sprechenden Titel (Seite — Teich — Dorfteich), verdrahtet in allen Routen-Komponenten; dynamische Titel folgen den geladenen Daten. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq --- apps/web/src/graph/PondGraphPage.tsx | 2 ++ apps/web/src/i18n/index.ts | 9 ++++++++ apps/web/src/lib/use-document-title.ts | 22 +++++++++++++++++++ apps/web/src/pages/AdminSettingsPage.tsx | 2 ++ apps/web/src/pages/AdminSystemPage.tsx | 2 ++ apps/web/src/pages/FontCatalogPage.tsx | 2 ++ apps/web/src/pages/HomePage.tsx | 2 ++ apps/web/src/pages/LegalPage.tsx | 2 ++ apps/web/src/pages/MaintenancePage.tsx | 2 ++ apps/web/src/pages/NotFoundPage.tsx | 2 ++ apps/web/src/pages/PageEditorPage.tsx | 2 ++ apps/web/src/pages/PondHomePage.tsx | 2 ++ apps/web/src/pages/PondSettingsPage.tsx | 2 ++ apps/web/src/pages/PublicPageView.tsx | 2 ++ apps/web/src/pages/SettingsPage.tsx | 2 ++ apps/web/src/pages/TrashPage.tsx | 2 ++ .../web/src/pages/auth/ForgotPasswordPage.tsx | 2 ++ apps/web/src/pages/auth/LoginPage.tsx | 2 ++ apps/web/src/pages/auth/ResetPasswordPage.tsx | 2 ++ apps/web/src/pages/auth/SignupPage.tsx | 2 ++ apps/web/src/pages/auth/VerifyEmailPage.tsx | 2 ++ 21 files changed, 69 insertions(+) create mode 100644 apps/web/src/lib/use-document-title.ts diff --git a/apps/web/src/graph/PondGraphPage.tsx b/apps/web/src/graph/PondGraphPage.tsx index e757cce..8d73c7d 100644 --- a/apps/web/src/graph/PondGraphPage.tsx +++ b/apps/web/src/graph/PondGraphPage.tsx @@ -9,6 +9,7 @@ import { FormError } from '../components/forms'; import { usePondLabels } from '../labels/use-pond-labels'; import { apiGet, apiPost } from '../lib/api'; import { usePersistentState } from '../lib/use-persistent-state'; +import { useDocumentTitle } from '../lib/use-document-title'; import { ForceGraph, GRAPH_SETTINGS_DEFAULTS, @@ -183,6 +184,7 @@ export function PondGraphPage(): React.JSX.Element { queryKey: ['pond', pondSlug], queryFn: () => apiGet(`/ponds/${pondSlug}`), }); + useDocumentTitle(t('title'), pond.data?.name); const graph = useQuery({ queryKey: ['pond-links', pond.data?.id], queryFn: () => apiGet(`/ponds/${pond.data!.id}/links`), diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts index 2b946e0..e7ff8e8 100644 --- a/apps/web/src/i18n/index.ts +++ b/apps/web/src/i18n/index.ts @@ -133,4 +133,13 @@ void i18n detection: { order: ['querystring', 'navigator'], lookupQuerystring: 'lng', caches: [] }, }); +// Mirror the active language onto (issue #163, WCAG 3.1.1): +// index.html ships a static fallback, and screen readers pick their voice +// from this attribute. Fires on init and on every later switch (e.g. the +// user-profile locale applied in auth-context). +i18n.on('languageChanged', (lng) => { + document.documentElement.lang = lng; +}); +if (i18n.resolvedLanguage) document.documentElement.lang = i18n.resolvedLanguage; + export default i18n; diff --git a/apps/web/src/lib/use-document-title.ts b/apps/web/src/lib/use-document-title.ts new file mode 100644 index 0000000..a3ec0a7 --- /dev/null +++ b/apps/web/src/lib/use-document-title.ts @@ -0,0 +1,22 @@ +import { useEffect } from 'react'; + +const APP_NAME = 'Dorfteich'; + +/** + * Route-specific document title (issue #163, WCAG 2.4.2): joins the given + * parts with the app name ("Page — Pond — Dorfteich"). Empty/undefined + * parts are skipped, so callers can pass still-loading data directly. + * Falls back to the bare app name on unmount. + */ +export function useDocumentTitle(...parts: (string | null | undefined)[]): void { + const joined = [...parts.filter(Boolean), APP_NAME].join(' — '); + useEffect(() => { + document.title = joined; + }, [joined]); + useEffect( + () => () => { + document.title = APP_NAME; + }, + [], + ); +} diff --git a/apps/web/src/pages/AdminSettingsPage.tsx b/apps/web/src/pages/AdminSettingsPage.tsx index 88d32b8..021ba5f 100644 --- a/apps/web/src/pages/AdminSettingsPage.tsx +++ b/apps/web/src/pages/AdminSettingsPage.tsx @@ -12,6 +12,7 @@ import { PluginManager } from './PluginManager'; import { QuotaManager } from './QuotaManager'; import { UserManager } from './UserManager'; +import { useDocumentTitle } from '../lib/use-document-title'; interface InstanceSettings { 'auth.registrationMode': 'open' | 'closed'; 'instance.name': string; @@ -32,6 +33,7 @@ interface InstanceSettings { export function AdminSettingsPage(): React.JSX.Element { const { t } = useTranslation(); + useDocumentTitle(t('settings:admin.title')); const { t: tQuotas } = useTranslation('quotas'); const queryClient = useQueryClient(); const [error, setError] = useState(null); diff --git a/apps/web/src/pages/AdminSystemPage.tsx b/apps/web/src/pages/AdminSystemPage.tsx index 428b64b..4d6df1b 100644 --- a/apps/web/src/pages/AdminSystemPage.tsx +++ b/apps/web/src/pages/AdminSystemPage.tsx @@ -16,6 +16,7 @@ import { formatBytes } from '../files/file-format'; import { apiGet, apiPost } from '../lib/api'; import { BackupSection } from './AdminBackupSection'; +import { useDocumentTitle } from '../lib/use-document-title'; /** * Site-Admin "System" panel (issue #86): maintenance jobs with a manual * trigger, the backup card fed by the sidecar's status.json, the audit-log @@ -24,6 +25,7 @@ import { BackupSection } from './AdminBackupSection'; */ export function AdminSystemPage(): React.JSX.Element { const { t } = useTranslation('system'); + useDocumentTitle(t('title')); return ( <>

{t('title')}

diff --git a/apps/web/src/pages/FontCatalogPage.tsx b/apps/web/src/pages/FontCatalogPage.tsx index 68a3fcf..cf3dafd 100644 --- a/apps/web/src/pages/FontCatalogPage.tsx +++ b/apps/web/src/pages/FontCatalogPage.tsx @@ -1,6 +1,7 @@ import { FONT_CATALOG, fontStack } from '@dorfteich/shared'; import { useTranslation } from 'react-i18next'; +import { useDocumentTitle } from '../lib/use-document-title'; /** * Font catalog attribution page (issue #66, ADR 0016): lists every self-hosted * family with its license, rendered in the font itself. The self-hosting is the @@ -8,6 +9,7 @@ import { useTranslation } from 'react-i18next'; */ export function FontCatalogPage(): React.JSX.Element { const { t } = useTranslation('font'); + useDocumentTitle(t('catalog.title')); return (

{t('catalog.title')}

diff --git a/apps/web/src/pages/HomePage.tsx b/apps/web/src/pages/HomePage.tsx index 406e1d7..e27e1d7 100644 --- a/apps/web/src/pages/HomePage.tsx +++ b/apps/web/src/pages/HomePage.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { apiGet, fetchHealth } from '../lib/api'; +import { useDocumentTitle } from '../lib/use-document-title'; /** * Public landing page (`/`). When a Site Admin has written landing content * (Admin → Settings → Landing page), it renders that — server-sanitized @@ -12,6 +13,7 @@ import { apiGet, fetchHealth } from '../lib/api'; */ export function HomePage(): React.JSX.Element { const { t } = useTranslation(); + useDocumentTitle(t('home.title')); const home = useQuery({ queryKey: ['home-content'], queryFn: () => apiGet('/home/content'), diff --git a/apps/web/src/pages/LegalPage.tsx b/apps/web/src/pages/LegalPage.tsx index 229a243..8d16d7d 100644 --- a/apps/web/src/pages/LegalPage.tsx +++ b/apps/web/src/pages/LegalPage.tsx @@ -7,6 +7,7 @@ import { useAuth } from '../auth/auth-context'; import { apiGet } from '../lib/api'; import { NotFoundPage } from './NotFoundPage'; +import { useDocumentTitle } from '../lib/use-document-title'; /** * Public legal page (issue #82): imprint or privacy policy, reachable * without a session. The HTML comes from the api's sanitizing Markdown @@ -18,6 +19,7 @@ export function LegalPage(): React.JSX.Element { const { t } = useTranslation('legal'); const { user } = useAuth(); const { kind = '' } = useParams<{ kind: string }>(); + useDocumentTitle(t(`title.${kind}`)); const query = useQuery({ queryKey: ['legal', kind], diff --git a/apps/web/src/pages/MaintenancePage.tsx b/apps/web/src/pages/MaintenancePage.tsx index 181acfd..b73a8c2 100644 --- a/apps/web/src/pages/MaintenancePage.tsx +++ b/apps/web/src/pages/MaintenancePage.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { apiGet } from '../lib/api'; +import { useDocumentTitle } from '../lib/use-document-title'; const POLL_INTERVAL_MS = 3000; /** @@ -15,6 +16,7 @@ const POLL_INTERVAL_MS = 3000; */ export function MaintenancePage(): React.JSX.Element { const { t } = useTranslation('system'); + useDocumentTitle(t('maintenance.title')); const [status, setStatus] = useState(null); useEffect(() => { diff --git a/apps/web/src/pages/NotFoundPage.tsx b/apps/web/src/pages/NotFoundPage.tsx index fde3428..3f8b670 100644 --- a/apps/web/src/pages/NotFoundPage.tsx +++ b/apps/web/src/pages/NotFoundPage.tsx @@ -1,8 +1,10 @@ import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; +import { useDocumentTitle } from '../lib/use-document-title'; export function NotFoundPage(): React.JSX.Element { const { t } = useTranslation(); + useDocumentTitle(t('notFound.title')); return ( <>

{t('notFound.title')}

diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index db5aff5..93ed349 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -41,6 +41,7 @@ import { recallPage, rememberPage } from '../offline/page-cache'; import { PluginBlockContext } from '../editor/plugin-block-context'; import { hasPageTools, PageToolsPanel } from '../plugins/PageToolsPanel'; import { SectionStyleSheets } from '../plugins/SectionStyleSheets'; +import { useDocumentTitle } from '../lib/use-document-title'; import { pluginBlockOptions, sectionStyleOptions, @@ -373,6 +374,7 @@ export function PageEditorPage(): React.JSX.Element { queryFn: () => apiGet(`/ponds/${pond.data!.id}/pages/${pageSlug}`), enabled: Boolean(pond.data), }); + useDocumentTitle(page.data?.title, pond.data?.name); // 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 diff --git a/apps/web/src/pages/PondHomePage.tsx b/apps/web/src/pages/PondHomePage.tsx index 9e82e3e..77ae254 100644 --- a/apps/web/src/pages/PondHomePage.tsx +++ b/apps/web/src/pages/PondHomePage.tsx @@ -7,6 +7,7 @@ import { useNavigate, useParams } from 'react-router-dom'; import { FormError } from '../components/forms'; import { apiGet } from '../lib/api'; +import { useDocumentTitle } from '../lib/use-document-title'; /** * Landing route for a pond without a page open yet (`/p/:pondSlug`, e.g. * from the pond switcher). Redirects to the first page per the pond's sort @@ -22,6 +23,7 @@ export function PondHomePage(): React.JSX.Element { queryKey: ['pond', pondSlug], queryFn: () => apiGet(`/ponds/${pondSlug}`), }); + useDocumentTitle(pond.data?.name); const pages = useQuery({ queryKey: ['pages', pond.data?.id, pond.data?.settings.sidebarSort], queryFn: () => apiGet(`/ponds/${pond.data!.id}/pages`), diff --git a/apps/web/src/pages/PondSettingsPage.tsx b/apps/web/src/pages/PondSettingsPage.tsx index f7707d4..43f7c3f 100644 --- a/apps/web/src/pages/PondSettingsPage.tsx +++ b/apps/web/src/pages/PondSettingsPage.tsx @@ -22,6 +22,7 @@ import { MemberManager } from '../members/MemberManager'; import { DeletePondSection } from '../ponds/DeletePondSection'; import { PondPluginSettings } from '../plugins/PondPluginSettings'; +import { useDocumentTitle } from '../lib/use-document-title'; /** * Pond settings (issues #44/#54). Hosts the 'Members' and 'Labels' sections; * future pond-level configuration (fonts, etc.) joins it here. Member @@ -49,6 +50,7 @@ export function PondSettingsPage(): React.JSX.Element { queryKey: ['pond', pondSlug], queryFn: () => apiGet(`/ponds/${pondSlug}`), }); + useDocumentTitle(tCommon('settings:title'), pond.data?.name); if (pond.error) return ; if (!pond.data) return <>; diff --git a/apps/web/src/pages/PublicPageView.tsx b/apps/web/src/pages/PublicPageView.tsx index 6f48cc5..c5f3cb6 100644 --- a/apps/web/src/pages/PublicPageView.tsx +++ b/apps/web/src/pages/PublicPageView.tsx @@ -5,6 +5,7 @@ import { useParams } from 'react-router-dom'; import { PublicComments } from '../comments/CommentsSection'; import { ApiError, apiGet } from '../lib/api'; +import { useDocumentTitle } from '../lib/use-document-title'; import { countWords, htmlToText } from '../lib/word-count'; import { NotFoundPage } from './NotFoundPage'; import { PageStatusBar } from './PageStatusBar'; @@ -34,6 +35,7 @@ export function PublicPageView(): React.JSX.Element { enabled: Boolean(pondSlug && pageSlug), retry: false, }); + useDocumentTitle(query.data?.title, query.data?.pondName); // 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 diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index 9ebd8df..48adb9f 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -14,6 +14,7 @@ import { ApiTokensSection } from '../api-tokens/ApiTokensSection'; import { FeedTokensSection } from '../api-tokens/FeedTokensSection'; import { WatchesSection } from '../watches/WatchesSection'; +import { useDocumentTitle } from '../lib/use-document-title'; interface SessionView { id: string; createdAt: string; @@ -24,6 +25,7 @@ interface SessionView { export function SettingsPage(): React.JSX.Element { const { t } = useTranslation(); + useDocumentTitle(t('settings:title')); return ( <>

{t('settings:title')}

diff --git a/apps/web/src/pages/TrashPage.tsx b/apps/web/src/pages/TrashPage.tsx index 4e6b636..7b895da 100644 --- a/apps/web/src/pages/TrashPage.tsx +++ b/apps/web/src/pages/TrashPage.tsx @@ -7,6 +7,7 @@ import { useParams } from 'react-router-dom'; import { FormError } from '../components/forms'; import { apiDelete, apiGet, apiPost } from '../lib/api'; +import { useDocumentTitle } from '../lib/use-document-title'; /** Pond trash view (issue #31): list, restore, and permanently delete — * single pages or a checkbox multi-selection at once (#128). */ export function TrashPage(): React.JSX.Element { @@ -21,6 +22,7 @@ export function TrashPage(): React.JSX.Element { queryKey: ['pond', pondSlug], queryFn: () => apiGet(`/ponds/${pondSlug}`), }); + useDocumentTitle(pond.data ? t('trash.title', { pond: pond.data.name }) : undefined); const trash = useQuery({ queryKey: ['trash', pond.data?.id], queryFn: () => apiGet(`/ponds/${pond.data!.id}/trash`), diff --git a/apps/web/src/pages/auth/ForgotPasswordPage.tsx b/apps/web/src/pages/auth/ForgotPasswordPage.tsx index 11e8ce9..cfcffe5 100644 --- a/apps/web/src/pages/auth/ForgotPasswordPage.tsx +++ b/apps/web/src/pages/auth/ForgotPasswordPage.tsx @@ -8,8 +8,10 @@ import { Link } from 'react-router-dom'; import { Field, FormError, FormSuccess } from '../../components/forms'; import { apiPost } from '../../lib/api'; +import { useDocumentTitle } from '../../lib/use-document-title'; export function ForgotPasswordPage(): React.JSX.Element { const { t } = useTranslation(); + useDocumentTitle(t('auth:forgot.title')); const [error, setError] = useState(null); const [sent, setSent] = useState(false); diff --git a/apps/web/src/pages/auth/LoginPage.tsx b/apps/web/src/pages/auth/LoginPage.tsx index 125bae6..08d8596 100644 --- a/apps/web/src/pages/auth/LoginPage.tsx +++ b/apps/web/src/pages/auth/LoginPage.tsx @@ -9,8 +9,10 @@ import { useAuth } from '../../auth/auth-context'; import { Field, FormError } from '../../components/forms'; import { ApiError, apiPost } from '../../lib/api'; +import { useDocumentTitle } from '../../lib/use-document-title'; export function LoginPage(): React.JSX.Element { const { t } = useTranslation(); + useDocumentTitle(t('auth:login.title')); const { refresh } = useAuth(); const navigate = useNavigate(); const [params] = useSearchParams(); diff --git a/apps/web/src/pages/auth/ResetPasswordPage.tsx b/apps/web/src/pages/auth/ResetPasswordPage.tsx index 8355955..bbb6b38 100644 --- a/apps/web/src/pages/auth/ResetPasswordPage.tsx +++ b/apps/web/src/pages/auth/ResetPasswordPage.tsx @@ -8,8 +8,10 @@ import { Link, useSearchParams } from 'react-router-dom'; import { Field, FormError } from '../../components/forms'; import { apiPost } from '../../lib/api'; +import { useDocumentTitle } from '../../lib/use-document-title'; export function ResetPasswordPage(): React.JSX.Element { const { t } = useTranslation(); + useDocumentTitle(t('auth:reset.title')); const [params] = useSearchParams(); const [error, setError] = useState(null); const [done, setDone] = useState(false); diff --git a/apps/web/src/pages/auth/SignupPage.tsx b/apps/web/src/pages/auth/SignupPage.tsx index 7e5f6aa..c9a7601 100644 --- a/apps/web/src/pages/auth/SignupPage.tsx +++ b/apps/web/src/pages/auth/SignupPage.tsx @@ -9,8 +9,10 @@ import { Link } from 'react-router-dom'; import { Field, FormError, applyFieldErrors } from '../../components/forms'; import { apiGet, apiPost } from '../../lib/api'; +import { useDocumentTitle } from '../../lib/use-document-title'; export function SignupPage(): React.JSX.Element { const { t, i18n } = useTranslation(); + useDocumentTitle(t('auth:signup.title')); const [error, setError] = useState(null); const [registered, setRegistered] = useState(null); diff --git a/apps/web/src/pages/auth/VerifyEmailPage.tsx b/apps/web/src/pages/auth/VerifyEmailPage.tsx index d2d2930..d1a54dc 100644 --- a/apps/web/src/pages/auth/VerifyEmailPage.tsx +++ b/apps/web/src/pages/auth/VerifyEmailPage.tsx @@ -4,10 +4,12 @@ import { Link, useSearchParams } from 'react-router-dom'; import { apiPost } from '../../lib/api'; +import { useDocumentTitle } from '../../lib/use-document-title'; type VerifyState = 'pending' | 'success' | 'error'; export function VerifyEmailPage(): React.JSX.Element { const { t } = useTranslation(); + useDocumentTitle(t('auth:verify.title')); const [params] = useSearchParams(); const [state, setState] = useState('pending'); const [email, setEmail] = useState(''); -- 2.45.2