mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-23 10:08:38 +02:00
* refactor(ui): unify collection page patterns * refactor(agents): redesign agent detail workbench (#5263) * refactor(agents): redesign agent detail workbench * refactor(agents): refine capability and settings surfaces * refactor(agents): rebuild general settings form * refactor(agents): refine overview and settings * refactor(agents): redesign custom args editor * docs(ui): record consistency audit
1282 lines
47 KiB
TypeScript
1282 lines
47 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,
|
|
} 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,
|
|
DropdownMenuItem,
|
|
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-xs text-muted-foreground/40">—</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-xs 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-sm 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-xs text-muted-foreground">
|
|
{leadName ?? "—"}
|
|
</span>
|
|
</button>
|
|
)}
|
|
/>
|
|
</ListGridCell>
|
|
) : (
|
|
<ListGridCell className="hidden px-0 @2xl:flex" />
|
|
)}
|
|
|
|
{isColVisible("issues") ? (
|
|
<ListGridCell className="hidden justify-end font-mono text-xs 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-xs 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-sm 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-[10px] tabular-nums text-muted-foreground">
|
|
{project.done_count}/{project.issue_count}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<span className="flex justify-end pt-2 text-[10px] 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-[10px] 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-[10px] 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-sm 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-xs 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-sm"
|
|
/>
|
|
</div>
|
|
{(hasActiveFilters || search.trim().length > 0) && (
|
|
<span
|
|
title={t(($) => $.toolbar.result_count_title)}
|
|
className="hidden shrink-0 text-xs 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-xs 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-xs 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-xs 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-xs 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-xs">
|
|
{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-xs 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-sm">{columnLabel(key)}</span>
|
|
<Switch size="sm" checked={!hiddenColumns.includes(key)} onCheckedChange={() => toggleColumn(key)} />
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{/* View toggle — a single button that flips table ⇄ cards.
|
|
Pure presentation; coupled to nothing else. */}
|
|
<Tooltip>
|
|
<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"
|
|
onClick={() => setViewMode(isCompact ? "comfortable" : "compact")}
|
|
>
|
|
{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">
|
|
{isCompact ? t(($) => $.page.view_cards) : t(($) => $.page.view_table)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</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-sm">{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>
|
|
);
|
|
}
|