From acc20d2f43fa170dd5ea71abd2bd934facf52158 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 11 Jun 2026 12:45:01 +0200 Subject: [PATCH] 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. --- spa/src/hooks/use-document-title.ts | 12 ++++++++++++ spa/src/routes/_app/diary.tsx | 2 ++ spa/src/routes/_app/movies.$id.tsx | 2 ++ spa/src/routes/_app/people.$id.tsx | 2 ++ spa/src/routes/_app/profile.tsx | 2 ++ spa/src/routes/_app/search.tsx | 2 ++ spa/src/routes/_app/settings/blocked.tsx | 2 ++ spa/src/routes/_app/settings/edit-profile.tsx | 2 ++ spa/src/routes/_app/settings/import.tsx | 2 ++ spa/src/routes/_app/settings/index.tsx | 2 ++ spa/src/routes/_app/settings/webhooks.tsx | 2 ++ spa/src/routes/_app/settings/wrapup.tsx | 2 ++ spa/src/routes/_app/social.tsx | 2 ++ spa/src/routes/_app/users.$id.tsx | 2 ++ spa/src/routes/_app/wrapup.$id.tsx | 2 ++ 15 files changed, 40 insertions(+) create mode 100644 spa/src/hooks/use-document-title.ts diff --git a/spa/src/hooks/use-document-title.ts b/spa/src/hooks/use-document-title.ts new file mode 100644 index 0000000..1d2006a --- /dev/null +++ b/spa/src/hooks/use-document-title.ts @@ -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]) +} diff --git a/spa/src/routes/_app/diary.tsx b/spa/src/routes/_app/diary.tsx index 3161d21..fde59db 100644 --- a/spa/src/routes/_app/diary.tsx +++ b/spa/src/routes/_app/diary.tsx @@ -10,6 +10,7 @@ import { VirtualList } from "@/components/virtual-list" import { Button } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" import { useInfiniteDiary, useDeleteReview } from "@/hooks/use-diary" +import { useDocumentTitle } from "@/hooks/use-document-title" import type { DiaryEntryDto } from "@/lib/api/common" export const Route = createFileRoute("/_app/diary")({ @@ -27,6 +28,7 @@ function groupByDate(items: DiaryEntryDto[]) { function DiaryPage() { const { t } = useTranslation() + useDocumentTitle(t("diary.title")) const [month, setMonth] = useState(() => startOfMonth(new Date())) const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } = useInfiniteDiary({ sort_by: "desc" }) diff --git a/spa/src/routes/_app/movies.$id.tsx b/spa/src/routes/_app/movies.$id.tsx index a4bf900..0732a59 100644 --- a/spa/src/routes/_app/movies.$id.tsx +++ b/spa/src/routes/_app/movies.$id.tsx @@ -12,6 +12,7 @@ import { Skeleton } from "@/components/ui/skeleton" import { posterUrl, tmdbProfileUrl } from "@/lib/api/client" import { timeAgo, shortDate } from "@/lib/date" import { useMovie, useMovieHistory, useMovieProfile } from "@/hooks/use-movies" +import { useDocumentTitle } from "@/hooks/use-document-title" import { useWatchlistStatus, useAddToWatchlist, @@ -34,6 +35,7 @@ function MovieDetailPage() { if (!data) return null const { movie, stats, reviews } = data + useDocumentTitle(movie.title) const hasStats = profile && (profile.budget_usd != null || profile.revenue_usd != null || profile.vote_average != null) return ( diff --git a/spa/src/routes/_app/people.$id.tsx b/spa/src/routes/_app/people.$id.tsx index 00a11d9..ca6c13a 100644 --- a/spa/src/routes/_app/people.$id.tsx +++ b/spa/src/routes/_app/people.$id.tsx @@ -7,6 +7,7 @@ import { EmptyState } from "@/components/empty-state" import { Skeleton } from "@/components/ui/skeleton" import { tmdbProfileUrl } from "@/lib/api/client" import { usePersonCredits } from "@/hooks/use-search" +import { useDocumentTitle } from "@/hooks/use-document-title" export const Route = createFileRoute("/_app/people/$id")({ component: PersonDetailPage, @@ -21,6 +22,7 @@ function PersonDetailPage() { if (!data) return null const { person, cast, crew } = data + useDocumentTitle(person.name) return (
diff --git a/spa/src/routes/_app/profile.tsx b/spa/src/routes/_app/profile.tsx index 694092d..618a289 100644 --- a/spa/src/routes/_app/profile.tsx +++ b/spa/src/routes/_app/profile.tsx @@ -11,6 +11,7 @@ import { useDeleteGoal } from "@/hooks/use-goals" import { GoalCard } from "@/components/goal-card" import { GoalSheet } from "@/components/goal-sheet" import { toast } from "sonner" +import { useDocumentTitle } from "@/hooks/use-document-title" import type { GoalDto } from "@/lib/api/users" export const Route = createFileRoute("/_app/profile")({ @@ -20,6 +21,7 @@ export const Route = createFileRoute("/_app/profile")({ function ProfilePage() { const { t } = useTranslation() const { auth } = useAuth() + useDocumentTitle(t("profile.title")) const { data, isPending } = useUserProfile(auth?.user_id ?? "", { view: "trends", }) diff --git a/spa/src/routes/_app/search.tsx b/spa/src/routes/_app/search.tsx index c17d6a0..e36e8fc 100644 --- a/spa/src/routes/_app/search.tsx +++ b/spa/src/routes/_app/search.tsx @@ -11,6 +11,7 @@ import { InfiniteScroll } from "@/components/infinite-scroll" import { Skeleton } from "@/components/ui/skeleton" import { useInfiniteSearch } from "@/hooks/use-search" import { useDebounce } from "@/hooks/use-debounce" +import { useDocumentTitle } from "@/hooks/use-document-title" import { useAddToWatchlist } from "@/hooks/use-watchlist" import { toast } from "sonner" @@ -20,6 +21,7 @@ export const Route = createFileRoute("/_app/search")({ function SearchPage() { const { t } = useTranslation() + useDocumentTitle(t("search.placeholder")) const addToWatchlist = useAddToWatchlist() const [query, setQuery] = useState("") const debouncedQuery = useDebounce(query, 300) diff --git a/spa/src/routes/_app/settings/blocked.tsx b/spa/src/routes/_app/settings/blocked.tsx index d8c6dbd..02039c3 100644 --- a/spa/src/routes/_app/settings/blocked.tsx +++ b/spa/src/routes/_app/settings/blocked.tsx @@ -15,6 +15,7 @@ import { useAddBlockedDomain, useRemoveBlockedDomain, } from "@/hooks/use-social" +import { useDocumentTitle } from "@/hooks/use-document-title" export const Route = createFileRoute("/_app/settings/blocked")({ component: BlockedPage, @@ -22,6 +23,7 @@ export const Route = createFileRoute("/_app/settings/blocked")({ function BlockedPage() { const { t } = useTranslation() + useDocumentTitle(t("blocked.title")) const isAdmin = useIsAdmin() return ( diff --git a/spa/src/routes/_app/settings/edit-profile.tsx b/spa/src/routes/_app/settings/edit-profile.tsx index 04c43af..de81005 100644 --- a/spa/src/routes/_app/settings/edit-profile.tsx +++ b/spa/src/routes/_app/settings/edit-profile.tsx @@ -10,6 +10,7 @@ import { Separator } from "@/components/ui/separator" import { Textarea } from "@/components/ui/textarea" import { Skeleton } from "@/components/ui/skeleton" import { useProfile, useUpdateProfile, useUpdateProfileFields } from "@/hooks/use-users" +import { useDocumentTitle } from "@/hooks/use-document-title" export const Route = createFileRoute("/_app/settings/edit-profile")({ component: EditProfilePage, @@ -17,6 +18,7 @@ export const Route = createFileRoute("/_app/settings/edit-profile")({ function EditProfilePage() { const { t } = useTranslation() + useDocumentTitle(t("editProfile.title")) const { data, isPending } = useProfile() const update = useUpdateProfile() const updateFields = useUpdateProfileFields() diff --git a/spa/src/routes/_app/settings/import.tsx b/spa/src/routes/_app/settings/import.tsx index dd47088..b5eb5d1 100644 --- a/spa/src/routes/_app/settings/import.tsx +++ b/spa/src/routes/_app/settings/import.tsx @@ -28,6 +28,7 @@ import { useConfirmImport, useImportPreview, } from "@/hooks/use-imports" +import { useDocumentTitle } from "@/hooks/use-document-title" import type { SessionCreatedResponse } from "@/lib/api/imports" export const Route = createFileRoute("/_app/settings/import")({ @@ -36,6 +37,7 @@ export const Route = createFileRoute("/_app/settings/import")({ function ImportPage() { const { t } = useTranslation() + useDocumentTitle(t("import.title")) const DOMAIN_FIELDS = [ { value: "title", label: t("import.fieldTitle") }, diff --git a/spa/src/routes/_app/settings/index.tsx b/spa/src/routes/_app/settings/index.tsx index 449e979..a789409 100644 --- a/spa/src/routes/_app/settings/index.tsx +++ b/spa/src/routes/_app/settings/index.tsx @@ -22,6 +22,7 @@ import { API_URL } from "@/lib/api/client" import { getToken } from "@/lib/auth" import { reindexSearch } from "@/lib/api/users" import { useSettings, useUpdateSettings } from "@/hooks/use-goals" +import { useDocumentTitle } from "@/hooks/use-document-title" export const Route = createFileRoute("/_app/settings/")({ component: SettingsPage, @@ -36,6 +37,7 @@ type SettingsItem = { function SettingsPage() { const { t } = useTranslation() + useDocumentTitle(t("settings.title")) const { logout } = useAuth() const isAdmin = useIsAdmin() const navigate = useNavigate() diff --git a/spa/src/routes/_app/settings/webhooks.tsx b/spa/src/routes/_app/settings/webhooks.tsx index eb7f5e3..b01e078 100644 --- a/spa/src/routes/_app/settings/webhooks.tsx +++ b/spa/src/routes/_app/settings/webhooks.tsx @@ -28,6 +28,7 @@ import { useDeleteToken, } from "@/hooks/use-webhooks" import { API_URL } from "@/lib/api/client" +import { useDocumentTitle } from "@/hooks/use-document-title" export const Route = createFileRoute("/_app/settings/webhooks")({ component: WebhooksPage, @@ -35,6 +36,7 @@ export const Route = createFileRoute("/_app/settings/webhooks")({ function WebhooksPage() { const { t } = useTranslation() + useDocumentTitle(t("webhooks.title")) const { data: tokens, isPending } = useWebhookTokens() const generate = useGenerateToken() const remove = useDeleteToken() diff --git a/spa/src/routes/_app/settings/wrapup.tsx b/spa/src/routes/_app/settings/wrapup.tsx index 060c89d..3b0f467 100644 --- a/spa/src/routes/_app/settings/wrapup.tsx +++ b/spa/src/routes/_app/settings/wrapup.tsx @@ -23,6 +23,7 @@ import { useDeleteWrapUp, } from "@/hooks/use-wrapup" import { useUsers } from "@/hooks/use-users" +import { useDocumentTitle } from "@/hooks/use-document-title" export const Route = createFileRoute("/_app/settings/wrapup")({ component: WrapupPage, @@ -30,6 +31,7 @@ export const Route = createFileRoute("/_app/settings/wrapup")({ function WrapupPage() { const { t } = useTranslation() + useDocumentTitle(t("wrapup.title")) const { auth } = useAuth() const isAdmin = useIsAdmin() const { data, isPending } = useWrapUps() diff --git a/spa/src/routes/_app/social.tsx b/spa/src/routes/_app/social.tsx index c3f50a7..b5fbc82 100644 --- a/spa/src/routes/_app/social.tsx +++ b/spa/src/routes/_app/social.tsx @@ -23,6 +23,7 @@ import { useUserFollowing, useUserFollowers, } from "@/hooks/use-social" +import { useDocumentTitle } from "@/hooks/use-document-title" import type { RemoteActorDto } from "@/lib/api/social" type SearchParams = { user?: string } @@ -36,6 +37,7 @@ export const Route = createFileRoute("/_app/social")({ function SocialPage() { const { t } = useTranslation() + useDocumentTitle(t("social.title")) const { user: viewUserId } = Route.useSearch() const { auth } = useAuth() const isSelf = !viewUserId || viewUserId === auth?.user_id diff --git a/spa/src/routes/_app/users.$id.tsx b/spa/src/routes/_app/users.$id.tsx index 88c5f04..dd3c83c 100644 --- a/spa/src/routes/_app/users.$id.tsx +++ b/spa/src/routes/_app/users.$id.tsx @@ -9,6 +9,7 @@ import { GoalCard } from "@/components/goal-card" import { useAuth } from "@/components/auth-provider" import { useUserProfile } from "@/hooks/use-users" import { useFollow, useUnfollow, useFollowing } from "@/hooks/use-social" +import { useDocumentTitle } from "@/hooks/use-document-title" export const Route = createFileRoute("/_app/users/$id")({ component: UserProfilePage, @@ -28,6 +29,7 @@ function UserProfilePage() { if (isPending) return if (!data) return null + useDocumentTitle(data?.username) const isSelf = auth?.user_id === id const isFollowing = followingData?.actors.some((a) => a.handle === data.username) ?? false diff --git a/spa/src/routes/_app/wrapup.$id.tsx b/spa/src/routes/_app/wrapup.$id.tsx index 6ff8a8b..c41b75a 100644 --- a/spa/src/routes/_app/wrapup.$id.tsx +++ b/spa/src/routes/_app/wrapup.$id.tsx @@ -17,6 +17,7 @@ import { RankCard } from "@/components/wrapup-rank-card" import { posterUrl } from "@/lib/api/client" import { fmtUsd } from "@/lib/format" import { useWrapUpReport } from "@/hooks/use-wrapup" +import { useDocumentTitle } from "@/hooks/use-document-title" import type { MovieRef } from "@/lib/api/wrapup" 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 [showShare, setShowShare] = useState(false) + useDocumentTitle(report ? `${report.date_range.start.slice(0, 4)} Wrap-Up` : undefined) if (isPending) return if (!report) return null