#163: Dokumentsprache und Seitentitel der SPA
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 4m43s
CI / Build container images (pull_request) Successful in 1m11s
CI / Auth e2e pack (pull_request) Successful in 7m14s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled

i18n spiegelt die aktive Sprache auf <html lang> (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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
This commit is contained in:
Claude Fable 5 2026-07-21 13:52:59 +02:00
parent 1eca7c334c
commit 418aafd5ec
21 changed files with 69 additions and 0 deletions

View File

@ -9,6 +9,7 @@ import { FormError } from '../components/forms';
import { usePondLabels } from '../labels/use-pond-labels'; import { usePondLabels } from '../labels/use-pond-labels';
import { apiGet, apiPost } from '../lib/api'; import { apiGet, apiPost } from '../lib/api';
import { usePersistentState } from '../lib/use-persistent-state'; import { usePersistentState } from '../lib/use-persistent-state';
import { useDocumentTitle } from '../lib/use-document-title';
import { import {
ForceGraph, ForceGraph,
GRAPH_SETTINGS_DEFAULTS, GRAPH_SETTINGS_DEFAULTS,
@ -183,6 +184,7 @@ export function PondGraphPage(): React.JSX.Element {
queryKey: ['pond', pondSlug], queryKey: ['pond', pondSlug],
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`), queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
}); });
useDocumentTitle(t('title'), pond.data?.name);
const graph = useQuery({ const graph = useQuery({
queryKey: ['pond-links', pond.data?.id], queryKey: ['pond-links', pond.data?.id],
queryFn: () => apiGet<PondGraphView>(`/ponds/${pond.data!.id}/links`), queryFn: () => apiGet<PondGraphView>(`/ponds/${pond.data!.id}/links`),

View File

@ -133,4 +133,13 @@ void i18n
detection: { order: ['querystring', 'navigator'], lookupQuerystring: 'lng', caches: [] }, detection: { order: ['querystring', 'navigator'], lookupQuerystring: 'lng', caches: [] },
}); });
// Mirror the active language onto <html lang> (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; export default i18n;

View File

@ -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;
},
[],
);
}

View File

@ -12,6 +12,7 @@ import { PluginManager } from './PluginManager';
import { QuotaManager } from './QuotaManager'; import { QuotaManager } from './QuotaManager';
import { UserManager } from './UserManager'; import { UserManager } from './UserManager';
import { useDocumentTitle } from '../lib/use-document-title';
interface InstanceSettings { interface InstanceSettings {
'auth.registrationMode': 'open' | 'closed'; 'auth.registrationMode': 'open' | 'closed';
'instance.name': string; 'instance.name': string;
@ -32,6 +33,7 @@ interface InstanceSettings {
export function AdminSettingsPage(): React.JSX.Element { export function AdminSettingsPage(): React.JSX.Element {
const { t } = useTranslation(); const { t } = useTranslation();
useDocumentTitle(t('settings:admin.title'));
const { t: tQuotas } = useTranslation('quotas'); const { t: tQuotas } = useTranslation('quotas');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [error, setError] = useState<unknown>(null); const [error, setError] = useState<unknown>(null);

View File

@ -16,6 +16,7 @@ import { formatBytes } from '../files/file-format';
import { apiGet, apiPost } from '../lib/api'; import { apiGet, apiPost } from '../lib/api';
import { BackupSection } from './AdminBackupSection'; import { BackupSection } from './AdminBackupSection';
import { useDocumentTitle } from '../lib/use-document-title';
/** /**
* Site-Admin "System" panel (issue #86): maintenance jobs with a manual * 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 * 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 { export function AdminSystemPage(): React.JSX.Element {
const { t } = useTranslation('system'); const { t } = useTranslation('system');
useDocumentTitle(t('title'));
return ( return (
<> <>
<h1>{t('title')}</h1> <h1>{t('title')}</h1>

View File

@ -1,6 +1,7 @@
import { FONT_CATALOG, fontStack } from '@dorfteich/shared'; import { FONT_CATALOG, fontStack } from '@dorfteich/shared';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useDocumentTitle } from '../lib/use-document-title';
/** /**
* Font catalog attribution page (issue #66, ADR 0016): lists every self-hosted * 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 * 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 { export function FontCatalogPage(): React.JSX.Element {
const { t } = useTranslation('font'); const { t } = useTranslation('font');
useDocumentTitle(t('catalog.title'));
return ( return (
<div className="font-catalog"> <div className="font-catalog">
<h1>{t('catalog.title')}</h1> <h1>{t('catalog.title')}</h1>

View File

@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { apiGet, fetchHealth } from '../lib/api'; import { apiGet, fetchHealth } from '../lib/api';
import { useDocumentTitle } from '../lib/use-document-title';
/** /**
* Public landing page (`/`). When a Site Admin has written landing content * Public landing page (`/`). When a Site Admin has written landing content
* (Admin Settings Landing page), it renders that server-sanitized * (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 { export function HomePage(): React.JSX.Element {
const { t } = useTranslation(); const { t } = useTranslation();
useDocumentTitle(t('home.title'));
const home = useQuery({ const home = useQuery({
queryKey: ['home-content'], queryKey: ['home-content'],
queryFn: () => apiGet<HomeContentView>('/home/content'), queryFn: () => apiGet<HomeContentView>('/home/content'),

View File

@ -7,6 +7,7 @@ import { useAuth } from '../auth/auth-context';
import { apiGet } from '../lib/api'; import { apiGet } from '../lib/api';
import { NotFoundPage } from './NotFoundPage'; import { NotFoundPage } from './NotFoundPage';
import { useDocumentTitle } from '../lib/use-document-title';
/** /**
* Public legal page (issue #82): imprint or privacy policy, reachable * Public legal page (issue #82): imprint or privacy policy, reachable
* without a session. The HTML comes from the api's sanitizing Markdown * 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 { t } = useTranslation('legal');
const { user } = useAuth(); const { user } = useAuth();
const { kind = '' } = useParams<{ kind: string }>(); const { kind = '' } = useParams<{ kind: string }>();
useDocumentTitle(t(`title.${kind}`));
const query = useQuery({ const query = useQuery({
queryKey: ['legal', kind], queryKey: ['legal', kind],

View File

@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { apiGet } from '../lib/api'; import { apiGet } from '../lib/api';
import { useDocumentTitle } from '../lib/use-document-title';
const POLL_INTERVAL_MS = 3000; const POLL_INTERVAL_MS = 3000;
/** /**
@ -15,6 +16,7 @@ const POLL_INTERVAL_MS = 3000;
*/ */
export function MaintenancePage(): React.JSX.Element { export function MaintenancePage(): React.JSX.Element {
const { t } = useTranslation('system'); const { t } = useTranslation('system');
useDocumentTitle(t('maintenance.title'));
const [status, setStatus] = useState<RestoreStatusResponse | null>(null); const [status, setStatus] = useState<RestoreStatusResponse | null>(null);
useEffect(() => { useEffect(() => {

View File

@ -1,8 +1,10 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useDocumentTitle } from '../lib/use-document-title';
export function NotFoundPage(): React.JSX.Element { export function NotFoundPage(): React.JSX.Element {
const { t } = useTranslation(); const { t } = useTranslation();
useDocumentTitle(t('notFound.title'));
return ( return (
<> <>
<h1>{t('notFound.title')}</h1> <h1>{t('notFound.title')}</h1>

View File

@ -41,6 +41,7 @@ import { recallPage, rememberPage } from '../offline/page-cache';
import { PluginBlockContext } from '../editor/plugin-block-context'; import { PluginBlockContext } from '../editor/plugin-block-context';
import { hasPageTools, PageToolsPanel } from '../plugins/PageToolsPanel'; import { hasPageTools, PageToolsPanel } from '../plugins/PageToolsPanel';
import { SectionStyleSheets } from '../plugins/SectionStyleSheets'; import { SectionStyleSheets } from '../plugins/SectionStyleSheets';
import { useDocumentTitle } from '../lib/use-document-title';
import { import {
pluginBlockOptions, pluginBlockOptions,
sectionStyleOptions, sectionStyleOptions,
@ -373,6 +374,7 @@ export function PageEditorPage(): React.JSX.Element {
queryFn: () => apiGet<PageStateView>(`/ponds/${pond.data!.id}/pages/${pageSlug}`), queryFn: () => apiGet<PageStateView>(`/ponds/${pond.data!.id}/pages/${pageSlug}`),
enabled: Boolean(pond.data), enabled: Boolean(pond.data),
}); });
useDocumentTitle(page.data?.title, pond.data?.name);
// Word count for the read-mode status line (#134). Derived from the same // 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 // Markdown export the history panel uses (shared query key), fetched only in

View File

@ -7,6 +7,7 @@ import { useNavigate, useParams } from 'react-router-dom';
import { FormError } from '../components/forms'; import { FormError } from '../components/forms';
import { apiGet } from '../lib/api'; 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. * 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 * 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], queryKey: ['pond', pondSlug],
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`), queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
}); });
useDocumentTitle(pond.data?.name);
const pages = useQuery({ const pages = useQuery({
queryKey: ['pages', pond.data?.id, pond.data?.settings.sidebarSort], queryKey: ['pages', pond.data?.id, pond.data?.settings.sidebarSort],
queryFn: () => apiGet<PageView[]>(`/ponds/${pond.data!.id}/pages`), queryFn: () => apiGet<PageView[]>(`/ponds/${pond.data!.id}/pages`),

View File

@ -22,6 +22,7 @@ import { MemberManager } from '../members/MemberManager';
import { DeletePondSection } from '../ponds/DeletePondSection'; import { DeletePondSection } from '../ponds/DeletePondSection';
import { PondPluginSettings } from '../plugins/PondPluginSettings'; import { PondPluginSettings } from '../plugins/PondPluginSettings';
import { useDocumentTitle } from '../lib/use-document-title';
/** /**
* Pond settings (issues #44/#54). Hosts the 'Members' and 'Labels' sections; * Pond settings (issues #44/#54). Hosts the 'Members' and 'Labels' sections;
* future pond-level configuration (fonts, etc.) joins it here. Member * future pond-level configuration (fonts, etc.) joins it here. Member
@ -49,6 +50,7 @@ export function PondSettingsPage(): React.JSX.Element {
queryKey: ['pond', pondSlug], queryKey: ['pond', pondSlug],
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`), queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
}); });
useDocumentTitle(tCommon('settings:title'), pond.data?.name);
if (pond.error) return <FormError error={pond.error} />; if (pond.error) return <FormError error={pond.error} />;
if (!pond.data) return <></>; if (!pond.data) return <></>;

View File

@ -5,6 +5,7 @@ import { useParams } from 'react-router-dom';
import { PublicComments } from '../comments/CommentsSection'; import { PublicComments } from '../comments/CommentsSection';
import { ApiError, apiGet } from '../lib/api'; import { ApiError, apiGet } from '../lib/api';
import { useDocumentTitle } from '../lib/use-document-title';
import { countWords, htmlToText } from '../lib/word-count'; import { countWords, htmlToText } from '../lib/word-count';
import { NotFoundPage } from './NotFoundPage'; import { NotFoundPage } from './NotFoundPage';
import { PageStatusBar } from './PageStatusBar'; import { PageStatusBar } from './PageStatusBar';
@ -34,6 +35,7 @@ export function PublicPageView(): React.JSX.Element {
enabled: Boolean(pondSlug && pageSlug), enabled: Boolean(pondSlug && pageSlug),
retry: false, retry: false,
}); });
useDocumentTitle(query.data?.title, query.data?.pondName);
// Word count for the status line (#134), derived from the server-rendered // 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 // HTML — no editor bundle needed. Kept before the early returns so the hook

View File

@ -14,6 +14,7 @@ import { ApiTokensSection } from '../api-tokens/ApiTokensSection';
import { FeedTokensSection } from '../api-tokens/FeedTokensSection'; import { FeedTokensSection } from '../api-tokens/FeedTokensSection';
import { WatchesSection } from '../watches/WatchesSection'; import { WatchesSection } from '../watches/WatchesSection';
import { useDocumentTitle } from '../lib/use-document-title';
interface SessionView { interface SessionView {
id: string; id: string;
createdAt: string; createdAt: string;
@ -24,6 +25,7 @@ interface SessionView {
export function SettingsPage(): React.JSX.Element { export function SettingsPage(): React.JSX.Element {
const { t } = useTranslation(); const { t } = useTranslation();
useDocumentTitle(t('settings:title'));
return ( return (
<> <>
<h1>{t('settings:title')}</h1> <h1>{t('settings:title')}</h1>

View File

@ -7,6 +7,7 @@ import { useParams } from 'react-router-dom';
import { FormError } from '../components/forms'; import { FormError } from '../components/forms';
import { apiDelete, apiGet, apiPost } from '../lib/api'; import { apiDelete, apiGet, apiPost } from '../lib/api';
import { useDocumentTitle } from '../lib/use-document-title';
/** Pond trash view (issue #31): list, restore, and permanently delete /** Pond trash view (issue #31): list, restore, and permanently delete
* single pages or a checkbox multi-selection at once (#128). */ * single pages or a checkbox multi-selection at once (#128). */
export function TrashPage(): React.JSX.Element { export function TrashPage(): React.JSX.Element {
@ -21,6 +22,7 @@ export function TrashPage(): React.JSX.Element {
queryKey: ['pond', pondSlug], queryKey: ['pond', pondSlug],
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`), queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
}); });
useDocumentTitle(pond.data ? t('trash.title', { pond: pond.data.name }) : undefined);
const trash = useQuery({ const trash = useQuery({
queryKey: ['trash', pond.data?.id], queryKey: ['trash', pond.data?.id],
queryFn: () => apiGet<PageView[]>(`/ponds/${pond.data!.id}/trash`), queryFn: () => apiGet<PageView[]>(`/ponds/${pond.data!.id}/trash`),

View File

@ -8,8 +8,10 @@ import { Link } from 'react-router-dom';
import { Field, FormError, FormSuccess } from '../../components/forms'; import { Field, FormError, FormSuccess } from '../../components/forms';
import { apiPost } from '../../lib/api'; import { apiPost } from '../../lib/api';
import { useDocumentTitle } from '../../lib/use-document-title';
export function ForgotPasswordPage(): React.JSX.Element { export function ForgotPasswordPage(): React.JSX.Element {
const { t } = useTranslation(); const { t } = useTranslation();
useDocumentTitle(t('auth:forgot.title'));
const [error, setError] = useState<unknown>(null); const [error, setError] = useState<unknown>(null);
const [sent, setSent] = useState(false); const [sent, setSent] = useState(false);

View File

@ -9,8 +9,10 @@ import { useAuth } from '../../auth/auth-context';
import { Field, FormError } from '../../components/forms'; import { Field, FormError } from '../../components/forms';
import { ApiError, apiPost } from '../../lib/api'; import { ApiError, apiPost } from '../../lib/api';
import { useDocumentTitle } from '../../lib/use-document-title';
export function LoginPage(): React.JSX.Element { export function LoginPage(): React.JSX.Element {
const { t } = useTranslation(); const { t } = useTranslation();
useDocumentTitle(t('auth:login.title'));
const { refresh } = useAuth(); const { refresh } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [params] = useSearchParams(); const [params] = useSearchParams();

View File

@ -8,8 +8,10 @@ import { Link, useSearchParams } from 'react-router-dom';
import { Field, FormError } from '../../components/forms'; import { Field, FormError } from '../../components/forms';
import { apiPost } from '../../lib/api'; import { apiPost } from '../../lib/api';
import { useDocumentTitle } from '../../lib/use-document-title';
export function ResetPasswordPage(): React.JSX.Element { export function ResetPasswordPage(): React.JSX.Element {
const { t } = useTranslation(); const { t } = useTranslation();
useDocumentTitle(t('auth:reset.title'));
const [params] = useSearchParams(); const [params] = useSearchParams();
const [error, setError] = useState<unknown>(null); const [error, setError] = useState<unknown>(null);
const [done, setDone] = useState(false); const [done, setDone] = useState(false);

View File

@ -9,8 +9,10 @@ import { Link } from 'react-router-dom';
import { Field, FormError, applyFieldErrors } from '../../components/forms'; import { Field, FormError, applyFieldErrors } from '../../components/forms';
import { apiGet, apiPost } from '../../lib/api'; import { apiGet, apiPost } from '../../lib/api';
import { useDocumentTitle } from '../../lib/use-document-title';
export function SignupPage(): React.JSX.Element { export function SignupPage(): React.JSX.Element {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
useDocumentTitle(t('auth:signup.title'));
const [error, setError] = useState<unknown>(null); const [error, setError] = useState<unknown>(null);
const [registered, setRegistered] = useState<string | null>(null); const [registered, setRegistered] = useState<string | null>(null);

View File

@ -4,10 +4,12 @@ import { Link, useSearchParams } from 'react-router-dom';
import { apiPost } from '../../lib/api'; import { apiPost } from '../../lib/api';
import { useDocumentTitle } from '../../lib/use-document-title';
type VerifyState = 'pending' | 'success' | 'error'; type VerifyState = 'pending' | 'success' | 'error';
export function VerifyEmailPage(): React.JSX.Element { export function VerifyEmailPage(): React.JSX.Element {
const { t } = useTranslation(); const { t } = useTranslation();
useDocumentTitle(t('auth:verify.title'));
const [params] = useSearchParams(); const [params] = useSearchParams();
const [state, setState] = useState<VerifyState>('pending'); const [state, setState] = useState<VerifyState>('pending');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');