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; +}