mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-19 17:02:59 +00:00
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:
parent
ade33195cb
commit
7e35f3c0a2
4 changed files with 281 additions and 204 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue