feat(ui): shot status, projects and client portal keep their place
Deploy / deploy (push) Successful in 3m31s

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 <main>, 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
<main> 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 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-09-18 05:52:13 +02:00
parent 532ebd82d7
commit 33bc6a935c
4 changed files with 170 additions and 59 deletions
+6 -16
View File
@@ -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<HTMLDivElement>(null);
useScrollMemory("projects", pageRef, !isLoading);
return (
<div className="p-8 space-y-6 max-w-[1600px] mx-auto">
<div ref={pageRef} className="p-8 space-y-6 max-w-[1600px] mx-auto">
<div className="flex items-center justify-between gap-4 mb-8">
<div>
<h1 className="text-3xl font-bold text-white">Projects</h1>
@@ -108,7 +98,7 @@ export default function ProjectsPage() {
// long description does not make its whole row taller than the rest.
<div className="grid auto-rows-fr grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{visibleProjects.map((project: any) => (
<div key={project.id} onClick={saveScroll} className="h-full">
<div key={project.id} className="h-full">
<ProjectCard project={project} />
</div>
))}
@@ -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<string, { label: string; color: string; icon: React.ElementType }> = {
WAITING: { label: "Waiting", color: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", icon: Clock },
@@ -780,7 +799,6 @@ function ShotTable({
<Link
href={`/projects/${projectId}/shots/${shot.id}`}
className="font-mono text-sm text-white hover:text-blue-400 transition-colors"
onClick={() => sessionStorage.setItem(SCROLL_KEY, String(window.scrollY))}
>
{shot.shotCode}
</Link>
@@ -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<Set<string>>(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<HTMLDivElement>(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<string>) => {
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 (
<div className="p-8 space-y-6 max-w-[1600px] mx-auto">
<div ref={pageRef} className="p-8 space-y-6 max-w-[1600px] mx-auto">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-white">Shot Status</h1>
+9 -11
View File
@@ -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<string, string> = {
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<string>('');
const [episodeFilter, setEpisodeFilter] = useState<string | null>(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<HTMLDivElement>(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 (
<div className="min-h-screen bg-zinc-950 text-white">
<div ref={pageRef} className="min-h-screen bg-zinc-950 text-white">
<header className="border-b border-zinc-800 bg-zinc-900">
<div className="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
{/* Logo */}
+91
View File
@@ -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 <main>
* (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<HTMLElement | null>,
ready: boolean
) {
const restoredKey = useRef<string | null>(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 <html>.
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;
}