From 33bc6a935cb135061b54efef517f7f7c5ee0c356 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Fri, 18 Sep 2026 05:52:13 +0200 Subject: [PATCH] feat(ui): shot status, projects and client portal keep their place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coming back from a shot left the shot status page at the top with its sections reset. The scroll restore saved window.scrollY, but dashboard pages scroll the layout's
, so it always saved 0; the Projects page had the same bug. Only episode expansion was remembered — scene and group sections came back fully collapsed. A shared useScrollMemory hook saves the real scroll container's position as the user scrolls (so any way of leaving keeps it) and restores it once the content that sets the page height is back. It ignores the scroll the shared
fires when the next page replaces this one, which otherwise overwrote the saved position. Shot status now remembers episode, scene and group expansion per project. The client portal already kept its filters and collapsed episodes but restored scroll before they were applied; it now uses the hook too. Co-Authored-By: Claude Opus 5 --- app/(dashboard)/projects/page.tsx | 22 ++--- .../shot-status/ShotStatusClient.tsx | 96 ++++++++++++------- app/client/[token]/page.tsx | 20 ++-- hooks/use-scroll-memory.ts | 91 ++++++++++++++++++ 4 files changed, 170 insertions(+), 59 deletions(-) create mode 100644 hooks/use-scroll-memory.ts diff --git a/app/(dashboard)/projects/page.tsx b/app/(dashboard)/projects/page.tsx index 5ca9cd7..04744b6 100644 --- a/app/(dashboard)/projects/page.tsx +++ b/app/(dashboard)/projects/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -8,6 +8,7 @@ import { ProjectCard } from "@/components/projects/ProjectCard"; import { NewProjectDialog } from "@/components/projects/NewProjectDialog"; import { Plus, Search, Loader2 } from "lucide-react"; import { HIDE_ARCHIVED_KEY } from "@/components/settings/HideArchivedToggle"; +import { useScrollMemory } from "@/hooks/use-scroll-memory"; export default function ProjectsPage() { const [search, setSearch] = useState(""); @@ -43,22 +44,11 @@ export default function ProjectsPage() { const visibleProjects = hideArchived ? projects.filter((p: any) => p.status !== "ARCHIVED") : projects; - const SCROLL_KEY = 'projects-scroll'; - - useEffect(() => { - if (!isLoading) { - const saved = sessionStorage.getItem(SCROLL_KEY); - if (saved) { - sessionStorage.removeItem(SCROLL_KEY); - requestAnimationFrame(() => window.scrollTo(0, parseInt(saved, 10))); - } - } - }, [isLoading]); - - const saveScroll = () => sessionStorage.setItem(SCROLL_KEY, String(window.scrollY)); + const pageRef = useRef(null); + useScrollMemory("projects", pageRef, !isLoading); return ( -
+

Projects

@@ -108,7 +98,7 @@ export default function ProjectsPage() { // long description does not make its whole row taller than the rest.
{visibleProjects.map((project: any) => ( -
+
))} diff --git a/app/(dashboard)/shot-status/ShotStatusClient.tsx b/app/(dashboard)/shot-status/ShotStatusClient.tsx index 5709f5c..eead873 100644 --- a/app/(dashboard)/shot-status/ShotStatusClient.tsx +++ b/app/(dashboard)/shot-status/ShotStatusClient.tsx @@ -4,6 +4,7 @@ import { useState, useTransition, useCallback, useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; import Image from "next/image"; import Link from "next/link"; +import { useScrollMemory } from "@/hooks/use-scroll-memory"; import { Select, SelectContent, @@ -101,7 +102,25 @@ interface ShotStatusClientProps { canManage: boolean; } -const SCROLL_KEY = 'shot-status-scroll'; +function readStored(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +/** A saved list of open sections, or null when none was saved (or it is unreadable). */ +function readStoredList(key: string): string[] | null { + const raw = readStored(key); + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === "string") : null; + } catch { + return null; + } +} const STATUS_CONFIG: Record = { WAITING: { label: "Waiting", color: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", icon: Clock }, @@ -780,7 +799,6 @@ function ShotTable({ sessionStorage.setItem(SCROLL_KEY, String(window.scrollY))} > {shot.shotCode} @@ -879,15 +897,6 @@ export function ShotStatusClient({ const router = useRouter(); const { toast } = useToast(); - // Restore scroll position when shots load - useEffect(() => { - const saved = sessionStorage.getItem(SCROLL_KEY); - if (saved) { - sessionStorage.removeItem(SCROLL_KEY); - requestAnimationFrame(() => window.scrollTo(0, parseInt(saved, 10))); - } - }, [shots]); - // Restore last selected project if no project is currently selected useEffect(() => { if (!selectedProjectId) { @@ -905,22 +914,39 @@ export function ShotStatusClient({ const [groupMode, setGroupMode] = useState<"flat" | "scene" | "group">("flat"); const [selectedIds, setSelectedIds] = useState>(new Set()); - // Load saved episode expand state when project changes + // Which sections were open is restored before the scroll position: until + // they are, the page is shorter and the saved offset would not fit. + const [expandRestored, setExpandRestored] = useState(false); + const pageRef = useRef(null); + useScrollMemory(selectedProjectId ? `shotStatus:${selectedProjectId}` : null, pageRef, expandRestored); + + // Load saved expand state and grouping when the project changes useEffect(() => { if (!selectedProjectId) return; - try { - const saved = localStorage.getItem(`shotStatus:${selectedProjectId}:episodeExpand`); - setExpandedEpisodes(saved ? new Set(JSON.parse(saved) as string[]) : new Set()); - } catch { - setExpandedEpisodes(new Set()); - } - try { - const savedGroup = localStorage.getItem(`shotStatus:${selectedProjectId}:groupMode`); - setGroupMode((savedGroup as "flat" | "scene" | "group") ?? "flat"); - } catch { - setGroupMode("flat"); - } + setExpandRestored(false); + const mode = (readStored(`shotStatus:${selectedProjectId}:groupMode`) ?? "flat") as "flat" | "scene" | "group"; + setGroupMode(mode); + setExpandedEpisodes(new Set(readStoredList(`shotStatus:${selectedProjectId}:episodeExpand`) ?? [])); + // Scenes and groups open fully when their mode is first chosen, so with + // nothing saved yet they come back that way rather than all collapsed. + setExpandedScenes( + new Set(readStoredList(`shotStatus:${selectedProjectId}:sceneExpand`) ?? shots.map((s) => s.scene)) + ); + setExpandedGroups( + new Set(readStoredList(`shotStatus:${selectedProjectId}:groupExpand`) ?? shots.map((s) => s.shotGroup?.id ?? "")) + ); + setExpandRestored(true); + // Shots are read only for the defaults; a refresh after an edit must not + // reset what the user has opened. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedProjectId]); + + const saveExpanded = (kind: "episodeExpand" | "sceneExpand" | "groupExpand", open: Set) => { + if (!selectedProjectId) return; + try { + localStorage.setItem(`shotStatus:${selectedProjectId}:${kind}`, JSON.stringify([...open])); + } catch {} + }; const [dueDateDialogOpen, setDueDateDialogOpen] = useState(false); const [assignDialogOpen, setAssignDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -930,11 +956,7 @@ export function ShotStatusClient({ setExpandedEpisodes((prev) => { const next = new Set(prev); next.has(ep) ? next.delete(ep) : next.add(ep); - if (selectedProjectId) { - try { - localStorage.setItem(`shotStatus:${selectedProjectId}:episodeExpand`, JSON.stringify([...next])); - } catch {} - } + saveExpanded("episodeExpand", next); return next; }); @@ -942,6 +964,7 @@ export function ShotStatusClient({ setExpandedScenes((prev) => { const next = new Set(prev); next.has(sc) ? next.delete(sc) : next.add(sc); + saveExpanded("sceneExpand", next); return next; }); @@ -949,13 +972,22 @@ export function ShotStatusClient({ setExpandedGroups((prev) => { const next = new Set(prev); next.has(g) ? next.delete(g) : next.add(g); + saveExpanded("groupExpand", next); return next; }); const handleGroupMode = (mode: "flat" | "scene" | "group") => { setGroupMode(mode); - if (mode === "scene") setExpandedScenes(new Set(shots.map((s) => s.scene))); - if (mode === "group") setExpandedGroups(new Set(shots.map((s) => s.shotGroup?.id ?? ""))); + if (mode === "scene") { + const all = new Set(shots.map((s) => s.scene)); + setExpandedScenes(all); + saveExpanded("sceneExpand", all); + } + if (mode === "group") { + const all = new Set(shots.map((s) => s.shotGroup?.id ?? "")); + setExpandedGroups(all); + saveExpanded("groupExpand", all); + } if (selectedProjectId) { try { localStorage.setItem(`shotStatus:${selectedProjectId}:groupMode`, mode); } catch {} } @@ -1092,7 +1124,7 @@ export function ShotStatusClient({ const selectionCount = selectedIds.size; return ( -
+
{/* Header */}

Shot Status

diff --git a/app/client/[token]/page.tsx b/app/client/[token]/page.tsx index 3c5be79..eab9ed8 100644 --- a/app/client/[token]/page.tsx +++ b/app/client/[token]/page.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useRef, useEffect } from 'react'; +import { useScrollMemory } from '@/hooks/use-scroll-memory'; import Link from 'next/link'; import Image from 'next/image'; import { @@ -127,7 +128,6 @@ const TASK_TYPE_LABELS: Record = { GENERAL: 'Task', }; -const SCROLL_KEY = 'client-portal-scroll'; const QUEUE_KEY = 'client-portal-queue'; const STATE_KEY = 'client-portal-state'; const PREFS_KEY_PREFIX = 'client-portal-prefs-'; @@ -154,6 +154,11 @@ export default function ClientPortalPage({ const [sortMode, setSortMode] = useState<'episode' | 'recent'>('episode'); const [searchQuery, setSearchQuery] = useState(''); const [episodeFilter, setEpisodeFilter] = useState(null); + // Filters and collapsed episodes set the page height, so the scroll + // position is restored only once they are back. + const [viewRestored, setViewRestored] = useState(false); + const pageRef = useRef(null); + useScrollMemory(token ? `clientPortal:${token}` : null, pageRef, viewRestored); const toggleEpisode = (ep: string) => { setCollapsedEpisodes((prev) => { @@ -209,13 +214,7 @@ export default function ClientPortalPage({ } } catch { /* ignore */ } } - const saved = sessionStorage.getItem(SCROLL_KEY); - if (saved) { - sessionStorage.removeItem(SCROLL_KEY); - requestAnimationFrame(() => { - window.scrollTo(0, parseInt(saved, 10)); - }); - } + setViewRestored(true); } }, [loading, token]); @@ -429,7 +428,6 @@ export default function ClientPortalPage({ }; const handleReviewClick = () => { - sessionStorage.setItem(SCROLL_KEY, String(window.scrollY)); sessionStorage.setItem(QUEUE_KEY, JSON.stringify(getReviewQueue())); sessionStorage.setItem( STATE_KEY, @@ -444,7 +442,7 @@ export default function ClientPortalPage({ }; return ( -
+
{/* Logo */} diff --git a/hooks/use-scroll-memory.ts b/hooks/use-scroll-memory.ts new file mode 100644 index 0000000..f6da1a9 --- /dev/null +++ b/hooks/use-scroll-memory.ts @@ -0,0 +1,91 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +/** + * Remembers a page's scroll position for the browser session and puts it back + * when the page is shown again — e.g. after opening a shot and coming back. + * + * Dashboard pages do not scroll the window: the layout scrolls its
+ * (overflow-auto), so window.scrollY is always 0. This finds the nearest + * scrolling ancestor of `anchorRef` and saves that instead, falling back to + * the document for pages that do scroll the window (the client portal). + * + * The position is saved as the user scrolls, not on a particular link's click, + * so any way of leaving the page (another link, the sidebar, a browser + * back/forward) keeps it. + * + * Restores once per `key`, and only when `ready` is true: the caller holds it + * false until the content that sets the page height (expanded sections) is + * back, or the saved offset would be clamped to a shorter page. + */ +export function useScrollMemory( + key: string | null, + anchorRef: React.RefObject, + ready: boolean +) { + const restoredKey = useRef(null); + + useEffect(() => { + if (!key || !ready || !anchorRef.current) return; + const anchor = anchorRef.current; + const scroller = findScroller(anchor); + const storageKey = `scroll:${key}`; + + if (restoredKey.current !== key) { + restoredKey.current = key; + let saved: number | null = null; + try { + const raw = sessionStorage.getItem(storageKey); + saved = raw ? parseInt(raw, 10) : null; + } catch {} + if (saved && saved > 0) { + // Thumbnails and late rows can still grow the page for a moment, so + // keep trying for a short while until the offset actually fits. + const target = saved; + let frames = 0; + const tryRestore = () => { + scroller.scrollTop = target; + if (Math.abs(scroller.scrollTop - target) > 1 && frames++ < 60) { + requestAnimationFrame(tryRestore); + } + }; + requestAnimationFrame(tryRestore); + } + } + + let pending = false; + let disposed = false; + const onScroll = () => { + if (pending) return; + pending = true; + requestAnimationFrame(() => { + pending = false; + // The layout's scroller outlives this page. When the next page + // replaces this one, the scroller clamps to the new, shorter content + // and fires a scroll before this listener is removed; recording it + // would overwrite the real position. Once this page's content is out + // of the document, scrolls are not ours. + if (disposed || !anchor.isConnected) return; + try { + sessionStorage.setItem(storageKey, String(Math.round(scroller.scrollTop))); + } catch {} + }); + }; + // When the document itself scrolls, the event fires on window, not on . + const target: HTMLElement | Window = scroller === document.scrollingElement ? window : scroller; + target.addEventListener("scroll", onScroll, { passive: true }); + return () => { + disposed = true; + target.removeEventListener("scroll", onScroll); + }; + }, [key, ready, anchorRef]); +} + +function findScroller(el: HTMLElement): HTMLElement { + for (let node = el.parentElement; node; node = node.parentElement) { + const overflowY = getComputedStyle(node).overflowY; + if (overflowY === "auto" || overflowY === "scroll") return node; + } + return document.scrollingElement as HTMLElement; +}