feat(home): delete-folder confirm, view preference cookie, show-more batching

Folder removal now confirms with copy stating portfolios are unfiled,
not deleted. Grid/list preference persists in a cookie read by the
server component, so the chosen view renders on first paint. Long lists
render the first 24 rows with a show-more button; the reveal count
resets when the view or search changes, and starred float keeps pinned
work on the first screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-07 16:32:19 +00:00
parent cfa181f3c7
commit 1940f02cf3
2 changed files with 67 additions and 9 deletions

View file

@ -22,6 +22,10 @@ import NewPortfolioModal from "./NewPortfolioModal";
const HOME_KEY = ["portfolio-home"];
// "Show more" batch size: bounds the initial wall without pagination chrome.
// Divisible by 2 and 3 so grid rows stay full at every breakpoint.
const PAGE_SIZE = 24;
async function fetchHome(): Promise<HomeData> {
const res = await fetch("/api/me/portfolio-home");
if (!res.ok) throw new Error("Failed to load portfolios");
@ -68,14 +72,32 @@ function useOptimisticHomeMutation<V>(
});
}
export default function PortfolioHome() {
export default function PortfolioHome({
initialDisplay = "grid",
}: {
initialDisplay?: "grid" | "list";
}) {
const queryClient = useQueryClient();
const { data, isLoading, isError, refetch } = useQuery(HOME_KEY, fetchHome);
const [view, setView] = useState<RailView>("all");
const [display, setDisplay] = useState<"grid" | "list">("grid");
const [display, setDisplay] = useState<"grid" | "list">(initialDisplay);
const chooseDisplay = (value: "grid" | "list") => {
setDisplay(value);
document.cookie = `home-display=${value}; path=/; max-age=31536000; samesite=lax`;
};
const [sort, setSort] = useState<HomeSort>("updated");
const [query, setQuery] = useState("");
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
// Changing what's listed resets how much of it is revealed.
const chooseView = (next: RailView) => {
setView(next);
setVisibleCount(PAGE_SIZE);
};
const chooseQuery = (next: string) => {
setQuery(next);
setVisibleCount(PAGE_SIZE);
};
const [announcement, setAnnouncement] = useState("");
const [newPortfolioOpen, setNewPortfolioOpen] = useState(false);
@ -209,6 +231,9 @@ export default function PortfolioHome() {
);
}
const visibleRows = rows.slice(0, visibleCount);
const hiddenCount = rows.length - visibleRows.length;
const viewTitle =
view === "all"
? "All portfolios"
@ -256,7 +281,7 @@ export default function PortfolioHome() {
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
onChange={(e) => chooseQuery(e.target.value)}
placeholder="Search portfolios"
aria-label="Search portfolios by name, status, goal or folder"
className="w-60 rounded-lg border border-[#c3ccd8] bg-white py-2 pl-8 pr-3 text-sm"
@ -290,7 +315,7 @@ export default function PortfolioHome() {
aria-pressed={display === value}
aria-label={label}
title={label}
onClick={() => setDisplay(value)}
onClick={() => chooseDisplay(value)}
className={`grid place-items-center px-2.5 py-2 ${
display === value
? "bg-brandblue text-white"
@ -326,7 +351,7 @@ export default function PortfolioHome() {
(p) => folderIdOf(p.id) === folderId && needsAttention(p),
).length
}
onSelect={setView}
onSelect={chooseView}
onReorder={(orderedIds) => {
reorderMutation.mutate(orderedIds);
setAnnouncement("Folder order saved");
@ -336,6 +361,17 @@ export default function PortfolioHome() {
renameMutation.mutate({ folderId, name })
}
onDelete={(folderId) => {
const folder = folderById.get(folderId);
const count = data.portfolios.filter(
(p) => folderIdOf(p.id) === folderId,
).length;
const inside =
count === 0
? "It is empty."
: `Its ${count} portfolio${count === 1 ? "" : "s"} will be unfiled, not deleted.`;
if (!window.confirm(`Remove folder “${folder?.name}”? ${inside}`)) {
return;
}
deleteMutation.mutate(folderId);
setAnnouncement("Folder removed — its portfolios are unfiled");
}}
@ -386,7 +422,7 @@ export default function PortfolioHome() {
<b className="text-gray-900">{query.trim()}</b>.{" "}
<button
type="button"
onClick={() => setQuery("")}
onClick={() => chooseQuery("")}
className="font-semibold text-brandmidblue hover:underline"
>
Clear search
@ -421,7 +457,7 @@ export default function PortfolioHome() {
)
) : display === "grid" ? (
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 xl:grid-cols-3">
{rows.map((portfolio) => (
{visibleRows.map((portfolio) => (
<PortfolioGridCard
key={portfolio.id}
portfolio={portfolio}
@ -436,13 +472,28 @@ export default function PortfolioHome() {
</div>
) : (
<PortfolioTable
portfolios={rows}
portfolios={visibleRows}
actions={cardActions}
folderNameOf={
view === "all" || view === "starred" ? folderNameOf : () => null
}
/>
)}
{hiddenCount > 0 && (
<div className="mt-5 flex flex-col items-center gap-1">
<button
type="button"
onClick={() => setVisibleCount((count) => count + PAGE_SIZE)}
className="rounded-lg border border-[#c3ccd8] bg-white px-5 py-2 text-sm font-semibold text-gray-900 hover:border-brandmidblue hover:text-brandmidblue"
>
Show {Math.min(PAGE_SIZE, hiddenCount)} more
</button>
<span className="text-xs text-gray-500">
Showing <span className="tabular-nums">{visibleRows.length}</span>{" "}
of <span className="tabular-nums">{rows.length}</span>
</span>
</div>
)}
</main>
</div>

View file

@ -1,5 +1,6 @@
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getServerSession } from "next-auth";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import PortfolioHome from "../components/home/PortfolioHome";
@ -10,6 +11,12 @@ const Home = async () => {
redirect("/");
}
return <PortfolioHome />;
// Device-level display preference travels as a cookie so the server renders
// the chosen view on first paint (no client-side flicker).
const cookieStore = await cookies();
const initialDisplay =
cookieStore.get("home-display")?.value === "list" ? "list" : "grid";
return <PortfolioHome initialDisplay={initialDisplay} />;
};
export default Home;