v3: fixed inconsistent page titles
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
This commit is contained in:
31
src/hooks/useDocumentTitle.ts
Normal file
31
src/hooks/useDocumentTitle.ts
Normal 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
@@ -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[]>([]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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") ?? "";
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user