feat(home): Headless UI menus, drag-to-file onto rail, folder view in URL

Card and folder menus move to Headless UI v2 (Menu / Popover with
anchor positioning): panels portal to the top layer so the list view
can't clip them, and Esc, outside-click close, arrow-key nav, and menu
semantics come from the primitive — replaces the details/summary menus
and the fixed-position workaround. The folder popover hosts the
two-step remove confirm. Cards and table rows are draggable onto rail
targets (folder = file, Starred = star, All portfolios = unfile) with
drop highlighting; folder-reorder drag is type-guarded from card drags.
Selected rail view persists in the URL via replaceState (?folder=id /
?view=starred), read by the server component; unknown folder ids fall
back to All, and deleting the folder being viewed returns to All.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-07 17:20:47 +00:00
parent ade33195cb
commit 7e35f3c0a2
4 changed files with 281 additions and 204 deletions

View file

@ -1,11 +1,15 @@
"use client";
import { useState } from "react";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { MoreVertical, Plus, Star } from "lucide-react";
import type { FolderWire } from "@/app/lib/portfolioHomeView";
export type RailView = "all" | "starred" | { folderId: string };
// Cards set this dataTransfer type on drag; rail items accept it as a drop.
const PORTFOLIO_DRAG_TYPE = "application/x-portfolio";
export type FolderRailProps = {
folders: FolderWire[]; // already ordered by position
view: RailView;
@ -19,6 +23,9 @@ export type FolderRailProps = {
onCreate: (name: string) => void;
onRename: (folderId: string, name: string) => void;
onDelete: (folderId: string) => void;
// Drag-to-file: a card dropped on a folder / Starred / All portfolios.
onDropToFolder: (portfolioId: string, folderId: string | null) => void;
onDropToStarred: (portfolioId: string) => void;
};
function AttentionBadge({ count }: { count: number }) {
@ -39,24 +46,25 @@ function railItemClasses(active: boolean) {
}`;
}
const dropHighlight = "ring-2 ring-inset ring-brandmidblue rounded-lg";
export default function FolderRail(props: FolderRailProps) {
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
const [creating, setCreating] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
// Two-step destructive confirm lives inside the menu panel, not a dialog:
// Two-step destructive confirm lives inside the menu popover, not a dialog:
// removal only unfiles portfolios, so OS-modal weight isn't warranted.
const [confirmingId, setConfirmingId] = useState<string | null>(null);
// Which rail target a dragged card is hovering: folder id, "starred",
// "all", or null.
const [cardDropTarget, setCardDropTarget] = useState<string | null>(null);
const isFolderView = (folderId: string) =>
typeof props.view === "object" && props.view.folderId === folderId;
const commitDrop = () => {
if (
dragIndex !== null &&
dropIndex !== null &&
dropIndex !== dragIndex
) {
if (dragIndex !== null && dropIndex !== null && dropIndex !== dragIndex) {
const ids = props.folders.map((f) => f.id);
const [moved] = ids.splice(dragIndex, 1);
ids.splice(dropIndex, 0, moved);
@ -66,6 +74,27 @@ export default function FolderRail(props: FolderRailProps) {
setDropIndex(null);
};
const cardDropHandlers = (
targetKey: string,
onDrop: (portfolioId: string) => void,
) => ({
onDragOver: (e: React.DragEvent) => {
if (!e.dataTransfer.types.includes(PORTFOLIO_DRAG_TYPE)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setCardDropTarget(targetKey);
},
onDragLeave: () =>
setCardDropTarget((current) => (current === targetKey ? null : current)),
onDrop: (e: React.DragEvent) => {
const portfolioId = e.dataTransfer.getData(PORTFOLIO_DRAG_TYPE);
if (!portfolioId) return;
e.preventDefault();
setCardDropTarget(null);
onDrop(portfolioId);
},
});
const nameForm = (
defaultValue: string,
onSubmit: (name: string) => void,
@ -94,14 +123,26 @@ export default function FolderRail(props: FolderRailProps) {
);
return (
<nav aria-label="Portfolio folders" className="w-full shrink-0 lg:sticky lg:top-5 lg:w-[230px]">
<nav
aria-label="Portfolio folders"
className="w-full shrink-0 lg:sticky lg:top-5 lg:w-[230px]"
>
<div className="flex flex-col gap-0.5">
<button
type="button"
onClick={() => props.onSelect("starred")}
className={railItemClasses(props.view === "starred")}
{...cardDropHandlers("starred", props.onDropToStarred)}
className={`${railItemClasses(props.view === "starred")} ${
cardDropTarget === "starred" ? dropHighlight : ""
}`}
>
<Star size={14} className="shrink-0" fill="#f1bb06" strokeWidth={0} aria-hidden />
<Star
size={14}
className="shrink-0"
fill="#f1bb06"
strokeWidth={0}
aria-hidden
/>
<span className="flex-1 truncate">Starred</span>
<span className="text-[13px] text-gray-600 tabular-nums">
{props.starredCount}
@ -110,7 +151,12 @@ export default function FolderRail(props: FolderRailProps) {
<button
type="button"
onClick={() => props.onSelect("all")}
className={railItemClasses(props.view === "all")}
{...cardDropHandlers("all", (portfolioId) =>
props.onDropToFolder(portfolioId, null),
)}
className={`${railItemClasses(props.view === "all")} ${
cardDropTarget === "all" ? dropHighlight : ""
}`}
>
<span className="flex-1 truncate">All portfolios</span>
<AttentionBadge count={props.attentionTotal} />
@ -138,12 +184,20 @@ export default function FolderRail(props: FolderRailProps) {
<li
key={folder.id}
draggable={editingId !== folder.id}
onDragStart={() => setDragIndex(index)}
onDragOver={(e) => {
e.preventDefault();
if (dragIndex !== null) setDropIndex(index);
onDragStart={(e) => {
// A card drag bubbling up must not start a folder reorder.
if (e.dataTransfer.types.includes(PORTFOLIO_DRAG_TYPE)) return;
setDragIndex(index);
}}
onDragOver={(e) => {
if (dragIndex !== null) {
e.preventDefault();
setDropIndex(index);
}
}}
onDrop={() => {
if (dragIndex !== null) commitDrop();
}}
onDrop={commitDrop}
onDragEnd={() => {
setDragIndex(null);
setDropIndex(null);
@ -169,7 +223,12 @@ export default function FolderRail(props: FolderRailProps) {
<button
type="button"
onClick={() => props.onSelect({ folderId: folder.id })}
className={railItemClasses(isFolderView(folder.id))}
{...cardDropHandlers(folder.id, (portfolioId) =>
props.onDropToFolder(portfolioId, folder.id),
)}
className={`${railItemClasses(isFolderView(folder.id))} ${
cardDropTarget === folder.id ? dropHighlight : ""
}`}
>
<span className="flex-1 truncate">{folder.name}</span>
<AttentionBadge count={props.attentionOf(folder.id)} />
@ -177,113 +236,105 @@ export default function FolderRail(props: FolderRailProps) {
{props.countOf(folder.id)}
</span>
</button>
<details
className="relative shrink-0"
onToggle={(e) => {
if (!e.currentTarget.open) setConfirmingId(null);
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
const details = e.currentTarget;
details.removeAttribute("open");
setConfirmingId(null);
details.querySelector<HTMLElement>("summary")?.focus();
}
}}
>
<summary
<Popover className="shrink-0">
<PopoverButton
aria-label={`Folder options for ${folder.name}`}
className="grid cursor-pointer list-none place-items-center rounded-lg p-1.5 text-gray-500 opacity-0 hover:bg-gray-100 focus-visible:opacity-100 group-hover:opacity-100 [&::-webkit-details-marker]:hidden [details[open]>&]:opacity-100"
onClick={() => setConfirmingId(null)}
className="grid place-items-center rounded-lg p-1.5 text-gray-500 opacity-0 hover:bg-gray-100 focus-visible:opacity-100 group-hover:opacity-100 data-[open]:opacity-100 data-[open]:bg-gray-100"
>
<MoreVertical size={15} aria-hidden />
</summary>
<div className="absolute right-0 top-full z-20 mt-1 min-w-[150px] rounded-lg border border-gray-200 bg-white p-1 shadow-md">
{confirmingId === folder.id ? (
<div className="w-56 p-2">
<p className="mb-2 text-sm font-semibold text-gray-900">
Remove {folder.name}?
</p>
<p className="mb-3 text-[13px] text-gray-600">
{props.countOf(folder.id) === 0
? "It's empty — no portfolios are affected."
: `Its ${props.countOf(folder.id)} portfolio${
props.countOf(folder.id) === 1 ? "" : "s"
} will be unfiled, not deleted.`}
</p>
<div className="flex gap-2">
<button
type="button"
autoFocus
onClick={(e) => {
e.currentTarget
.closest("details")
?.removeAttribute("open");
setConfirmingId(null);
props.onDelete(folder.id);
}}
className="rounded-md bg-red-700 px-3 py-1.5 text-sm font-semibold text-white hover:bg-red-800"
>
Remove folder
</button>
<button
type="button"
onClick={() => setConfirmingId(null)}
className="rounded-md px-3 py-1.5 text-sm font-semibold text-gray-700 hover:bg-gray-100"
>
Cancel
</button>
</PopoverButton>
<PopoverPanel
focus
anchor={{ to: "bottom end", gap: 4 }}
className="z-20 min-w-[150px] rounded-lg border border-gray-200 bg-white p-1 shadow-md focus:outline-none"
>
{({ close }) =>
confirmingId === folder.id ? (
<div className="w-56 p-2">
<p className="mb-2 text-sm font-semibold text-gray-900">
Remove {folder.name}?
</p>
<p className="mb-3 text-[13px] text-gray-600">
{props.countOf(folder.id) === 0
? "It's empty — no portfolios are affected."
: `Its ${props.countOf(folder.id)} portfolio${
props.countOf(folder.id) === 1 ? "" : "s"
} will be unfiled, not deleted.`}
</p>
<div className="flex gap-2">
<button
type="button"
autoFocus
onClick={() => {
close();
setConfirmingId(null);
props.onDelete(folder.id);
}}
className="rounded-md bg-red-700 px-3 py-1.5 text-sm font-semibold text-white hover:bg-red-800"
>
Remove folder
</button>
<button
type="button"
onClick={() => setConfirmingId(null)}
className="rounded-md px-3 py-1.5 text-sm font-semibold text-gray-700 hover:bg-gray-100"
>
Cancel
</button>
</div>
</div>
</div>
) : (
<>
{(
[
["Move up", index > 0, -1],
["Move down", index < props.folders.length - 1, 1],
] as const
).map(([label, enabled, delta]) => (
) : (
<>
{(
[
["Move up", index > 0, -1],
[
"Move down",
index < props.folders.length - 1,
1,
],
] as const
).map(([label, enabled, delta]) => (
<button
key={label}
type="button"
disabled={!enabled}
onClick={() => {
close();
const ids = props.folders.map((f) => f.id);
const [moved] = ids.splice(index, 1);
ids.splice(index + delta, 0, moved);
props.onReorder(ids);
}}
className="block w-full rounded-md px-2.5 py-1.5 text-left text-sm hover:bg-gray-50 disabled:cursor-default disabled:text-gray-400 disabled:hover:bg-transparent"
>
{label}
</button>
))}
<div className="my-1 border-t border-gray-200" />
<button
key={label}
type="button"
disabled={!enabled}
onClick={(e) => {
e.currentTarget
.closest("details")
?.removeAttribute("open");
const ids = props.folders.map((f) => f.id);
const [moved] = ids.splice(index, 1);
ids.splice(index + delta, 0, moved);
props.onReorder(ids);
onClick={() => {
close();
setEditingId(folder.id);
}}
className="block w-full rounded-md px-2.5 py-1.5 text-left text-sm hover:bg-gray-50 disabled:cursor-default disabled:text-gray-400 disabled:hover:bg-transparent"
className="block w-full rounded-md px-2.5 py-1.5 text-left text-sm hover:bg-gray-50"
>
{label}
Rename
</button>
))}
<div className="my-1 border-t border-gray-200" />
<button
type="button"
onClick={(e) => {
e.currentTarget
.closest("details")
?.removeAttribute("open");
setEditingId(folder.id);
}}
className="block w-full rounded-md px-2.5 py-1.5 text-left text-sm hover:bg-gray-50"
>
Rename
</button>
<button
type="button"
onClick={() => setConfirmingId(folder.id)}
className="block w-full rounded-md px-2.5 py-1.5 text-left text-sm text-red-700 hover:bg-red-50"
>
Remove folder
</button>
</>
)}
</div>
</details>
<button
type="button"
onClick={() => setConfirmingId(folder.id)}
className="block w-full rounded-md px-2.5 py-1.5 text-left text-sm text-red-700 hover:bg-red-50"
>
Remove folder
</button>
</>
)
}
</PopoverPanel>
</Popover>
</>
)}
</li>
@ -296,8 +347,8 @@ export default function FolderRail(props: FolderRailProps) {
</ul>
<p className="hidden px-2.5 pt-2 text-xs text-gray-500 lg:block">
Folders are personal to you teammates organise their own. Drag to
reorder.
Folders are personal to you teammates organise their own. Drag
folders to reorder, or drop a portfolio card onto a folder to file it.
</p>
</nav>
);

View file

@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { MoreVertical, Star } from "lucide-react";
import {
formatMoney,
@ -70,97 +70,62 @@ function StarButton({
);
}
// Headless UI Menu: the anchor prop portals the panel to the top layer, so
// the list view's scroll container can't clip it; Esc, outside-click, and
// arrow-key navigation come with the primitive.
function CardMenu({
portfolio,
actions,
openUpward,
escapeClipping,
}: {
portfolio: PortfolioWire;
actions: CardActions;
openUpward?: boolean;
// Inside the list view's overflow-x-auto container an absolute panel gets
// clipped; fixed positioning (measured on open) escapes it.
escapeClipping?: boolean;
}) {
const currentFolderId = actions.folderIdOf(portfolio.id);
const [fixedPosition, setFixedPosition] = useState<{
top: number;
right: number;
} | null>(null);
const itemClasses =
"flex w-full items-center justify-between gap-3 rounded-md px-2.5 py-1.5 text-left text-sm data-[focus]:bg-gray-50";
return (
<details
className="relative"
onToggle={(e) => {
if (!escapeClipping) return;
if (!e.currentTarget.open) {
setFixedPosition(null);
return;
}
const rect = e.currentTarget
.querySelector("summary")!
.getBoundingClientRect();
setFixedPosition({
top: rect.bottom + 4,
right: window.innerWidth - rect.right,
});
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
const details = e.currentTarget;
details.removeAttribute("open");
details.querySelector<HTMLElement>("summary")?.focus();
}
}}
>
<summary
<Menu>
<MenuButton
aria-label={`Actions for ${portfolio.name}`}
className="grid cursor-pointer list-none place-items-center rounded-lg p-1 text-gray-500 hover:bg-gray-100 hover:text-brandblue [&::-webkit-details-marker]:hidden"
className="grid place-items-center rounded-lg p-1 text-gray-500 hover:bg-gray-100 hover:text-brandblue data-[open]:bg-gray-100 data-[open]:text-brandblue"
>
<MoreVertical size={16} aria-hidden />
</summary>
<div
style={fixedPosition ?? undefined}
className={`z-20 min-w-[160px] rounded-lg border border-gray-200 bg-white p-1 shadow-md ${
fixedPosition
? "fixed"
: `absolute right-0 ${openUpward ? "bottom-full mb-1" : "top-full mt-1"}`
}`}
</MenuButton>
<MenuItems
anchor={{ to: "bottom end", gap: 4 }}
className="z-20 min-w-[160px] rounded-lg border border-gray-200 bg-white p-1 shadow-md focus:outline-none"
>
<div className="px-2.5 py-1 text-[11px] font-semibold text-gray-500">
Move to folder
</div>
{actions.folders.map((folder) => (
<MenuItem key={folder.id}>
<button
type="button"
onClick={() => actions.onMoveToFolder(portfolio, folder.id)}
className={itemClasses}
>
<span className="truncate">{folder.name}</span>
{currentFolderId === folder.id && (
<span className="text-brandmidblue" aria-hidden></span>
)}
</button>
</MenuItem>
))}
<MenuItem>
<button
key={folder.id}
type="button"
onClick={(e) => {
e.currentTarget.closest("details")?.removeAttribute("open");
actions.onMoveToFolder(portfolio, folder.id);
}}
className="flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-left text-sm hover:bg-gray-50"
onClick={() => actions.onMoveToFolder(portfolio, null)}
className={itemClasses}
>
{folder.name}
{currentFolderId === folder.id && (
No folder
{currentFolderId === null && (
<span className="text-brandmidblue" aria-hidden></span>
)}
</button>
))}
<button
type="button"
onClick={(e) => {
e.currentTarget.closest("details")?.removeAttribute("open");
actions.onMoveToFolder(portfolio, null);
}}
className="flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-left text-sm hover:bg-gray-50"
>
No folder
{currentFolderId === null && (
<span className="text-brandmidblue" aria-hidden></span>
)}
</button>
</div>
</details>
</MenuItem>
</MenuItems>
</Menu>
);
}
@ -221,7 +186,14 @@ export function PortfolioGridCard({
folderName?: string | null;
}) {
return (
<article className="flex flex-col gap-2.5 rounded-xl border border-[#e2e7ee] bg-white p-4 transition-colors hover:border-[#c3ccd8]">
<article
draggable
onDragStart={(e) => {
e.dataTransfer.setData("application/x-portfolio", portfolio.id);
e.dataTransfer.effectAllowed = "move";
}}
className="flex flex-col gap-2.5 rounded-xl border border-[#e2e7ee] bg-white p-4 transition-colors hover:border-[#c3ccd8]"
>
<div className="flex items-start justify-between gap-2">
<Link
href={`/portfolio/${portfolio.id}`}
@ -251,7 +223,7 @@ export function PortfolioGridCard({
{folderName ? `${folderName} · ` : ""}
Updated {formatUpdatedAgo(portfolio.updatedAt, new Date())}
</span>
<CardMenu portfolio={portfolio} actions={actions} openUpward />
<CardMenu portfolio={portfolio} actions={actions} />
</div>
</article>
);
@ -285,6 +257,11 @@ export function PortfolioTable({
{portfolios.map((portfolio) => (
<tr
key={portfolio.id}
draggable
onDragStart={(e) => {
e.dataTransfer.setData("application/x-portfolio", portfolio.id);
e.dataTransfer.effectAllowed = "move";
}}
className="border-b border-[#eef1f6] last:border-b-0 hover:bg-[#f8fafc]"
>
<td className="px-3 py-2">
@ -317,7 +294,7 @@ export function PortfolioTable({
{formatUpdatedAgo(portfolio.updatedAt, new Date())}
</td>
<td className="px-3 py-2">
<CardMenu portfolio={portfolio} actions={actions} escapeClipping />
<CardMenu portfolio={portfolio} actions={actions} />
</td>
</tr>
))}

View file

@ -72,15 +72,23 @@ function useOptimisticHomeMutation<V>(
});
}
function viewToUrl(view: RailView): string {
if (view === "starred") return "/home?view=starred";
if (typeof view === "object") return `/home?folder=${view.folderId}`;
return "/home";
}
export default function PortfolioHome({
initialDisplay = "grid",
initialView = "all",
}: {
initialDisplay?: "grid" | "list";
initialView?: RailView;
}) {
const queryClient = useQueryClient();
const { data, isLoading, isError, refetch } = useQuery(HOME_KEY, fetchHome);
const [view, setView] = useState<RailView>("all");
const [view, setView] = useState<RailView>(initialView);
const [display, setDisplay] = useState<"grid" | "list">(initialDisplay);
const chooseDisplay = (value: "grid" | "list") => {
setDisplay(value);
@ -89,10 +97,13 @@ export default function PortfolioHome({
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.
// Changing what's listed resets how much of it is revealed. The selection
// also lands in the URL (replaceState — no navigation) so refresh and
// sharing keep the folder context.
const chooseView = (next: RailView) => {
setView(next);
setVisibleCount(PAGE_SIZE);
window.history.replaceState(null, "", viewToUrl(next));
};
const chooseQuery = (next: string) => {
setQuery(next);
@ -206,6 +217,11 @@ export default function PortfolioHome({
(a, b) => a.position - b.position,
);
// A folder id from the URL or a just-deleted folder may not exist; fall
// back to All rather than rendering a ghost view.
const activeView: RailView =
typeof view === "object" && !folderById.has(view.folderId) ? "all" : view;
const isStarred = (id: string) => configOf.get(id)?.starredAt != null;
const folderIdOf = (id: string) => configOf.get(id)?.folderId ?? null;
const folderNameOf = (id: string) => {
@ -216,18 +232,18 @@ export default function PortfolioHome({
statusDisplay(p.status).needsAttention;
const inView = (p: PortfolioWire) =>
view === "all"
activeView === "all"
? true
: view === "starred"
: activeView === "starred"
? isStarred(p.id)
: folderIdOf(p.id) === view.folderId;
: folderIdOf(p.id) === activeView.folderId;
const scope = data.portfolios.filter(inView);
const filtered = scope.filter((p) =>
matchesSearch({ ...p, folderName: folderNameOf(p.id) }, query),
);
let rows = sortPortfolios(filtered, sort);
if (view === "starred") {
if (activeView === "starred") {
rows = [...rows].sort(
(a, b) =>
new Date(configOf.get(b.id)!.starredAt!).getTime() -
@ -243,11 +259,11 @@ export default function PortfolioHome({
const hiddenCount = rows.length - visibleRows.length;
const viewTitle =
view === "all"
activeView === "all"
? "All portfolios"
: view === "starred"
: activeView === "starred"
? "Starred"
: (folderById.get(view.folderId)?.name ?? "Folder");
: (folderById.get(activeView.folderId)?.name ?? "Folder");
const attentionCount = rows.filter(needsAttention).length;
const propertiesSum = rows.reduce(
(sum, p) => sum + (p.numberOfProperties ?? 0),
@ -360,7 +376,7 @@ export default function PortfolioHome({
<div className="flex flex-col gap-4 lg:flex-row lg:gap-7">
<FolderRail
folders={orderedFolders}
view={view}
view={activeView}
totalCount={data.portfolios.length}
starredCount={data.portfolios.filter((p) => isStarred(p.id)).length}
attentionTotal={data.portfolios.filter(needsAttention).length}
@ -378,10 +394,29 @@ export default function PortfolioHome({
setAnnouncement("Folder order saved");
}}
onCreate={(name) => createMutation.mutate(name)}
onDropToFolder={(portfolioId, folderId) => {
const portfolio = data.portfolios.find((p) => p.id === portfolioId);
if (!portfolio || folderIdOf(portfolioId) === folderId) return;
configMutation.mutate({ portfolioId, change: { folderId } });
setAnnouncement(
folderId
? `Moved ${portfolio.name} to ${folderById.get(folderId)?.name}`
: `Removed ${portfolio.name} from its folder`,
);
}}
onDropToStarred={(portfolioId) => {
const portfolio = data.portfolios.find((p) => p.id === portfolioId);
if (!portfolio || isStarred(portfolioId)) return;
configMutation.mutate({ portfolioId, change: { starred: true } });
setAnnouncement(`Starred ${portfolio.name}`);
}}
onRename={(folderId, name) =>
renameMutation.mutate({ folderId, name })
}
onDelete={(folderId) => {
if (typeof activeView === "object" && activeView.folderId === folderId) {
chooseView("all");
}
deleteMutation.mutate(folderId);
setAnnouncement("Folder removed — its portfolios are unfiled");
}}
@ -440,7 +475,7 @@ export default function PortfolioHome({
</div>
) : (
<div className="rounded-xl border border-dashed border-[#c3ccd8] px-5 py-6 text-sm text-gray-500">
{view === "starred" ? (
{activeView === "starred" ? (
<>
<b className="font-semibold text-gray-900">
Nothing starred yet.
@ -448,7 +483,7 @@ export default function PortfolioHome({
Star a portfolio to keep your day-to-day work one click
away.
</>
) : view === "all" ? (
) : activeView === "all" ? (
<>
<b className="font-semibold text-gray-900">
No portfolios yet.
@ -473,7 +508,7 @@ export default function PortfolioHome({
portfolio={portfolio}
actions={cardActions}
folderName={
view === "all" || view === "starred"
activeView === "all" || activeView === "starred"
? folderNameOf(portfolio.id)
: null
}
@ -485,7 +520,7 @@ export default function PortfolioHome({
portfolios={visibleRows}
actions={cardActions}
folderNameOf={
view === "all" || view === "starred" ? folderNameOf : () => null
activeView === "all" || activeView === "starred" ? folderNameOf : () => null
}
/>
)}

View file

@ -4,7 +4,9 @@ import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import PortfolioHome from "../components/home/PortfolioHome";
const Home = async () => {
const Home = async (props: {
searchParams: Promise<{ folder?: string; view?: string }>;
}) => {
const user = await getServerSession(AuthOptions);
if (!user?.user) {
@ -17,6 +19,18 @@ const Home = async () => {
const initialDisplay =
cookieStore.get("home-display")?.value === "list" ? "list" : "grid";
return <PortfolioHome initialDisplay={initialDisplay} />;
// The selected rail view survives refresh via the URL; an unknown folder id
// falls back to All client-side once data loads.
const searchParams = await props.searchParams;
const initialView =
searchParams.view === "starred"
? ("starred" as const)
: /^\d+$/.test(searchParams.folder ?? "")
? { folderId: searchParams.folder! }
: ("all" as const);
return (
<PortfolioHome initialDisplay={initialDisplay} initialView={initialView} />
);
};
export default Home;