mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-15 00:04:58 +02:00
* fix(ui): replace the text-transparency ladder with solid tones (MUL-5452) Hierarchy was being expressed with transparency: 152 call sites of text-muted-foreground/30..80, 26 of text-foreground/60..90, plus a handful on destructive and current, and a few written as a standalone opacity-* utility instead of a slash alpha. On light surfaces every muted variant failed WCAG AA - /80 reached only 3.78:1 and /40 sat at 1.80:1, below even the 3:1 floor for non-text - because the palette had no step below --muted-foreground, so transparency was the only tool for 'quieter than muted'. The palette now has that step, and it is deliberately non-text: --faint-foreground clears 3:1 (WCAG 1.4.11) on every surface for icons, chevrons, separator glyphs and empty-cell em dashes. There is no room for a third readable text tone - AA caps a lighter text tone 0.018 L away from muted - so text keeps exactly one floor, --muted-foreground. Also fixes text-destructive/70 on a cron error message, which was 3.61:1. This branch changes zero font sizes. The sub-12px half of the issue is MUL-5451's (#6136); keeping the two apart is what makes this one reviewable on its own after #6108 was reverted. apps/web/app/text-contrast.test.ts replaces muted-foreground-contrast.test.ts rather than sitting beside it. It recomputes the floors from tokens.css instead of hard-coding ratios, and fails the build on all four ways to spell the defect: /70, /[0.5], /[50%], and a detached opacity-* in the same class string. Transparency behind hover/focus/disabled stays allowed - the resting state carries the contrast obligation and it is solid. Co-authored-by: multica-agent <github@multica.ai> * fix(ui): correlate transparency across a whole class expression Review found two ways past the guard, both real. A per-literal check cannot see cn("… text-muted-foreground", suppressed && "opacity-60") - one element wearing a colour in one argument and a dim in the next. That split shape is the common one, and it was hiding live violations: the comment trigger chips dimmed aria-pressed label text to 2.55:1 while the sweep reported clean. The second was my own exemption. Accepting any state word within 80 characters let "text-muted-foreground hover:text-foreground opacity-50" through, because the hover: belongs to the colour, not to the opacity. The detector now correlates across a whole cn() call or template literal, splits it into segments that each carry their own condition, and exempts only on the variant prefix the opacity utility itself carries or on the condition governing its segment. Segment splitting is what keeps ${disabled ? "opacity-60" : ""} exempt while flagging its neighbours. Fixed what that surfaced: three trigger-chip controls (the suppressed state is already carried by the avatar's own grayscale, the sentence wording and a solid muted step), a disabled-skill icon chip, and the diff gutter marker. tab-bar's isDragging is exempt - a drag ghost is an in-flight gesture, the same category as :active. The detector now has its own table of thirteen cases. Every hole so far has been silent, so the shapes it must and must not catch are pinned next to the reason each one exists. Co-authored-by: multica-agent <github@multica.ai> * test(ui): cover the faint tone in the cn() merge regression test text-faint-foreground is a new text-<x> class, which is the exact shape that silently broke the sidebar labels in #6108: tailwind-merge cannot tell a size from a colour and drops one of them. The token does resolve correctly today - verified both orders against a size step and against another colour - but the test that exists to catch this was not covering it, so the guarantee rested on nothing. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Lambda <agent@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
1308 lines
48 KiB
TypeScript
1308 lines
48 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useMemo, useState, type MouseEvent } from "react";
|
|
import {
|
|
ArrowDown,
|
|
ArrowUp,
|
|
ChevronDown,
|
|
Filter,
|
|
FolderKanban,
|
|
LayoutGrid,
|
|
MoreHorizontal,
|
|
Pin,
|
|
PinOff,
|
|
Plus,
|
|
Rows3,
|
|
Search,
|
|
Trash2,
|
|
X,
|
|
} from "lucide-react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { toast } from "sonner";
|
|
import {
|
|
projectListOptions,
|
|
useUpdateProject,
|
|
useDeleteProject,
|
|
useProjectViewStore,
|
|
type ProjectColumnKey,
|
|
type ProjectListFilters,
|
|
type ProjectSortField,
|
|
type ProjectViewMode,
|
|
} from "@multica/core/projects";
|
|
import {
|
|
pinListOptions,
|
|
useCreatePin,
|
|
useDeletePin,
|
|
} from "@multica/core/pins";
|
|
import { useWorkspaceId } from "@multica/core/hooks";
|
|
import { useWorkspacePaths } from "@multica/core/paths";
|
|
import { useAuthStore } from "@multica/core/auth";
|
|
import { useActorName } from "@multica/core/workspace/hooks";
|
|
import { memberListOptions } from "@multica/core/workspace/queries";
|
|
import { useModalStore } from "@multica/core/modals";
|
|
import { AppLink, useRowLink } from "../../navigation";
|
|
import { ActorAvatar } from "../../common/actor-avatar";
|
|
import { FILTER_ITEM_CLASS, HoverCheck } from "../../common/hover-check";
|
|
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
|
import { Button } from "@multica/ui/components/ui/button";
|
|
import { Checkbox } from "@multica/ui/components/ui/checkbox";
|
|
import { Input } from "@multica/ui/components/ui/input";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@multica/ui/components/ui/dialog";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuCheckboxItem,
|
|
DropdownMenuContent,
|
|
DropdownMenuGroup,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuRadioGroup,
|
|
DropdownMenuRadioItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuSub,
|
|
DropdownMenuSubContent,
|
|
DropdownMenuSubTrigger,
|
|
DropdownMenuTrigger,
|
|
} from "@multica/ui/components/ui/dropdown-menu";
|
|
import {
|
|
ListGrid,
|
|
ListGridCell,
|
|
ListGridHeader,
|
|
ListGridHeaderCell,
|
|
ListGridRow,
|
|
LIST_GRID_BOTTOM_CLEARANCE,
|
|
type ListGridSortDirection,
|
|
} from "@multica/ui/components/ui/list-grid";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@multica/ui/components/ui/popover";
|
|
import { Switch } from "@multica/ui/components/ui/switch";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@multica/ui/components/ui/tooltip";
|
|
import type {
|
|
MemberWithUser,
|
|
Project,
|
|
ProjectPriority,
|
|
ProjectStatus,
|
|
UpdateProjectRequest,
|
|
} from "@multica/core/types";
|
|
import {
|
|
CollectionPageHeader,
|
|
CollectionPageHeaderAction,
|
|
CollectionPageState,
|
|
} from "../../layout/collection-page";
|
|
import { ProjectIcon } from "./project-icon";
|
|
import { useT } from "../../i18n";
|
|
import { matchesPinyin } from "../../editor/extensions/pinyin-match";
|
|
import { useFormatRelativeDate } from "./labels";
|
|
import { ProjectStatusBadge, ProjectPriorityBadge } from "./project-badge";
|
|
import { ProjectLeadPicker } from "./project-lead-picker";
|
|
|
|
// Sort order maps for the enum columns (header sort needs a total order).
|
|
const PRIORITY_ORDER: Record<ProjectPriority, number> = {
|
|
urgent: 4,
|
|
high: 3,
|
|
medium: 2,
|
|
low: 1,
|
|
none: 0,
|
|
};
|
|
const STATUS_ORDER: Record<ProjectStatus, number> = {
|
|
planned: 0,
|
|
in_progress: 1,
|
|
paused: 2,
|
|
completed: 3,
|
|
cancelled: 4,
|
|
};
|
|
|
|
const progressOf = (p: Project) =>
|
|
p.issue_count > 0 ? p.done_count / p.issue_count : -1;
|
|
|
|
// Composite "type:id" lead value so the string[] filter holds member/agent
|
|
// refs alike.
|
|
function leadFilterValue(p: Project): string | null {
|
|
return p.lead_type && p.lead_id ? `${p.lead_type}:${p.lead_id}` : null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Table (compact) view — ListGrid. Name + status are the core columns;
|
|
// priority/progress/lead/issues/created collapse below @2xl, with min-width
|
|
// + the wrapper's overflow as the escape valve. Rows use whole-row mouse
|
|
// navigation; inline controls stop propagation so edit/menu clicks stay local.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const COLUMN_WIDTHS: Record<ProjectColumnKey, number> = {
|
|
priority: 116,
|
|
progress: 88,
|
|
lead: 132,
|
|
issues: 80,
|
|
created: 104,
|
|
};
|
|
|
|
// Fixed tracks: edges 12+12, checkbox 16, name min 200, status 116,
|
|
// kebab 28 = 384, plus the 10 gap-x-3 gaps between the wide template's
|
|
// 11 tracks.
|
|
const FIXED_TRACKS_WIDTH = 384 + 10 * 12;
|
|
|
|
// Render/track order: checkbox, name, status (core, fixed 116px), priority,
|
|
// progress, lead, issues, created, kebab. MUST be a literal string —
|
|
// Tailwind can't see interpolated `grid-cols-[...]` arbitrary values, so an
|
|
// interpolated width silently drops the whole template and the grid
|
|
// collapses to one column.
|
|
const GRID_COLS =
|
|
"grid-cols-[0.75rem_1rem_minmax(120px,1fr)_116px_1.75rem_0.75rem] " +
|
|
"@2xl:grid-cols-[0.75rem_1rem_minmax(200px,1fr)_116px_var(--pjc-priority)_var(--pjc-progress)_var(--pjc-lead)_var(--pjc-issues)_var(--pjc-created)_1.75rem_0.75rem]";
|
|
|
|
const stopRowNavigation = (e: MouseEvent) => e.stopPropagation();
|
|
|
|
function columnTrackVars(
|
|
isVisible: (key: ProjectColumnKey) => boolean,
|
|
): React.CSSProperties {
|
|
const width = (key: ProjectColumnKey) =>
|
|
isVisible(key) ? `${COLUMN_WIDTHS[key]}px` : "0px";
|
|
const minWidth =
|
|
FIXED_TRACKS_WIDTH +
|
|
(Object.keys(COLUMN_WIDTHS) as ProjectColumnKey[]).reduce(
|
|
(sum, key) => sum + (isVisible(key) ? COLUMN_WIDTHS[key] : 0),
|
|
0,
|
|
);
|
|
return {
|
|
"--pjc-priority": width("priority"),
|
|
"--pjc-progress": width("progress"),
|
|
"--pjc-lead": width("lead"),
|
|
"--pjc-issues": width("issues"),
|
|
"--pjc-created": width("created"),
|
|
"--pjc-minw": `${minWidth}px`,
|
|
} as React.CSSProperties;
|
|
}
|
|
|
|
function ProgressRing({ project }: { project: Project }) {
|
|
if (project.issue_count === 0) {
|
|
return <span className="text-caption text-faint-foreground">—</span>;
|
|
}
|
|
const pct = Math.round((project.done_count / project.issue_count) * 100);
|
|
return (
|
|
<span className="flex items-center gap-1.5">
|
|
<span className="relative h-3.5 w-3.5">
|
|
<svg className="h-3.5 w-3.5 -rotate-90" viewBox="0 0 16 16">
|
|
<circle className="text-muted" strokeWidth="2" stroke="currentColor" fill="none" r="6" cx="8" cy="8" />
|
|
<circle
|
|
className="text-emerald-500"
|
|
strokeWidth="2"
|
|
stroke="currentColor"
|
|
fill="none"
|
|
r="6"
|
|
cx="8"
|
|
cy="8"
|
|
strokeDasharray={`${pct * 0.377} 37.7`}
|
|
strokeLinecap="round"
|
|
/>
|
|
</svg>
|
|
</span>
|
|
<span className="text-caption tabular-nums text-muted-foreground">
|
|
{project.done_count}/{project.issue_count}
|
|
</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// Compact rows own whole-row navigation; callers stop propagation around this
|
|
// menu so action clicks do not bubble into the rowLink handler.
|
|
function ProjectRowActions({
|
|
project,
|
|
pinned,
|
|
canDelete,
|
|
}: {
|
|
project: Project;
|
|
pinned: boolean;
|
|
canDelete: boolean;
|
|
}) {
|
|
const { t } = useT("projects");
|
|
const createPin = useCreatePin();
|
|
const deletePin = useDeletePin();
|
|
const deleteProject = useDeleteProject();
|
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
|
|
const togglePin = () => {
|
|
if (pinned) deletePin.mutate({ itemType: "project", itemId: project.id });
|
|
else createPin.mutate({ item_type: "project", item_id: project.id });
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<button
|
|
type="button"
|
|
aria-label={t(($) => $.page.row_menu)}
|
|
className="flex size-7 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-accent-foreground group-hover/row:opacity-100 data-popup-open:bg-accent data-popup-open:opacity-100 data-popup-open:text-accent-foreground"
|
|
>
|
|
<MoreHorizontal className="size-4" />
|
|
</button>
|
|
}
|
|
/>
|
|
<DropdownMenuContent align="end" className="w-44">
|
|
<DropdownMenuItem onClick={togglePin}>
|
|
{pinned ? (
|
|
<PinOff className="size-3.5" />
|
|
) : (
|
|
<Pin className="size-3.5" />
|
|
)}
|
|
{pinned ? t(($) => $.page.unpin) : t(($) => $.page.pin)}
|
|
</DropdownMenuItem>
|
|
{canDelete && (
|
|
<>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
variant="destructive"
|
|
onClick={() => setDeleteOpen(true)}
|
|
>
|
|
<Trash2 className="size-3.5" />
|
|
{t(($) => $.page.delete)}
|
|
</DropdownMenuItem>
|
|
</>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{t(($) => $.delete_dialog.title)}</DialogTitle>
|
|
<DialogDescription>
|
|
{t(($) => $.delete_dialog.description)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setDeleteOpen(false)}
|
|
>
|
|
{t(($) => $.delete_dialog.cancel)}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => {
|
|
deleteProject.mutate(project.id, {
|
|
onError: (err) =>
|
|
toast.error(
|
|
err instanceof Error ? err.message : String(err),
|
|
),
|
|
});
|
|
setDeleteOpen(false);
|
|
}}
|
|
>
|
|
{t(($) => $.delete_dialog.confirm)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function CheckboxCell({
|
|
checked,
|
|
onToggle,
|
|
}: {
|
|
checked: boolean;
|
|
onToggle: () => void;
|
|
}) {
|
|
return (
|
|
<ListGridCell className="justify-center px-0">
|
|
<button
|
|
type="button"
|
|
aria-pressed={checked}
|
|
onClick={(e) => {
|
|
stopRowNavigation(e);
|
|
onToggle();
|
|
}}
|
|
onAuxClick={stopRowNavigation}
|
|
className={`-m-1.5 flex items-center p-1.5 ${
|
|
checked ? "" : "opacity-0 transition-opacity group-hover/row:opacity-100"
|
|
}`}
|
|
>
|
|
<Checkbox checked={checked} tabIndex={-1} className="pointer-events-none" />
|
|
</button>
|
|
</ListGridCell>
|
|
);
|
|
}
|
|
|
|
function ProjectTableRow({
|
|
project,
|
|
pinned,
|
|
canDelete,
|
|
isColVisible,
|
|
selected,
|
|
onToggleSelect,
|
|
rowHref,
|
|
rowLink,
|
|
}: {
|
|
project: Project;
|
|
pinned: boolean;
|
|
canDelete: boolean;
|
|
isColVisible: (key: ProjectColumnKey) => boolean;
|
|
selected: boolean;
|
|
onToggleSelect: () => void;
|
|
rowHref: string;
|
|
rowLink: ReturnType<typeof useRowLink>;
|
|
}) {
|
|
const formatRelativeDate = useFormatRelativeDate();
|
|
const updateProject = useUpdateProject();
|
|
const handleUpdate = useCallback(
|
|
(data: UpdateProjectRequest) => updateProject.mutate({ id: project.id, ...data }),
|
|
[project.id, updateProject],
|
|
);
|
|
|
|
return (
|
|
<ListGridRow
|
|
className={`h-11 cursor-pointer ${selected ? "bg-accent/30" : ""}`}
|
|
{...rowLink(rowHref)}
|
|
>
|
|
<CheckboxCell checked={selected} onToggle={onToggleSelect} />
|
|
<ListGridCell className="gap-2">
|
|
<ProjectIcon project={project} size="sm" />
|
|
<span className="min-w-0 truncate text-body font-medium">
|
|
{project.title}
|
|
</span>
|
|
</ListGridCell>
|
|
|
|
{/* status — core column, always visible */}
|
|
<ListGridCell onClick={stopRowNavigation} onAuxClick={stopRowNavigation}>
|
|
<ProjectStatusBadge project={project} handleUpdate={handleUpdate} align="start" />
|
|
</ListGridCell>
|
|
|
|
{isColVisible("priority") ? (
|
|
<ListGridCell className="hidden @2xl:flex" onClick={stopRowNavigation} onAuxClick={stopRowNavigation}>
|
|
<ProjectPriorityBadge project={project} handleUpdate={handleUpdate} align="start" />
|
|
</ListGridCell>
|
|
) : (
|
|
<ListGridCell className="hidden px-0 @2xl:flex" />
|
|
)}
|
|
|
|
{isColVisible("progress") ? (
|
|
<ListGridCell className="hidden @2xl:flex">
|
|
<ProgressRing project={project} />
|
|
</ListGridCell>
|
|
) : (
|
|
<ListGridCell className="hidden px-0 @2xl:flex" />
|
|
)}
|
|
|
|
{isColVisible("lead") ? (
|
|
<ListGridCell className="hidden @2xl:flex" onClick={stopRowNavigation} onAuxClick={stopRowNavigation}>
|
|
<ProjectLeadPicker
|
|
project={project}
|
|
handleUpdate={handleUpdate}
|
|
align="start"
|
|
renderTrigger={(leadName) => (
|
|
<button
|
|
type="button"
|
|
className="flex min-w-0 items-center gap-1.5 rounded px-1 py-0.5 transition-colors hover:bg-accent/60"
|
|
>
|
|
{project.lead_type && project.lead_id ? (
|
|
<ActorAvatar actorType={project.lead_type} actorId={project.lead_id} size="sm" enableHoverCard />
|
|
) : (
|
|
<span className="inline-flex h-[18px] w-[18px] rounded-full border border-dashed border-muted-foreground/30" />
|
|
)}
|
|
<span className="min-w-0 truncate text-caption text-muted-foreground">
|
|
{leadName ?? "—"}
|
|
</span>
|
|
</button>
|
|
)}
|
|
/>
|
|
</ListGridCell>
|
|
) : (
|
|
<ListGridCell className="hidden px-0 @2xl:flex" />
|
|
)}
|
|
|
|
{isColVisible("issues") ? (
|
|
<ListGridCell className="hidden justify-end font-mono text-caption tabular-nums text-muted-foreground @2xl:flex">
|
|
{project.issue_count}
|
|
</ListGridCell>
|
|
) : (
|
|
<ListGridCell className="hidden px-0 @2xl:flex" />
|
|
)}
|
|
|
|
{isColVisible("created") ? (
|
|
<ListGridCell className="hidden whitespace-nowrap text-caption tabular-nums text-muted-foreground @2xl:flex">
|
|
{formatRelativeDate(project.created_at)}
|
|
</ListGridCell>
|
|
) : (
|
|
<ListGridCell className="hidden px-0 @2xl:flex" />
|
|
)}
|
|
|
|
<ListGridCell className="justify-end px-0">
|
|
<span onClick={stopRowNavigation} onAuxClick={stopRowNavigation} className="flex items-center">
|
|
<ProjectRowActions project={project} pinned={pinned} canDelete={canDelete} />
|
|
</span>
|
|
</ListGridCell>
|
|
</ListGridRow>
|
|
);
|
|
}
|
|
|
|
function ProjectTableHeader({
|
|
sortField,
|
|
sortDirection,
|
|
onSort,
|
|
isColVisible,
|
|
allSelected,
|
|
someSelected,
|
|
onToggleAll,
|
|
}: {
|
|
sortField: ProjectSortField;
|
|
sortDirection: ListGridSortDirection;
|
|
onSort: (field: ProjectSortField) => void;
|
|
isColVisible: (key: ProjectColumnKey) => boolean;
|
|
allSelected: boolean;
|
|
someSelected: boolean;
|
|
onToggleAll: () => void;
|
|
}) {
|
|
const { t } = useT("projects");
|
|
const sorted = (field: ProjectSortField) =>
|
|
sortField === field ? sortDirection : false;
|
|
const anySelected = allSelected || someSelected;
|
|
return (
|
|
<ListGridHeader>
|
|
<div className="flex items-center justify-center">
|
|
<button
|
|
type="button"
|
|
aria-pressed={allSelected}
|
|
onClick={onToggleAll}
|
|
className={`-m-1.5 flex items-center p-1.5 ${
|
|
anySelected ? "" : "opacity-0 transition-opacity group-hover/header:opacity-100"
|
|
}`}
|
|
>
|
|
<Checkbox
|
|
checked={allSelected}
|
|
indeterminate={someSelected && !allSelected}
|
|
tabIndex={-1}
|
|
className="pointer-events-none"
|
|
/>
|
|
</button>
|
|
</div>
|
|
<ListGridHeaderCell sorted={sorted("name")} onSort={() => onSort("name")}>
|
|
{t(($) => $.table.name)}
|
|
</ListGridHeaderCell>
|
|
<ListGridHeaderCell sorted={sorted("status")} onSort={() => onSort("status")}>
|
|
{t(($) => $.table.status)}
|
|
</ListGridHeaderCell>
|
|
{isColVisible("priority") ? (
|
|
<ListGridHeaderCell
|
|
className="hidden @2xl:flex"
|
|
sorted={sorted("priority")}
|
|
onSort={() => onSort("priority")}
|
|
>
|
|
{t(($) => $.table.priority)}
|
|
</ListGridHeaderCell>
|
|
) : (
|
|
<ListGridHeaderCell className="hidden px-0 @2xl:flex" />
|
|
)}
|
|
{isColVisible("progress") ? (
|
|
<ListGridHeaderCell
|
|
className="hidden @2xl:flex"
|
|
sorted={sorted("progress")}
|
|
onSort={() => onSort("progress")}
|
|
>
|
|
{t(($) => $.table.progress)}
|
|
</ListGridHeaderCell>
|
|
) : (
|
|
<ListGridHeaderCell className="hidden px-0 @2xl:flex" />
|
|
)}
|
|
{isColVisible("lead") ? (
|
|
<ListGridHeaderCell className="hidden @2xl:flex">
|
|
{t(($) => $.table.lead)}
|
|
</ListGridHeaderCell>
|
|
) : (
|
|
<ListGridHeaderCell className="hidden px-0 @2xl:flex" />
|
|
)}
|
|
{isColVisible("issues") ? (
|
|
<ListGridHeaderCell className="hidden justify-end @2xl:flex" align="right">
|
|
{t(($) => $.table.issues)}
|
|
</ListGridHeaderCell>
|
|
) : (
|
|
<ListGridHeaderCell className="hidden px-0 @2xl:flex" />
|
|
)}
|
|
{isColVisible("created") ? (
|
|
<ListGridHeaderCell
|
|
className="hidden @2xl:flex"
|
|
sorted={sorted("created")}
|
|
onSort={() => onSort("created")}
|
|
>
|
|
{t(($) => $.table.created)}
|
|
</ListGridHeaderCell>
|
|
) : (
|
|
<ListGridHeaderCell className="hidden px-0 @2xl:flex" />
|
|
)}
|
|
<span aria-hidden="true" />
|
|
</ListGridHeader>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Card (comfortable) view — kept from the prior page.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function ProjectCard({
|
|
project,
|
|
pinned,
|
|
canDelete,
|
|
}: {
|
|
project: Project;
|
|
pinned: boolean;
|
|
canDelete: boolean;
|
|
}) {
|
|
const { t } = useT("projects");
|
|
const wsPaths = useWorkspacePaths();
|
|
const formatRelativeDate = useFormatRelativeDate();
|
|
const updateProject = useUpdateProject();
|
|
const handleUpdate = useCallback(
|
|
(data: UpdateProjectRequest) => updateProject.mutate({ id: project.id, ...data }),
|
|
[project.id, updateProject],
|
|
);
|
|
const progressPercent =
|
|
project.issue_count > 0
|
|
? Math.round((project.done_count / project.issue_count) * 100)
|
|
: 0;
|
|
|
|
return (
|
|
<div className="group/card group/row flex flex-col rounded-md border bg-card transition-colors hover:border-primary/50">
|
|
<div className="p-3 pb-2">
|
|
<div className="flex items-center gap-2">
|
|
<AppLink
|
|
href={wsPaths.projectDetail(project.id)}
|
|
className="flex min-w-0 flex-1 items-center gap-2"
|
|
>
|
|
<ProjectIcon project={project} size="sm" />
|
|
<h3 className="truncate text-body font-medium">{project.title}</h3>
|
|
</AppLink>
|
|
<ProjectRowActions project={project} pinned={pinned} canDelete={canDelete} />
|
|
<ProjectStatusBadge project={project} handleUpdate={handleUpdate} triggerClassName="shrink-0" />
|
|
</div>
|
|
|
|
{project.issue_count > 0 ? (
|
|
<div className="flex items-center justify-end gap-1.5 pt-2">
|
|
<div className="relative h-4 w-4">
|
|
<svg className="h-4 w-4 -rotate-90" viewBox="0 0 16 16">
|
|
<circle className="text-muted" strokeWidth="2" stroke="currentColor" fill="none" r="6" cx="8" cy="8" />
|
|
<circle
|
|
className="text-emerald-500"
|
|
strokeWidth="2"
|
|
stroke="currentColor"
|
|
fill="none"
|
|
r="6"
|
|
cx="8"
|
|
cy="8"
|
|
strokeDasharray={`${progressPercent * 0.377} 37.7`}
|
|
strokeLinecap="round"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<span className="text-micro tabular-nums text-muted-foreground">
|
|
{project.done_count}/{project.issue_count}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<span className="flex justify-end pt-2 text-micro text-muted-foreground">
|
|
{t(($) => $.detail.no_issues_yet)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-0 flex items-center justify-between border-t px-3 pb-3 pt-2">
|
|
<ProjectLeadPicker
|
|
project={project}
|
|
handleUpdate={handleUpdate}
|
|
renderTrigger={(leadName) => (
|
|
<button type="button" className="-mx-1.5 flex items-center gap-1.5 rounded px-1.5 py-0.5 transition-colors hover:bg-accent/60">
|
|
{project.lead_type && project.lead_id ? (
|
|
<ActorAvatar actorType={project.lead_type} actorId={project.lead_id} size="sm" enableHoverCard />
|
|
) : (
|
|
<span className="inline-flex h-5 w-5 rounded-full border border-dashed border-muted-foreground/30" />
|
|
)}
|
|
<span className="max-w-[60px] truncate text-micro text-muted-foreground">
|
|
{leadName ?? t(($) => $.lead.no_lead)}
|
|
</span>
|
|
</button>
|
|
)}
|
|
/>
|
|
<div className="flex items-center gap-2">
|
|
<ProjectPriorityBadge project={project} handleUpdate={handleUpdate} align="start" />
|
|
<span className="text-micro text-muted-foreground">
|
|
{formatRelativeDate(project.created_at)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Toolbar — search + result count + filter + display (compact only) + view
|
|
// toggle.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const STATUS_VALUES: ProjectStatus[] = [
|
|
"planned",
|
|
"in_progress",
|
|
"paused",
|
|
"completed",
|
|
"cancelled",
|
|
];
|
|
const PRIORITY_VALUES: ProjectPriority[] = ["urgent", "high", "medium", "low", "none"];
|
|
const COLUMN_KEYS: ProjectColumnKey[] = ["priority", "progress", "lead", "issues", "created"];
|
|
const SORT_FIELDS: ProjectSortField[] = ["name", "priority", "status", "progress", "created"];
|
|
|
|
function countActiveFilters(f: ProjectListFilters): number {
|
|
let c = 0;
|
|
if (f.statuses.length) c++;
|
|
if (f.priorities.length) c++;
|
|
if (f.leads.length) c++;
|
|
return c;
|
|
}
|
|
|
|
// Batch toolbar — page-anchored (not viewport). Pin all selected (any
|
|
// member) + Delete (workspace admin). Mirrors the other lists.
|
|
function ProjectBatchToolbar({
|
|
rows,
|
|
pinnedIds,
|
|
canDelete,
|
|
onClear,
|
|
}: {
|
|
rows: Project[];
|
|
pinnedIds: Set<string>;
|
|
canDelete: boolean;
|
|
onClear: () => void;
|
|
}) {
|
|
const { t } = useT("projects");
|
|
const createPin = useCreatePin();
|
|
const deleteProject = useDeleteProject();
|
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
|
|
if (rows.length === 0) return null;
|
|
const anyUnpinned = rows.some((p) => !pinnedIds.has(p.id));
|
|
|
|
return (
|
|
<>
|
|
<div className="absolute bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-1 rounded-lg border bg-background px-2 py-1.5 shadow-lg">
|
|
<div className="mr-1 flex items-center gap-1.5 border-r pl-1 pr-2">
|
|
<span className="text-body font-medium">
|
|
{t(($) => $.page.selected, { count: rows.length })}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
aria-label={t(($) => $.page.clear_selection)}
|
|
onClick={onClear}
|
|
className="rounded p-0.5 transition-colors hover:bg-accent"
|
|
>
|
|
<X className="size-3.5 text-muted-foreground" />
|
|
</button>
|
|
</div>
|
|
{anyUnpinned && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
for (const p of rows) {
|
|
if (!pinnedIds.has(p.id)) {
|
|
createPin.mutate({ item_type: "project", item_id: p.id });
|
|
}
|
|
}
|
|
onClear();
|
|
}}
|
|
>
|
|
<Pin className="mr-1 size-3.5" />
|
|
{t(($) => $.page.pin)}
|
|
</Button>
|
|
)}
|
|
{canDelete && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-destructive hover:text-destructive"
|
|
onClick={() => setConfirmDelete(true)}
|
|
>
|
|
<Trash2 className="mr-1 size-3.5" />
|
|
{t(($) => $.page.delete)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<Dialog open={confirmDelete} onOpenChange={setConfirmDelete}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{t(($) => $.delete_dialog.title)}</DialogTitle>
|
|
<DialogDescription>{t(($) => $.delete_dialog.description)}</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button type="button" variant="outline" size="sm" onClick={() => setConfirmDelete(false)}>
|
|
{t(($) => $.delete_dialog.cancel)}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => {
|
|
for (const p of rows) deleteProject.mutate(p.id);
|
|
setConfirmDelete(false);
|
|
onClear();
|
|
}}
|
|
>
|
|
{t(($) => $.delete_dialog.confirm)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Page
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function ProjectsPage() {
|
|
const { t } = useT("projects");
|
|
const wsId = useWorkspaceId();
|
|
const wsPaths = useWorkspacePaths();
|
|
const rowLink = useRowLink();
|
|
const currentUser = useAuthStore((s) => s.user);
|
|
const { getActorName } = useActorName();
|
|
|
|
const viewMode = useProjectViewStore((s) => s.viewMode);
|
|
const setViewMode = useProjectViewStore((s) => s.setViewMode);
|
|
const sortField = useProjectViewStore((s) => s.sortField);
|
|
const sortDirection = useProjectViewStore((s) => s.sortDirection);
|
|
const hiddenColumns = useProjectViewStore((s) => s.hiddenColumns);
|
|
const filters = useProjectViewStore((s) => s.filters);
|
|
const toggleSort = useProjectViewStore((s) => s.toggleSort);
|
|
const setSortField = useProjectViewStore((s) => s.setSortField);
|
|
const setSortDirection = useProjectViewStore((s) => s.setSortDirection);
|
|
const toggleColumn = useProjectViewStore((s) => s.toggleColumn);
|
|
const toggleFilter = useProjectViewStore((s) => s.toggleFilter);
|
|
const clearFilters = useProjectViewStore((s) => s.clearFilters);
|
|
const isCompact = viewMode === "compact";
|
|
const isColVisible = (key: ProjectColumnKey) => !hiddenColumns.includes(key);
|
|
|
|
const { data: projects = [], isLoading } = useQuery(projectListOptions(wsId));
|
|
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
|
const { data: pins = [] } = useQuery({
|
|
...pinListOptions(wsId, currentUser?.id ?? ""),
|
|
enabled: !!wsId && !!currentUser?.id,
|
|
});
|
|
const openCreateProject = () => useModalStore.getState().open("create-project");
|
|
|
|
const isWorkspaceAdmin = useMemo(() => {
|
|
if (!currentUser) return false;
|
|
const me = members.find((m: MemberWithUser) => m.user_id === currentUser.id);
|
|
return me?.role === "owner" || me?.role === "admin";
|
|
}, [members, currentUser]);
|
|
|
|
const pinnedProjectIds = useMemo(() => {
|
|
const s = new Set<string>();
|
|
for (const pin of pins) if (pin.item_type === "project") s.add(pin.item_id);
|
|
return s;
|
|
}, [pins]);
|
|
|
|
const [search, setSearch] = useState("");
|
|
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set());
|
|
const toggleSelected = (id: string) =>
|
|
setSelectedIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
|
|
const activeFilterCount = countActiveFilters(filters);
|
|
const hasActiveFilters = activeFilterCount > 0;
|
|
|
|
// Filter option counts derive from the full set so toggling one dimension
|
|
// doesn't make the others vanish.
|
|
const leadOptions = useMemo(() => {
|
|
const m = new Map<string, { type: string; id: string; count: number }>();
|
|
for (const p of projects) {
|
|
const v = leadFilterValue(p);
|
|
if (!v || !p.lead_type || !p.lead_id) continue;
|
|
const e = m.get(v);
|
|
if (e) e.count += 1;
|
|
else m.set(v, { type: p.lead_type, id: p.lead_id, count: 1 });
|
|
}
|
|
return m;
|
|
}, [projects]);
|
|
|
|
const visible = useMemo(() => {
|
|
const q = search.trim().toLowerCase();
|
|
const filtered = projects.filter((p) => {
|
|
if (q && !p.title.toLowerCase().includes(q) && !matchesPinyin(p.title, q)) {
|
|
return false;
|
|
}
|
|
if (filters.statuses.length && !filters.statuses.includes(p.status)) return false;
|
|
if (filters.priorities.length && !filters.priorities.includes(p.priority)) {
|
|
return false;
|
|
}
|
|
if (filters.leads.length) {
|
|
const v = leadFilterValue(p);
|
|
if (!v || !filters.leads.includes(v)) return false;
|
|
}
|
|
return true;
|
|
});
|
|
const dir = sortDirection === "asc" ? 1 : -1;
|
|
const sorted = [...filtered];
|
|
sorted.sort((a, b) => {
|
|
if (sortField === "name") return a.title.localeCompare(b.title) * dir;
|
|
if (sortField === "priority") {
|
|
return (
|
|
(PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]) * dir ||
|
|
a.title.localeCompare(b.title)
|
|
);
|
|
}
|
|
if (sortField === "status") {
|
|
return (
|
|
(STATUS_ORDER[a.status] - STATUS_ORDER[b.status]) * dir ||
|
|
a.title.localeCompare(b.title)
|
|
);
|
|
}
|
|
if (sortField === "progress") {
|
|
return (progressOf(a) - progressOf(b)) * dir || a.title.localeCompare(b.title);
|
|
}
|
|
return (Date.parse(a.created_at) - Date.parse(b.created_at)) * dir;
|
|
});
|
|
return sorted;
|
|
}, [projects, search, filters, sortField, sortDirection]);
|
|
|
|
const selectedProjects = visible.filter((p) => selectedIds.has(p.id));
|
|
const allSelected = visible.length > 0 && selectedProjects.length === visible.length;
|
|
const someSelected = selectedProjects.length > 0 && !allSelected;
|
|
const handleToggleAll = () =>
|
|
setSelectedIds(allSelected ? new Set() : new Set(visible.map((p) => p.id)));
|
|
|
|
const sortLabel = (f: ProjectSortField) =>
|
|
f === "name"
|
|
? t(($) => $.table.name)
|
|
: f === "priority"
|
|
? t(($) => $.table.priority)
|
|
: f === "status"
|
|
? t(($) => $.table.status)
|
|
: f === "progress"
|
|
? t(($) => $.table.progress)
|
|
: t(($) => $.table.created);
|
|
const columnLabel = (k: ProjectColumnKey) =>
|
|
k === "priority"
|
|
? t(($) => $.table.priority)
|
|
: k === "progress"
|
|
? t(($) => $.table.progress)
|
|
: k === "lead"
|
|
? t(($) => $.table.lead)
|
|
: k === "issues"
|
|
? t(($) => $.table.issues)
|
|
: t(($) => $.table.created);
|
|
|
|
const showEmpty = !isLoading && projects.length === 0;
|
|
const countBadge = (n: number) => (
|
|
<span className="ml-auto pl-3 text-caption text-muted-foreground">{n}</span>
|
|
);
|
|
|
|
return (
|
|
// relative: positioning anchor for the page-centered batch toolbar.
|
|
<div className="relative flex flex-1 min-h-0 flex-col">
|
|
<CollectionPageHeader
|
|
icon={FolderKanban}
|
|
title={t(($) => $.page.title)}
|
|
count={projects.length}
|
|
actions={
|
|
<CollectionPageHeaderAction
|
|
icon={Plus}
|
|
label={t(($) => $.page.new_project)}
|
|
onClick={openCreateProject}
|
|
/>
|
|
}
|
|
/>
|
|
|
|
{showEmpty ? (
|
|
<CollectionPageState
|
|
icon={FolderKanban}
|
|
title={t(($) => $.page.empty)}
|
|
actions={
|
|
<Button size="sm" variant="outline" onClick={openCreateProject}>
|
|
{t(($) => $.page.create_first)}
|
|
</Button>
|
|
}
|
|
/>
|
|
) : (
|
|
<>
|
|
{/* Toolbar */}
|
|
<div className="flex h-12 shrink-0 items-center justify-between gap-2 px-5">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<div className="relative hidden md:block">
|
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
aria-label={t(($) => $.page.search_placeholder)}
|
|
placeholder={t(($) => $.page.search_placeholder)}
|
|
className="h-8 w-56 pl-8 text-body"
|
|
/>
|
|
</div>
|
|
{(hasActiveFilters || search.trim().length > 0) && (
|
|
<span
|
|
title={t(($) => $.toolbar.result_count_title)}
|
|
className="hidden shrink-0 text-caption tabular-nums text-muted-foreground md:inline"
|
|
>
|
|
{visible.length} / {projects.length}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
{/* Filter */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<Button
|
|
variant={hasActiveFilters ? "default" : "outline"}
|
|
size="sm"
|
|
className={
|
|
hasActiveFilters
|
|
? "h-8 w-8 gap-1 bg-brand px-0 text-white hover:bg-brand/90 md:w-auto md:px-2.5"
|
|
: "h-8 w-8 gap-1 px-0 text-muted-foreground md:w-auto md:px-2.5"
|
|
}
|
|
>
|
|
<Filter className="size-3.5" />
|
|
{hasActiveFilters ? (
|
|
<>
|
|
<span className="hidden md:inline">
|
|
{t(($) => $.toolbar.filter_active_count, { count: activeFilterCount })}
|
|
</span>
|
|
<span className="tabular-nums md:hidden">{activeFilterCount}</span>
|
|
</>
|
|
) : (
|
|
<span className="hidden md:inline">{t(($) => $.toolbar.filter_label)}</span>
|
|
)}
|
|
{hasActiveFilters && (
|
|
<span
|
|
role="button"
|
|
tabIndex={-1}
|
|
aria-label={t(($) => $.toolbar.clear_filters)}
|
|
className="-mr-1 ml-0.5 hidden rounded-sm p-0.5 hover:bg-white/20 md:inline-flex"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
clearFilters();
|
|
}}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
>
|
|
<X className="size-3" />
|
|
</span>
|
|
)}
|
|
</Button>
|
|
}
|
|
/>
|
|
<DropdownMenuContent align="end" className="w-auto">
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger>
|
|
<span className="flex-1">{t(($) => $.toolbar.section_status)}</span>
|
|
{filters.statuses.length > 0 && (
|
|
<span className="text-caption font-medium text-primary">{filters.statuses.length}</span>
|
|
)}
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent className="w-auto min-w-44">
|
|
{STATUS_VALUES.map((s) => (
|
|
<DropdownMenuCheckboxItem
|
|
key={s}
|
|
checked={filters.statuses.includes(s)}
|
|
onCheckedChange={() => toggleFilter("statuses", s)}
|
|
className={FILTER_ITEM_CLASS}
|
|
>
|
|
<HoverCheck checked={filters.statuses.includes(s)} />
|
|
{t(($) => $.status[s])}
|
|
</DropdownMenuCheckboxItem>
|
|
))}
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger>
|
|
<span className="flex-1">{t(($) => $.toolbar.section_priority)}</span>
|
|
{filters.priorities.length > 0 && (
|
|
<span className="text-caption font-medium text-primary">{filters.priorities.length}</span>
|
|
)}
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent className="w-auto min-w-44">
|
|
{PRIORITY_VALUES.map((pr) => (
|
|
<DropdownMenuCheckboxItem
|
|
key={pr}
|
|
checked={filters.priorities.includes(pr)}
|
|
onCheckedChange={() => toggleFilter("priorities", pr)}
|
|
className={FILTER_ITEM_CLASS}
|
|
>
|
|
<HoverCheck checked={filters.priorities.includes(pr)} />
|
|
{t(($) => $.priority[pr])}
|
|
</DropdownMenuCheckboxItem>
|
|
))}
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger>
|
|
<span className="flex-1">{t(($) => $.toolbar.section_lead)}</span>
|
|
{filters.leads.length > 0 && (
|
|
<span className="text-caption font-medium text-primary">{filters.leads.length}</span>
|
|
)}
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent className="max-h-72 w-auto min-w-48 overflow-y-auto">
|
|
{[...leadOptions.entries()].map(([value, { type, id, count }]) => (
|
|
<DropdownMenuCheckboxItem
|
|
key={value}
|
|
checked={filters.leads.includes(value)}
|
|
onCheckedChange={() => toggleFilter("leads", value)}
|
|
className={FILTER_ITEM_CLASS}
|
|
>
|
|
<HoverCheck checked={filters.leads.includes(value)} />
|
|
<ActorAvatar actorType={type} actorId={id} size="sm" />
|
|
<span className="min-w-0 truncate">{getActorName(type, id)}</span>
|
|
{countBadge(count)}
|
|
</DropdownMenuCheckboxItem>
|
|
))}
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{/* Display (sort + columns). Always present — view mode is a
|
|
pure presentation choice and must not reshape the toolbar.
|
|
Sort applies to both views; the columns section is shown
|
|
only in the table view (cards have no columns). */}
|
|
<Popover>
|
|
<Tooltip>
|
|
<PopoverTrigger
|
|
render={
|
|
<TooltipTrigger
|
|
render={
|
|
<Button variant="outline" size="sm" className="h-8 w-8 gap-1 px-0 text-muted-foreground md:w-auto md:px-2.5">
|
|
{sortDirection === "asc" ? <ArrowUp className="size-3.5" /> : <ArrowDown className="size-3.5" />}
|
|
<span className="hidden md:inline">{sortLabel(sortField)}</span>
|
|
</Button>
|
|
}
|
|
/>
|
|
}
|
|
/>
|
|
<TooltipContent side="bottom">{t(($) => $.toolbar.display)}</TooltipContent>
|
|
</Tooltip>
|
|
<PopoverContent align="end" className="w-64 p-0">
|
|
<div className="border-b px-3 py-2.5">
|
|
<span className="text-caption font-medium text-muted-foreground">{t(($) => $.toolbar.sort_by)}</span>
|
|
<div className="mt-2 flex items-center gap-1.5">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<Button variant="outline" size="sm" className="flex-1 justify-between text-caption">
|
|
{sortLabel(sortField)}
|
|
<ChevronDown className="size-3 text-muted-foreground" />
|
|
</Button>
|
|
}
|
|
/>
|
|
<DropdownMenuContent align="start" className="w-auto">
|
|
<DropdownMenuRadioGroup
|
|
value={sortField}
|
|
onValueChange={(v) => setSortField(v as ProjectSortField)}
|
|
>
|
|
{SORT_FIELDS.map((f) => (
|
|
<DropdownMenuRadioItem key={f} value={f}>
|
|
{sortLabel(f)}
|
|
</DropdownMenuRadioItem>
|
|
))}
|
|
</DropdownMenuRadioGroup>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<Button
|
|
variant="outline"
|
|
size="icon-sm"
|
|
onClick={() => setSortDirection(sortDirection === "asc" ? "desc" : "asc")}
|
|
title={sortDirection === "asc" ? t(($) => $.toolbar.direction_asc) : t(($) => $.toolbar.direction_desc)}
|
|
>
|
|
{sortDirection === "asc" ? <ArrowUp className="size-3.5" /> : <ArrowDown className="size-3.5" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{isCompact && (
|
|
<div className="px-3 py-2.5">
|
|
<span className="text-caption font-medium text-muted-foreground">{t(($) => $.toolbar.section_columns)}</span>
|
|
<div className="mt-2 space-y-2">
|
|
{COLUMN_KEYS.map((key) => (
|
|
<label key={key} className="flex cursor-pointer items-center justify-between">
|
|
<span className="text-body">{columnLabel(key)}</span>
|
|
<Switch size="sm" checked={!hiddenColumns.includes(key)} onCheckedChange={() => toggleColumn(key)} />
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{/* View selector — a dropdown menu to pick the list view,
|
|
aligned with the issue list's view menu. The trigger shows
|
|
the active view; the menu carries every mode so new views
|
|
can be added as menu items. Pure presentation. */}
|
|
<DropdownMenu>
|
|
<Tooltip>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<TooltipTrigger
|
|
render={
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 w-8 gap-1 px-0 text-muted-foreground md:w-auto md:px-2.5"
|
|
>
|
|
{isCompact ? (
|
|
<Rows3 className="size-3.5" />
|
|
) : (
|
|
<LayoutGrid className="size-3.5" />
|
|
)}
|
|
<span className="hidden md:inline">
|
|
{isCompact ? t(($) => $.page.view_table) : t(($) => $.page.view_cards)}
|
|
</span>
|
|
</Button>
|
|
}
|
|
/>
|
|
}
|
|
/>
|
|
<TooltipContent side="bottom">{t(($) => $.toolbar.view)}</TooltipContent>
|
|
</Tooltip>
|
|
<DropdownMenuContent align="end" className="w-auto">
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuLabel>{t(($) => $.toolbar.view)}</DropdownMenuLabel>
|
|
</DropdownMenuGroup>
|
|
<DropdownMenuRadioGroup
|
|
value={viewMode}
|
|
onValueChange={(v) => setViewMode(v as ProjectViewMode)}
|
|
>
|
|
<DropdownMenuRadioItem value="compact">
|
|
<Rows3 />
|
|
{t(($) => $.page.view_table)}
|
|
</DropdownMenuRadioItem>
|
|
<DropdownMenuRadioItem value="comfortable">
|
|
<LayoutGrid />
|
|
{t(($) => $.page.view_cards)}
|
|
</DropdownMenuRadioItem>
|
|
</DropdownMenuRadioGroup>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
{isLoading ? (
|
|
<LoadingState isCompact={isCompact} />
|
|
) : visible.length === 0 ? (
|
|
<div className="flex flex-1 flex-col items-center justify-center py-24 text-muted-foreground">
|
|
<Search className="mb-3 h-10 w-10 opacity-30" />
|
|
<p className="text-body">{t(($) => $.page.no_matches)}</p>
|
|
</div>
|
|
) : isCompact ? (
|
|
<div className="min-h-0 flex-1 overflow-auto @container">
|
|
<ListGrid
|
|
className={`${GRID_COLS} @2xl:min-w-[var(--pjc-minw)]`}
|
|
style={{
|
|
...columnTrackVars(isColVisible),
|
|
paddingBottom: LIST_GRID_BOTTOM_CLEARANCE,
|
|
}}
|
|
>
|
|
<ProjectTableHeader
|
|
sortField={sortField}
|
|
sortDirection={sortDirection}
|
|
onSort={toggleSort}
|
|
isColVisible={isColVisible}
|
|
allSelected={allSelected}
|
|
someSelected={someSelected}
|
|
onToggleAll={handleToggleAll}
|
|
/>
|
|
{visible.map((project) => (
|
|
<ProjectTableRow
|
|
key={project.id}
|
|
project={project}
|
|
pinned={pinnedProjectIds.has(project.id)}
|
|
canDelete={isWorkspaceAdmin}
|
|
isColVisible={isColVisible}
|
|
selected={selectedIds.has(project.id)}
|
|
onToggleSelect={() => toggleSelected(project.id)}
|
|
rowHref={wsPaths.projectDetail(project.id)}
|
|
rowLink={rowLink}
|
|
/>
|
|
))}
|
|
</ListGrid>
|
|
</div>
|
|
) : (
|
|
<div className="min-h-0 flex-1 overflow-y-auto px-5 pt-4">
|
|
<div
|
|
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4"
|
|
style={{ paddingBottom: LIST_GRID_BOTTOM_CLEARANCE }}
|
|
>
|
|
{visible.map((project) => (
|
|
<ProjectCard
|
|
key={project.id}
|
|
project={project}
|
|
pinned={pinnedProjectIds.has(project.id)}
|
|
canDelete={isWorkspaceAdmin}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<ProjectBatchToolbar
|
|
rows={selectedProjects}
|
|
pinnedIds={pinnedProjectIds}
|
|
canDelete={isWorkspaceAdmin}
|
|
onClear={() => setSelectedIds(new Set())}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LoadingState({ isCompact }: { isCompact: boolean }) {
|
|
if (isCompact) {
|
|
return (
|
|
<div className="min-h-0 flex-1 overflow-auto px-5 pt-4">
|
|
<div className="space-y-2">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-11 w-full rounded-md" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className="grid grid-cols-1 gap-3 px-5 pt-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
{Array.from({ length: 8 }).map((_, i) => (
|
|
<div key={i} className="flex flex-col gap-2 rounded-md border p-3">
|
|
<div className="flex items-center gap-2">
|
|
<Skeleton className="h-8 w-8 rounded" />
|
|
<Skeleton className="h-4 w-3/4" />
|
|
</div>
|
|
<div className="flex gap-1.5">
|
|
<Skeleton className="h-5 w-16 rounded" />
|
|
<Skeleton className="h-5 w-20 rounded" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|