Set dynamic document titles per page

This commit is contained in:
Steven RYDELL
2026-07-29 09:43:30 +02:00
parent 5152b07a9a
commit 544ee36a1e
7 changed files with 50 additions and 2 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
<base href="/" /> <base href="/" />
<link rel="icon" type="image/x-icon" href="favicon.ico" /> <link rel="icon" type="image/x-icon" href="favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Portal</title> <title>Stalwart WebUI</title>
</head> </head>
<body> <body>
+17
View File
@@ -0,0 +1,17 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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]);
}
+3
View File
@@ -76,6 +76,7 @@
"preset30d": "Last 30 days", "preset30d": "Last 30 days",
"preset7d": "Last 7 days", "preset7d": "Last 7 days",
"preset90d": "Last 90 days", "preset90d": "Last 90 days",
"title": "Dashboard",
"to": "To" "to": "To"
}, },
"deliveryTrace": { "deliveryTrace": {
@@ -308,6 +309,7 @@
"continue": "Continue", "continue": "Continue",
"error": "An unexpected error occurred", "error": "An unexpected error occurred",
"prompt": "Enter your account name to continue", "prompt": "Enter your account name to continue",
"title": "Sign in",
"usernamePlaceholder": "user@example.com" "usernamePlaceholder": "user@example.com"
}, },
"logo": { "logo": {
@@ -326,6 +328,7 @@
"missingParams": "Missing authorization code or state parameter", "missingParams": "Missing authorization code or state parameter",
"processing": "Completing sign in...", "processing": "Completing sign in...",
"stateMismatch": "State parameter mismatch. Please try logging in again.", "stateMismatch": "State parameter mismatch. Please try logging in again.",
"title": "Signing in",
"tokenExchangeFailed": "Token exchange failed: {{status}} {{statusText}}" "tokenExchangeFailed": "Token exchange failed: {{status}} {{statusText}}"
}, },
"otp": { "otp": {
+19
View File
@@ -19,6 +19,8 @@ import { MainContent } from '@/components/layout/MainContent';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { ErrorBoundary } from '@/components/layout/ErrorBoundary'; import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
import { LoadingFallback } from '@/components/common/LoadingFallback'; import { LoadingFallback } from '@/components/common/LoadingFallback';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { friendlyName } from '@/hooks/useGlobalSearch';
import { import {
findFirstAccessibleLinkInLayout, findFirstAccessibleLinkInLayout,
findFirstVisibleLinkInLayout, findFirstVisibleLinkInLayout,
@@ -85,6 +87,23 @@ export default function AdminPanel() {
const [initError, setInitError] = useState<string | null>(null); const [initError, setInitError] = useState<string | null>(null);
const [initializing, setInitializing] = useState(!isSchemaLoaded); 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(() => { useEffect(() => {
const bypassToken = import.meta.env.VITE_ACCESS_TOKEN; const bypassToken = import.meta.env.VITE_ACCESS_TOKEN;
if (bypassToken && !accessToken) { if (bypassToken && !accessToken) {
+3
View File
@@ -11,6 +11,7 @@ import { ArrowRight, Loader2 } from 'lucide-react';
import Logo from '@/components/common/Logo'; import Logo from '@/components/common/Logo';
import { ThemeSwitcher } from '@/components/common/ThemeSwitcher'; import { ThemeSwitcher } from '@/components/common/ThemeSwitcher';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Card, CardContent, CardHeader } from '@/components/ui/card';
@@ -24,6 +25,8 @@ export default function LoginPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
useDocumentTitle(t('login.title', 'Sign in'));
async function handleSubmit(e: FormEvent) { async function handleSubmit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
const trimmed = username.trim(); const trimmed = username.trim();
+3
View File
@@ -6,10 +6,13 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
export default function NotFound() { export default function NotFound() {
const { t } = useTranslation(); const { t } = useTranslation();
useDocumentTitle(t('errors.notFound', 'The requested resource was not found.'));
return ( return (
<div className="flex min-h-screen items-center justify-center"> <div className="flex min-h-screen items-center justify-center">
<div className="text-center"> <div className="text-center">
+3
View File
@@ -11,6 +11,7 @@ import { Loader2 } from 'lucide-react';
import { useAuthStore } from '@/stores/authStore'; import { useAuthStore } from '@/stores/authStore';
import { ThemeSwitcher } from '@/components/common/ThemeSwitcher'; import { ThemeSwitcher } from '@/components/common/ThemeSwitcher';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { getBasePath } from '@/lib/basePath'; import { getBasePath } from '@/lib/basePath';
import { exchangeCode, getStoredOAuthData, clearStoredOAuthData, getOAuthRedirectUri } from '@/services/auth/oauth'; import { exchangeCode, getStoredOAuthData, clearStoredOAuthData, getOAuthRedirectUri } from '@/services/auth/oauth';
@@ -20,6 +21,8 @@ export default function OAuthCallback() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useDocumentTitle(t('oauth.title', 'Signing in'));
useEffect(() => { useEffect(() => {
async function handleCallback() { async function handleCallback() {
try { try {