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

1
deno.lock generated
View File

@@ -35,6 +35,7 @@
"npm:@vitejs/plugin-react-swc@^4.3.1": "4.3.1_vite@8.1.0__@types+node@26.0.1__jiti@2.7.0_@types+node@26.0.1_jiti@2.7.0", "npm:@vitejs/plugin-react-swc@^4.3.1": "4.3.1_vite@8.1.0__@types+node@26.0.1__jiti@2.7.0_@types+node@26.0.1_jiti@2.7.0",
"npm:eslint-plugin-react-hooks@^7.1.1": "7.1.1_eslint@10.6.0__jiti@2.7.0_jiti@2.7.0", "npm:eslint-plugin-react-hooks@^7.1.1": "7.1.1_eslint@10.6.0__jiti@2.7.0_jiti@2.7.0",
"npm:eslint-plugin-react-refresh@~0.5.3": "0.5.3_eslint@10.6.0__jiti@2.7.0_jiti@2.7.0", "npm:eslint-plugin-react-refresh@~0.5.3": "0.5.3_eslint@10.6.0__jiti@2.7.0_jiti@2.7.0",
"npm:eslint@*": "10.6.0_jiti@2.7.0",
"npm:eslint@^10.6.0": "10.6.0_jiti@2.7.0", "npm:eslint@^10.6.0": "10.6.0_jiti@2.7.0",
"npm:frimousse@0.3": "0.3.0_react@19.2.7_typescript@6.0.3", "npm:frimousse@0.3": "0.3.0_react@19.2.7_typescript@6.0.3",
"npm:globals@^17.7.0": "17.7.0", "npm:globals@^17.7.0": "17.7.0",

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"; } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { relativeTime } from "../utils/relativeTime.ts"; import { relativeTime } from "../utils/relativeTime.ts";
import { useWS } from "../hooks/useWS.ts"; import { useWS } from "../hooks/useWS.ts";
import { Avatar } from "../components/Avatar.tsx"; import { Avatar } from "../components/Avatar.tsx";
@@ -55,6 +56,14 @@ export function Dump() {
const [op, setOp] = useState<PublicUser | null>(null); const [op, setOp] = useState<PublicUser | null>(null);
const [playlistModalOpen, setPlaylistModalOpen] = useState(false); const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
useDocumentTitle(
dumpState.status === "loaded"
? dumpState.dump.title
: dumpState.status === "error"
? null
: undefined,
);
const [comments, setComments] = useState<Comment[]>([]); const [comments, setComments] = useState<Comment[]>([]);
const [relatedDumps, setRelatedDumps] = useState<Dump[]>([]); 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 type { Dump, RawDump, UpdateDumpRequest } from "../model.ts";
import { deserializeDump, parseAPIResponse } from "../model.ts"; import { deserializeDump, parseAPIResponse } from "../model.ts";
import { useRequiredAuth } from "../hooks/useAuth.ts"; import { useRequiredAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { formatBytes } from "../utils/format.ts"; import { formatBytes } from "../utils/format.ts";
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts"; import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
import { PageShell } from "../components/PageShell.tsx"; import { PageShell } from "../components/PageShell.tsx";
@@ -41,6 +42,15 @@ export function DumpEdit() {
const { authFetch, token } = useRequiredAuth(); const { authFetch, token } = useRequiredAuth();
const [state, setState] = useState<DumpEditState>({ status: "loading" }); 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 [confirmDelete, setConfirmDelete] = useState(false);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [thumbUploading, setThumbUploading] = useState(false); const [thumbUploading, setThumbUploading] = useState(false);

View File

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

View File

@@ -1,8 +1,11 @@
import { Link } from "react-router"; import { Link } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { PageShell } from "../components/PageShell.tsx"; import { PageShell } from "../components/PageShell.tsx";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
export function NotFound() { export function NotFound() {
useDocumentTitle(t`Page not found`);
return ( return (
<PageShell centered> <PageShell centered>
<h1>404</h1> <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 { API_URL, NOTIFICATIONS_PAGE_SIZE } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { ErrorCard } from "../components/ErrorCard.tsx"; import { ErrorCard } from "../components/ErrorCard.tsx";
import { Tooltip } from "../components/Tooltip.tsx"; import { Tooltip } from "../components/Tooltip.tsx";
import { useWS } from "../hooks/useWS.ts"; import { useWS } from "../hooks/useWS.ts";
@@ -256,6 +257,7 @@ function groupByDate(
} }
export function Notifications() { export function Notifications() {
useDocumentTitle(t`Notifications`);
const { authFetch } = useAuth(); const { authFetch } = useAuth();
const { clearUnreadNotifications, lastNotification } = useWS(); const { clearUnreadNotifications, lastNotification } = useWS();
const [state, setState] = useState<State>({ status: "loading" }); const [state, setState] = useState<State>({ status: "loading" });

View File

@@ -27,6 +27,7 @@ import {
import { playlistUrl } from "../utils/urls.ts"; import { playlistUrl } from "../utils/urls.ts";
import { can } from "../utils/permissions.ts"; import { can } from "../utils/permissions.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useWS } from "../hooks/useWS.ts"; import { useWS } from "../hooks/useWS.ts";
import { relativeTime } from "../utils/relativeTime.ts"; import { relativeTime } from "../utils/relativeTime.ts";
import { DumpCard } from "../components/DumpCard.tsx"; 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 // Stable UUID for WS comparisons — avoids re-running effects on every state change
const playlistUUID = state.status === "loaded" ? state.playlist.id : null; 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) // activeDumpIds: which dumps are currently in the playlist (the canonical set)
const [activeDumpIds, setActiveDumpIds] = useState<Set<string>>(new 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 { Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../config/api.ts"; import { API_URL, VALIDATION } from "../config/api.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { PageShell } from "../components/PageShell.tsx"; import { PageShell } from "../components/PageShell.tsx";
import { import {
expectOk, expectOk,
@@ -20,6 +21,7 @@ interface Values {
} }
export function ResetPassword() { export function ResetPassword() {
useDocumentTitle(t`Reset password`);
const [params] = useSearchParams(); const [params] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
const token = params.get("token") ?? ""; const token = params.get("token") ?? "";

View File

@@ -8,6 +8,7 @@ import { DumpCard } from "../components/DumpCard.tsx";
import { PlaylistCard } from "../components/PlaylistCard.tsx"; import { PlaylistCard } from "../components/PlaylistCard.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx"; import { ErrorCard } from "../components/ErrorCard.tsx";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useWS } from "../hooks/useWS.ts"; import { useWS } from "../hooks/useWS.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts"; import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
import { useTabParam } from "../hooks/useTabParam.ts"; import { useTabParam } from "../hooks/useTabParam.ts";
@@ -46,6 +47,7 @@ type SearchState =
}; };
export function Search() { export function Search() {
useDocumentTitle(t`Search`);
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const q = searchParams.get("q") ?? ""; const q = searchParams.get("q") ?? "";
const [tab, setTab] = useTabParam<Tab>(SEARCH_TABS, "dumps"); 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 { Link, useParams } from "react-router";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useWS } from "../hooks/useWS.ts"; import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts"; import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts"; import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
@@ -19,6 +20,8 @@ export function UserDumps() {
const { user: me } = useAuth(); const { user: me } = useAuth();
const { voteCounts, myVotes, lastDumpEvent, castVote, removeVote } = useWS(); const { voteCounts, myVotes, lastDumpEvent, castVote, removeVote } = useWS();
useDocumentTitle(username ?? null);
const { state, setItems, sentinelRef } = useUserDumpFeed( const { state, setItems, sentinelRef } = useUserDumpFeed(
username, username,
"dumps", "dumps",

View File

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

View File

@@ -23,6 +23,7 @@ import {
hydratePlaylist, hydratePlaylist,
} from "../model.ts"; } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useWS } from "../hooks/useWS.ts"; import { useWS } from "../hooks/useWS.ts";
import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts"; import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts";
import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts"; import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
@@ -60,6 +61,8 @@ function initialFeed(items: Playlist[], hasMore: boolean): PlaylistFeed {
export function UserPlaylists() { export function UserPlaylists() {
const { username } = useParams(); const { username } = useParams();
const { user: me, authFetch, token } = useAuth(); const { user: me, authFetch, token } = useAuth();
useDocumentTitle(username ?? null);
const { lastPlaylistEvent } = useWS(); const { lastPlaylistEvent } = useWS();
const { cached: cachedCreated, saveState: saveCreated } = useFeedCache< 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 { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx"; import { PageError } from "../components/PageError.tsx";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useTheme } from "../hooks/useTheme.ts"; import { useTheme } from "../hooks/useTheme.ts";
import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts"; import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts";
import { useWS } from "../hooks/useWS.ts"; import { useWS } from "../hooks/useWS.ts";
@@ -211,6 +212,14 @@ export function UserPublicProfile() {
const [state, setState] = useState<ProfileState>({ status: "loading" }); 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 profileUserId = state.status === "loaded" ? state.user.id : null;
const isOwnProfile = me?.id === profileUserId; const isOwnProfile = me?.id === profileUserId;

View File

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

View File

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