feat: dynamic page titles across SPA

useDocumentTitle hook sets document.title per page.
Dynamic: movie name, person name, username, wrapup year.
Static: diary, profile, search, social, all settings pages.
This commit is contained in:
2026-06-11 12:45:01 +02:00
parent a95be0b131
commit acc20d2f43
15 changed files with 40 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
import { useEffect } from "react"
const BASE_TITLE = "Movies Diary"
export function useDocumentTitle(title?: string) {
useEffect(() => {
document.title = title ? `${title}${BASE_TITLE}` : BASE_TITLE
return () => {
document.title = BASE_TITLE
}
}, [title])
}

View File

@@ -10,6 +10,7 @@ import { VirtualList } from "@/components/virtual-list"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { useInfiniteDiary, useDeleteReview } from "@/hooks/use-diary" import { useInfiniteDiary, useDeleteReview } from "@/hooks/use-diary"
import { useDocumentTitle } from "@/hooks/use-document-title"
import type { DiaryEntryDto } from "@/lib/api/common" import type { DiaryEntryDto } from "@/lib/api/common"
export const Route = createFileRoute("/_app/diary")({ export const Route = createFileRoute("/_app/diary")({
@@ -27,6 +28,7 @@ function groupByDate(items: DiaryEntryDto[]) {
function DiaryPage() { function DiaryPage() {
const { t } = useTranslation() const { t } = useTranslation()
useDocumentTitle(t("diary.title"))
const [month, setMonth] = useState(() => startOfMonth(new Date())) const [month, setMonth] = useState(() => startOfMonth(new Date()))
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } = const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteDiary({ sort_by: "desc" }) useInfiniteDiary({ sort_by: "desc" })

View File

@@ -12,6 +12,7 @@ import { Skeleton } from "@/components/ui/skeleton"
import { posterUrl, tmdbProfileUrl } from "@/lib/api/client" import { posterUrl, tmdbProfileUrl } from "@/lib/api/client"
import { timeAgo, shortDate } from "@/lib/date" import { timeAgo, shortDate } from "@/lib/date"
import { useMovie, useMovieHistory, useMovieProfile } from "@/hooks/use-movies" import { useMovie, useMovieHistory, useMovieProfile } from "@/hooks/use-movies"
import { useDocumentTitle } from "@/hooks/use-document-title"
import { import {
useWatchlistStatus, useWatchlistStatus,
useAddToWatchlist, useAddToWatchlist,
@@ -34,6 +35,7 @@ function MovieDetailPage() {
if (!data) return null if (!data) return null
const { movie, stats, reviews } = data const { movie, stats, reviews } = data
useDocumentTitle(movie.title)
const hasStats = profile && (profile.budget_usd != null || profile.revenue_usd != null || profile.vote_average != null) const hasStats = profile && (profile.budget_usd != null || profile.revenue_usd != null || profile.vote_average != null)
return ( return (

View File

@@ -7,6 +7,7 @@ import { EmptyState } from "@/components/empty-state"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { tmdbProfileUrl } from "@/lib/api/client" import { tmdbProfileUrl } from "@/lib/api/client"
import { usePersonCredits } from "@/hooks/use-search" import { usePersonCredits } from "@/hooks/use-search"
import { useDocumentTitle } from "@/hooks/use-document-title"
export const Route = createFileRoute("/_app/people/$id")({ export const Route = createFileRoute("/_app/people/$id")({
component: PersonDetailPage, component: PersonDetailPage,
@@ -21,6 +22,7 @@ function PersonDetailPage() {
if (!data) return null if (!data) return null
const { person, cast, crew } = data const { person, cast, crew } = data
useDocumentTitle(person.name)
return ( return (
<div className="space-y-4 p-4"> <div className="space-y-4 p-4">

View File

@@ -11,6 +11,7 @@ import { useDeleteGoal } from "@/hooks/use-goals"
import { GoalCard } from "@/components/goal-card" import { GoalCard } from "@/components/goal-card"
import { GoalSheet } from "@/components/goal-sheet" import { GoalSheet } from "@/components/goal-sheet"
import { toast } from "sonner" import { toast } from "sonner"
import { useDocumentTitle } from "@/hooks/use-document-title"
import type { GoalDto } from "@/lib/api/users" import type { GoalDto } from "@/lib/api/users"
export const Route = createFileRoute("/_app/profile")({ export const Route = createFileRoute("/_app/profile")({
@@ -20,6 +21,7 @@ export const Route = createFileRoute("/_app/profile")({
function ProfilePage() { function ProfilePage() {
const { t } = useTranslation() const { t } = useTranslation()
const { auth } = useAuth() const { auth } = useAuth()
useDocumentTitle(t("profile.title"))
const { data, isPending } = useUserProfile(auth?.user_id ?? "", { const { data, isPending } = useUserProfile(auth?.user_id ?? "", {
view: "trends", view: "trends",
}) })

View File

@@ -11,6 +11,7 @@ import { InfiniteScroll } from "@/components/infinite-scroll"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { useInfiniteSearch } from "@/hooks/use-search" import { useInfiniteSearch } from "@/hooks/use-search"
import { useDebounce } from "@/hooks/use-debounce" import { useDebounce } from "@/hooks/use-debounce"
import { useDocumentTitle } from "@/hooks/use-document-title"
import { useAddToWatchlist } from "@/hooks/use-watchlist" import { useAddToWatchlist } from "@/hooks/use-watchlist"
import { toast } from "sonner" import { toast } from "sonner"
@@ -20,6 +21,7 @@ export const Route = createFileRoute("/_app/search")({
function SearchPage() { function SearchPage() {
const { t } = useTranslation() const { t } = useTranslation()
useDocumentTitle(t("search.placeholder"))
const addToWatchlist = useAddToWatchlist() const addToWatchlist = useAddToWatchlist()
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const debouncedQuery = useDebounce(query, 300) const debouncedQuery = useDebounce(query, 300)

View File

@@ -15,6 +15,7 @@ import {
useAddBlockedDomain, useAddBlockedDomain,
useRemoveBlockedDomain, useRemoveBlockedDomain,
} from "@/hooks/use-social" } from "@/hooks/use-social"
import { useDocumentTitle } from "@/hooks/use-document-title"
export const Route = createFileRoute("/_app/settings/blocked")({ export const Route = createFileRoute("/_app/settings/blocked")({
component: BlockedPage, component: BlockedPage,
@@ -22,6 +23,7 @@ export const Route = createFileRoute("/_app/settings/blocked")({
function BlockedPage() { function BlockedPage() {
const { t } = useTranslation() const { t } = useTranslation()
useDocumentTitle(t("blocked.title"))
const isAdmin = useIsAdmin() const isAdmin = useIsAdmin()
return ( return (

View File

@@ -10,6 +10,7 @@ import { Separator } from "@/components/ui/separator"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { useProfile, useUpdateProfile, useUpdateProfileFields } from "@/hooks/use-users" import { useProfile, useUpdateProfile, useUpdateProfileFields } from "@/hooks/use-users"
import { useDocumentTitle } from "@/hooks/use-document-title"
export const Route = createFileRoute("/_app/settings/edit-profile")({ export const Route = createFileRoute("/_app/settings/edit-profile")({
component: EditProfilePage, component: EditProfilePage,
@@ -17,6 +18,7 @@ export const Route = createFileRoute("/_app/settings/edit-profile")({
function EditProfilePage() { function EditProfilePage() {
const { t } = useTranslation() const { t } = useTranslation()
useDocumentTitle(t("editProfile.title"))
const { data, isPending } = useProfile() const { data, isPending } = useProfile()
const update = useUpdateProfile() const update = useUpdateProfile()
const updateFields = useUpdateProfileFields() const updateFields = useUpdateProfileFields()

View File

@@ -28,6 +28,7 @@ import {
useConfirmImport, useConfirmImport,
useImportPreview, useImportPreview,
} from "@/hooks/use-imports" } from "@/hooks/use-imports"
import { useDocumentTitle } from "@/hooks/use-document-title"
import type { SessionCreatedResponse } from "@/lib/api/imports" import type { SessionCreatedResponse } from "@/lib/api/imports"
export const Route = createFileRoute("/_app/settings/import")({ export const Route = createFileRoute("/_app/settings/import")({
@@ -36,6 +37,7 @@ export const Route = createFileRoute("/_app/settings/import")({
function ImportPage() { function ImportPage() {
const { t } = useTranslation() const { t } = useTranslation()
useDocumentTitle(t("import.title"))
const DOMAIN_FIELDS = [ const DOMAIN_FIELDS = [
{ value: "title", label: t("import.fieldTitle") }, { value: "title", label: t("import.fieldTitle") },

View File

@@ -22,6 +22,7 @@ import { API_URL } from "@/lib/api/client"
import { getToken } from "@/lib/auth" import { getToken } from "@/lib/auth"
import { reindexSearch } from "@/lib/api/users" import { reindexSearch } from "@/lib/api/users"
import { useSettings, useUpdateSettings } from "@/hooks/use-goals" import { useSettings, useUpdateSettings } from "@/hooks/use-goals"
import { useDocumentTitle } from "@/hooks/use-document-title"
export const Route = createFileRoute("/_app/settings/")({ export const Route = createFileRoute("/_app/settings/")({
component: SettingsPage, component: SettingsPage,
@@ -36,6 +37,7 @@ type SettingsItem = {
function SettingsPage() { function SettingsPage() {
const { t } = useTranslation() const { t } = useTranslation()
useDocumentTitle(t("settings.title"))
const { logout } = useAuth() const { logout } = useAuth()
const isAdmin = useIsAdmin() const isAdmin = useIsAdmin()
const navigate = useNavigate() const navigate = useNavigate()

View File

@@ -28,6 +28,7 @@ import {
useDeleteToken, useDeleteToken,
} from "@/hooks/use-webhooks" } from "@/hooks/use-webhooks"
import { API_URL } from "@/lib/api/client" import { API_URL } from "@/lib/api/client"
import { useDocumentTitle } from "@/hooks/use-document-title"
export const Route = createFileRoute("/_app/settings/webhooks")({ export const Route = createFileRoute("/_app/settings/webhooks")({
component: WebhooksPage, component: WebhooksPage,
@@ -35,6 +36,7 @@ export const Route = createFileRoute("/_app/settings/webhooks")({
function WebhooksPage() { function WebhooksPage() {
const { t } = useTranslation() const { t } = useTranslation()
useDocumentTitle(t("webhooks.title"))
const { data: tokens, isPending } = useWebhookTokens() const { data: tokens, isPending } = useWebhookTokens()
const generate = useGenerateToken() const generate = useGenerateToken()
const remove = useDeleteToken() const remove = useDeleteToken()

View File

@@ -23,6 +23,7 @@ import {
useDeleteWrapUp, useDeleteWrapUp,
} from "@/hooks/use-wrapup" } from "@/hooks/use-wrapup"
import { useUsers } from "@/hooks/use-users" import { useUsers } from "@/hooks/use-users"
import { useDocumentTitle } from "@/hooks/use-document-title"
export const Route = createFileRoute("/_app/settings/wrapup")({ export const Route = createFileRoute("/_app/settings/wrapup")({
component: WrapupPage, component: WrapupPage,
@@ -30,6 +31,7 @@ export const Route = createFileRoute("/_app/settings/wrapup")({
function WrapupPage() { function WrapupPage() {
const { t } = useTranslation() const { t } = useTranslation()
useDocumentTitle(t("wrapup.title"))
const { auth } = useAuth() const { auth } = useAuth()
const isAdmin = useIsAdmin() const isAdmin = useIsAdmin()
const { data, isPending } = useWrapUps() const { data, isPending } = useWrapUps()

View File

@@ -23,6 +23,7 @@ import {
useUserFollowing, useUserFollowing,
useUserFollowers, useUserFollowers,
} from "@/hooks/use-social" } from "@/hooks/use-social"
import { useDocumentTitle } from "@/hooks/use-document-title"
import type { RemoteActorDto } from "@/lib/api/social" import type { RemoteActorDto } from "@/lib/api/social"
type SearchParams = { user?: string } type SearchParams = { user?: string }
@@ -36,6 +37,7 @@ export const Route = createFileRoute("/_app/social")({
function SocialPage() { function SocialPage() {
const { t } = useTranslation() const { t } = useTranslation()
useDocumentTitle(t("social.title"))
const { user: viewUserId } = Route.useSearch() const { user: viewUserId } = Route.useSearch()
const { auth } = useAuth() const { auth } = useAuth()
const isSelf = !viewUserId || viewUserId === auth?.user_id const isSelf = !viewUserId || viewUserId === auth?.user_id

View File

@@ -9,6 +9,7 @@ import { GoalCard } from "@/components/goal-card"
import { useAuth } from "@/components/auth-provider" import { useAuth } from "@/components/auth-provider"
import { useUserProfile } from "@/hooks/use-users" import { useUserProfile } from "@/hooks/use-users"
import { useFollow, useUnfollow, useFollowing } from "@/hooks/use-social" import { useFollow, useUnfollow, useFollowing } from "@/hooks/use-social"
import { useDocumentTitle } from "@/hooks/use-document-title"
export const Route = createFileRoute("/_app/users/$id")({ export const Route = createFileRoute("/_app/users/$id")({
component: UserProfilePage, component: UserProfilePage,
@@ -28,6 +29,7 @@ function UserProfilePage() {
if (isPending) return <ProfileSkeleton /> if (isPending) return <ProfileSkeleton />
if (!data) return null if (!data) return null
useDocumentTitle(data?.username)
const isSelf = auth?.user_id === id const isSelf = auth?.user_id === id
const isFollowing = followingData?.actors.some((a) => a.handle === data.username) ?? false const isFollowing = followingData?.actors.some((a) => a.handle === data.username) ?? false

View File

@@ -17,6 +17,7 @@ import { RankCard } from "@/components/wrapup-rank-card"
import { posterUrl } from "@/lib/api/client" import { posterUrl } from "@/lib/api/client"
import { fmtUsd } from "@/lib/format" import { fmtUsd } from "@/lib/format"
import { useWrapUpReport } from "@/hooks/use-wrapup" import { useWrapUpReport } from "@/hooks/use-wrapup"
import { useDocumentTitle } from "@/hooks/use-document-title"
import type { MovieRef } from "@/lib/api/wrapup" import type { MovieRef } from "@/lib/api/wrapup"
const WrapUpShareCard = lazy(() => import("@/components/wrapup-share-card").then((m) => ({ default: m.WrapUpShareCard }))) const WrapUpShareCard = lazy(() => import("@/components/wrapup-share-card").then((m) => ({ default: m.WrapUpShareCard })))
@@ -39,6 +40,7 @@ function WrapUpReportPage() {
const { data: report, isPending } = useWrapUpReport(id) const { data: report, isPending } = useWrapUpReport(id)
const [showShare, setShowShare] = useState(false) const [showShare, setShowShare] = useState(false)
useDocumentTitle(report ? `${report.date_range.start.slice(0, 4)} Wrap-Up` : undefined)
if (isPending) return <ReportSkeleton /> if (isPending) return <ReportSkeleton />
if (!report) return null if (!report) return null