From 544ee36a1e8ea8d209d32f5c8539d2096052f3aa Mon Sep 17 00:00:00 2001 From: Steven RYDELL Date: Wed, 29 Jul 2026 09:43:30 +0200 Subject: [PATCH] Set dynamic document titles per page --- index.html | 4 ++-- src/hooks/useDocumentTitle.ts | 17 +++++++++++++++++ src/i18n/en.json | 3 +++ src/pages/AdminPanel.tsx | 19 +++++++++++++++++++ src/pages/LoginPage.tsx | 3 +++ src/pages/NotFound.tsx | 3 +++ src/pages/OAuthCallback.tsx | 3 +++ 7 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 src/hooks/useDocumentTitle.ts diff --git a/index.html b/index.html index 12bf8c6..98075f0 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@ - Portal + Stalwart WebUI @@ -14,4 +14,4 @@ - \ No newline at end of file + diff --git a/src/hooks/useDocumentTitle.ts b/src/hooks/useDocumentTitle.ts new file mode 100644 index 0000000..e72e04f --- /dev/null +++ b/src/hooks/useDocumentTitle.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +import { useEffect } from 'react'; + +const APP_NAME = 'Stalwart WebUI'; + +// Keeps the tab title in sync with the current page instead of the static +// index.html title; falls back to the bare app name when no page title is given. +export function useDocumentTitle(title?: string | null) { + useEffect(() => { + document.title = title ? `${title} · ${APP_NAME}` : APP_NAME; + }, [title]); +} diff --git a/src/i18n/en.json b/src/i18n/en.json index a29e736..07977b9 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -76,6 +76,7 @@ "preset30d": "Last 30 days", "preset7d": "Last 7 days", "preset90d": "Last 90 days", + "title": "Dashboard", "to": "To" }, "deliveryTrace": { @@ -308,6 +309,7 @@ "continue": "Continue", "error": "An unexpected error occurred", "prompt": "Enter your account name to continue", + "title": "Sign in", "usernamePlaceholder": "user@example.com" }, "logo": { @@ -326,6 +328,7 @@ "missingParams": "Missing authorization code or state parameter", "processing": "Completing sign in...", "stateMismatch": "State parameter mismatch. Please try logging in again.", + "title": "Signing in", "tokenExchangeFailed": "Token exchange failed: {{status}} {{statusText}}" }, "otp": { diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index bb16eb6..dfae5a3 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -19,6 +19,8 @@ import { MainContent } from '@/components/layout/MainContent'; import { ScrollArea } from '@/components/ui/scroll-area'; import { ErrorBoundary } from '@/components/layout/ErrorBoundary'; import { LoadingFallback } from '@/components/common/LoadingFallback'; +import { useDocumentTitle } from '@/hooks/useDocumentTitle'; +import { friendlyName } from '@/hooks/useGlobalSearch'; import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, @@ -85,6 +87,23 @@ export default function AdminPanel() { const [initError, setInitError] = useState(null); const [initializing, setInitializing] = useState(!isSchemaLoaded); + const searchIndex = useSchemaStore((s) => s.searchIndex); + + // Tab title mirrors the sidebar label (search index) so it always matches + // what the user sees in the navigation, e.g. "MTA-STS · Settings". + const pageTitle = useMemo(() => { + if (!section) return t('dashboard.title', 'Dashboard'); + if (!viewName) return section; + const entry = + searchIndex.find((e) => e.type === 'link' && e.viewName === viewName && e.section === section) ?? + searchIndex.find((e) => e.type === 'link' && e.viewName === viewName); + const base = entry?.text ?? friendlyName(viewName); + if (id === 'new') return `${t('form.createTitle', 'Create {{name}}', { name: base })} · ${section}`; + return `${base} · ${section}`; + }, [section, viewName, id, searchIndex, t]); + + useDocumentTitle(pageTitle); + useEffect(() => { const bypassToken = import.meta.env.VITE_ACCESS_TOKEN; if (bypassToken && !accessToken) { diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index aa6cb1d..ae97e41 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -11,6 +11,7 @@ import { ArrowRight, Loader2 } from 'lucide-react'; import Logo from '@/components/common/Logo'; import { ThemeSwitcher } from '@/components/common/ThemeSwitcher'; +import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; @@ -24,6 +25,8 @@ export default function LoginPage() { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + useDocumentTitle(t('login.title', 'Sign in')); + async function handleSubmit(e: FormEvent) { e.preventDefault(); const trimmed = username.trim(); diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx index e493393..098160c 100644 --- a/src/pages/NotFound.tsx +++ b/src/pages/NotFound.tsx @@ -6,10 +6,13 @@ import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; +import { useDocumentTitle } from '@/hooks/useDocumentTitle'; export default function NotFound() { const { t } = useTranslation(); + useDocumentTitle(t('errors.notFound', 'The requested resource was not found.')); + return (
diff --git a/src/pages/OAuthCallback.tsx b/src/pages/OAuthCallback.tsx index c7cf3a6..028b5b0 100644 --- a/src/pages/OAuthCallback.tsx +++ b/src/pages/OAuthCallback.tsx @@ -11,6 +11,7 @@ import { Loader2 } from 'lucide-react'; import { useAuthStore } from '@/stores/authStore'; import { ThemeSwitcher } from '@/components/common/ThemeSwitcher'; +import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { getBasePath } from '@/lib/basePath'; import { exchangeCode, getStoredOAuthData, clearStoredOAuthData, getOAuthRedirectUri } from '@/services/auth/oauth'; @@ -20,6 +21,8 @@ export default function OAuthCallback() { const [searchParams] = useSearchParams(); const [error, setError] = useState(null); + useDocumentTitle(t('oauth.title', 'Signing in')); + useEffect(() => { async function handleCallback() { try {