v3: fixed inconsistent page titles
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s

This commit is contained in:
khannurien
2026-06-28 06:51:42 +00:00
parent 551e29c564
commit c8c7b05c25
20 changed files with 598 additions and 470 deletions

View File

@@ -0,0 +1,31 @@
import { useEffect } from "react";
// The server injects the configured site name into both <title> and this meta
// tag (see api/lib/static.ts / api/middleware/og.ts). Read it once at startup.
// In the Vite dev server the placeholder isn't substituted, so fall back.
function readSiteName(): string {
const meta = document.querySelector('meta[name="site-name"]')
?.getAttribute("content")?.trim();
if (meta && meta !== "__SITE_NAME__") return meta;
return "gerbeur";
}
export const SITE_NAME = readSiteName();
/**
* Keeps document.title in sync with the current page during client-side
* navigation. Without this the tab title only ever reflects the server-rendered
* <title> from the initial page load and goes stale on every SPA navigation.
*
* - Pass a non-empty string to show `<title> · <site name>`.
* - Pass `null` to show just the site name (home / generic pages).
* - Pass `undefined` while the page-specific title is still loading: the title
* is left untouched, so a server-rendered OG title survives until data
* arrives (avoids a flash to the bare site name on direct loads).
*/
export function useDocumentTitle(title: string | null | undefined): void {
useEffect(() => {
if (title === undefined) return;
document.title = title ? `${title} · ${SITE_NAME}` : SITE_NAME;
}, [title]);
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,7 @@ import {
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { relativeTime } from "../utils/relativeTime.ts";
import { useWS } from "../hooks/useWS.ts";
import { Avatar } from "../components/Avatar.tsx";
@@ -55,6 +56,14 @@ export function Dump() {
const [op, setOp] = useState<PublicUser | null>(null);
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
useDocumentTitle(
dumpState.status === "loaded"
? dumpState.dump.title
: dumpState.status === "error"
? null
: undefined,
);
const [comments, setComments] = useState<Comment[]>([]);
const [relatedDumps, setRelatedDumps] = useState<Dump[]>([]);

View File

@@ -7,6 +7,7 @@ import { API_URL, VALIDATION } from "../config/api.ts";
import type { Dump, RawDump, UpdateDumpRequest } from "../model.ts";
import { deserializeDump, parseAPIResponse } from "../model.ts";
import { useRequiredAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { formatBytes } from "../utils/format.ts";
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
import { PageShell } from "../components/PageShell.tsx";
@@ -41,6 +42,15 @@ export function DumpEdit() {
const { authFetch, token } = useRequiredAuth();
const [state, setState] = useState<DumpEditState>({ status: "loading" });
useDocumentTitle(
state.status === "loaded"
? t`Editing ${state.dump.title}`
: state.status === "error"
? null
: undefined,
);
const [confirmDelete, setConfirmDelete] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [thumbUploading, setThumbUploading] = useState(false);

View File

@@ -28,6 +28,7 @@ import {
import { friendlyFetchError } from "../utils/apiError.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { useScrollSave } from "../hooks/useScrollSave.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
@@ -55,6 +56,8 @@ export function Index() {
const justDeletedId = (location.state as { deletedDumpId?: string } | null)
?.deletedDumpId;
useDocumentTitle(null);
const { user, token } = useAuth();
const {
voteCounts,

View File

@@ -1,8 +1,11 @@
import { Link } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { PageShell } from "../components/PageShell.tsx";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
export function NotFound() {
useDocumentTitle(t`Page not found`);
return (
<PageShell centered>
<h1>404</h1>

View File

@@ -6,6 +6,7 @@ import { relativeTime } from "../utils/relativeTime.ts";
import { API_URL, NOTIFICATIONS_PAGE_SIZE } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { Tooltip } from "../components/Tooltip.tsx";
import { useWS } from "../hooks/useWS.ts";
@@ -256,6 +257,7 @@ function groupByDate(
}
export function Notifications() {
useDocumentTitle(t`Notifications`);
const { authFetch } = useAuth();
const { clearUnreadNotifications, lastNotification } = useWS();
const [state, setState] = useState<State>({ status: "loading" });

View File

@@ -27,6 +27,7 @@ import {
import { playlistUrl } from "../utils/urls.ts";
import { can } from "../utils/permissions.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useWS } from "../hooks/useWS.ts";
import { relativeTime } from "../utils/relativeTime.ts";
import { DumpCard } from "../components/DumpCard.tsx";
@@ -71,6 +72,14 @@ export function PlaylistDetail() {
// Stable UUID for WS comparisons — avoids re-running effects on every state change
const playlistUUID = state.status === "loaded" ? state.playlist.id : null;
useDocumentTitle(
state.status === "loaded"
? state.playlist.title
: state.status === "error"
? null
: undefined,
);
// activeDumpIds: which dumps are currently in the playlist (the canonical set)
const [activeDumpIds, setActiveDumpIds] = useState<Set<string>>(new Set());

View File

@@ -4,6 +4,7 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../config/api.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { PageShell } from "../components/PageShell.tsx";
import {
expectOk,
@@ -20,6 +21,7 @@ interface Values {
}
export function ResetPassword() {
useDocumentTitle(t`Reset password`);
const [params] = useSearchParams();
const navigate = useNavigate();
const token = params.get("token") ?? "";

View File

@@ -8,6 +8,7 @@ import { DumpCard } from "../components/DumpCard.tsx";
import { PlaylistCard } from "../components/PlaylistCard.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useWS } from "../hooks/useWS.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
import { useTabParam } from "../hooks/useTabParam.ts";
@@ -46,6 +47,7 @@ type SearchState =
};
export function Search() {
useDocumentTitle(t`Search`);
const [searchParams] = useSearchParams();
const q = searchParams.get("q") ?? "";
const [tab, setTab] = useTabParam<Tab>(SEARCH_TABS, "dumps");

View File

@@ -4,6 +4,7 @@ import { Trans } from "@lingui/react/macro";
import { Link, useParams } from "react-router";
import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
@@ -19,6 +20,8 @@ export function UserDumps() {
const { user: me } = useAuth();
const { voteCounts, myVotes, lastDumpEvent, castVote, removeVote } = useWS();
useDocumentTitle(username ?? null);
const { state, setItems, sentinelRef } = useUserDumpFeed(
username,
"dumps",

View File

@@ -10,6 +10,7 @@ import {
type RawAuthResponse,
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { PageShell } from "../components/PageShell.tsx";
import {
expectOk,
@@ -30,6 +31,7 @@ interface ResetValues {
}
export function UserLogin() {
useDocumentTitle(t`Log in`);
const navigate = useNavigate();
const { login } = useAuth();
const [showReset, setShowReset] = useState(false);

View File

@@ -23,6 +23,7 @@ import {
hydratePlaylist,
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useWS } from "../hooks/useWS.ts";
import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts";
import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
@@ -60,6 +61,8 @@ function initialFeed(items: Playlist[], hasMore: boolean): PlaylistFeed {
export function UserPlaylists() {
const { username } = useParams();
const { user: me, authFetch, token } = useAuth();
useDocumentTitle(username ?? null);
const { lastPlaylistEvent } = useWS();
const { cached: cachedCreated, saveState: saveCreated } = useFeedCache<

View File

@@ -38,6 +38,7 @@ import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useTheme } from "../hooks/useTheme.ts";
import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts";
import { useWS } from "../hooks/useWS.ts";
@@ -211,6 +212,14 @@ export function UserPublicProfile() {
const [state, setState] = useState<ProfileState>({ status: "loading" });
useDocumentTitle(
state.status === "loaded"
? state.user.username
: state.status === "error"
? null
: undefined,
);
const profileUserId = state.status === "loaded" ? state.user.id : null;
const isOwnProfile = me?.id === profileUserId;

View File

@@ -10,6 +10,7 @@ import {
type RegisterRequest,
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { PageShell } from "../components/PageShell.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
import {
@@ -33,6 +34,7 @@ interface Values {
}
export function UserRegister() {
useDocumentTitle(t`Register`);
const navigate = useNavigate();
const { login } = useAuth();
const [searchParams] = useSearchParams();

View File

@@ -7,6 +7,7 @@ import { API_URL } from "../config/api.ts";
import type { Dump } from "../model.ts";
import { deserializeDump } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { useFading } from "../hooks/useFading.ts";
@@ -21,6 +22,8 @@ export function UserUpvoted() {
const { user: me } = useAuth();
const { voteCounts, myVotes, lastVoteEvent, castVote, removeVote } = useWS();
useDocumentTitle(username ?? null);
const [votedIds, setVotedIds] = useState<Set<string>>(new Set());
const { fading, startFading, cancelFading, cancelAll } = useFading();
const prevMyVotesRef = useRef<Set<string> | null>(null);