From 418aafd5ecca6fbd2d83ed3300c7bf957ad41c62 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Tue, 21 Jul 2026 13:52:59 +0200 Subject: [PATCH] #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('');