mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
feat(projects): redesign project UI to match Linear and align with issue patterns
Create Project dialog: - Match Create Issue modal layout (custom shell, TitleEditor, ContentEditor, property toolbar with pill buttons) - Add status picker, lead picker, and emoji icon chooser - Expandable dialog (compact ↔ expanded) Projects list page: - Replace card layout with Linear-style table (column headers, dense rows with icon, name, status badge, lead avatar, created date) Project detail page: - Linear-style breadcrumb header with ... menu (copy link, delete) and copy link icon on the right - Tab bar: Overview + Issues - Overview: clickable emoji icon picker, TitleEditor, inline property pills (status + lead), ContentEditor for description - Issues tab: reuses existing BoardView/ListView/IssuesHeader/ BatchActionToolbar with a project-scoped view store and client-side project_id filtering - Remove summary stats section
This commit is contained in:
@@ -1,26 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { ArrowLeft, Check, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState, useCallback, useRef } from "react";
|
||||
import { Check, ChevronRight, Link2, ListTodo, MoreHorizontal, Trash2, UserMinus } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import type { Issue, IssueStatus, ProjectStatus } from "@multica/core/types";
|
||||
import { projectDetailOptions } from "@multica/core/projects/queries";
|
||||
import { useUpdateProject, useDeleteProject } from "@multica/core/projects/mutations";
|
||||
import { issueListOptions } from "@multica/core/issues/queries";
|
||||
import { useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
import { PROJECT_STATUS_ORDER, PROJECT_STATUS_CONFIG } from "@multica/core/projects/config";
|
||||
import { STATUS_CONFIG } from "@multica/core/issues/config";
|
||||
import { StatusIcon } from "../../issues/components/status-icon";
|
||||
import { BOARD_STATUSES } from "@multica/core/issues/config";
|
||||
import { createIssueViewStore, useIssueViewStore as useGlobalIssueViewStore } from "@multica/core/issues/stores/view-store";
|
||||
import { ViewStoreProvider, useViewStore } from "@multica/core/issues/stores/view-store-context";
|
||||
import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store";
|
||||
import { filterIssues } from "../../issues/utils/filter";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { AppLink, useNavigation } from "../../navigation";
|
||||
import { TitleEditor, ContentEditor, type ContentEditorRef } from "../../editor";
|
||||
import { IssuesHeader } from "../../issues/components/issues-header";
|
||||
import { BoardView } from "../../issues/components/board-view";
|
||||
import { ListView } from "../../issues/components/list-view";
|
||||
import { BatchActionToolbar } from "../../issues/components/batch-action-toolbar";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@multica/ui/components/ui/tabs";
|
||||
import { Textarea } from "@multica/ui/components/ui/textarea";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@multica/ui/components/ui/popover";
|
||||
import { EmojiPicker } from "@multica/ui/components/common/emoji-picker";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -31,44 +52,163 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@multica/ui/components/ui/alert-dialog";
|
||||
import type { Issue } from "@multica/core/types";
|
||||
|
||||
function IssueRow({ issue }: { issue: Issue }) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property pill — inline clickable pill for status/lead
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PropertyPill({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<AppLink
|
||||
href={`/issues/${issue.id}`}
|
||||
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent/50 transition-colors"
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs",
|
||||
"hover:bg-accent/60 transition-colors cursor-pointer",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<StatusIcon status={issue.status} className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-muted-foreground shrink-0 text-xs">{issue.identifier}</span>
|
||||
<span className="truncate">{issue.title}</span>
|
||||
<span className={`ml-auto shrink-0 text-xs ${STATUS_CONFIG[issue.status].iconColor}`}>
|
||||
{STATUS_CONFIG[issue.status].label}
|
||||
</span>
|
||||
</AppLink>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project Issues Tab — reuses the existing issues list/board components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const projectViewStore = createIssueViewStore("project_issues_view");
|
||||
|
||||
function ProjectIssuesTab({ projectIssues }: { projectIssues: Issue[] }) {
|
||||
const viewMode = useViewStore((s) => s.viewMode);
|
||||
const statusFilters = useViewStore((s) => s.statusFilters);
|
||||
const priorityFilters = useViewStore((s) => s.priorityFilters);
|
||||
const assigneeFilters = useViewStore((s) => s.assigneeFilters);
|
||||
const includeNoAssignee = useViewStore((s) => s.includeNoAssignee);
|
||||
const creatorFilters = useViewStore((s) => s.creatorFilters);
|
||||
|
||||
const issues = useMemo(
|
||||
() => filterIssues(projectIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters }),
|
||||
[projectIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters],
|
||||
);
|
||||
|
||||
const visibleStatuses = useMemo(() => {
|
||||
if (statusFilters.length > 0)
|
||||
return BOARD_STATUSES.filter((s) => statusFilters.includes(s));
|
||||
return BOARD_STATUSES;
|
||||
}, [statusFilters]);
|
||||
|
||||
const hiddenStatuses = useMemo(
|
||||
() => BOARD_STATUSES.filter((s) => !visibleStatuses.includes(s)),
|
||||
[visibleStatuses],
|
||||
);
|
||||
|
||||
const updateIssueMutation = useUpdateIssue();
|
||||
const handleMoveIssue = useCallback(
|
||||
(issueId: string, newStatus: IssueStatus, newPosition?: number) => {
|
||||
const viewState = projectViewStore.getState();
|
||||
if (viewState.sortBy !== "position") {
|
||||
viewState.setSortBy("position");
|
||||
viewState.setSortDirection("asc");
|
||||
}
|
||||
const updates: Partial<{ status: IssueStatus; position: number }> = { status: newStatus };
|
||||
if (newPosition !== undefined) updates.position = newPosition;
|
||||
updateIssueMutation.mutate(
|
||||
{ id: issueId, ...updates },
|
||||
{ onError: () => toast.error("Failed to move issue") },
|
||||
);
|
||||
},
|
||||
[updateIssueMutation],
|
||||
);
|
||||
|
||||
if (projectIssues.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<ListTodo className="h-10 w-10 text-muted-foreground/40" />
|
||||
<p className="text-sm">No issues linked</p>
|
||||
<p className="text-xs">Assign issues to this project from the issue detail page.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{viewMode === "board" ? (
|
||||
<BoardView
|
||||
issues={issues}
|
||||
allIssues={projectIssues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
onMoveIssue={handleMoveIssue}
|
||||
/>
|
||||
) : (
|
||||
<ListView issues={issues} visibleStatuses={visibleStatuses} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ProjectDetail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ProjectDetail({ projectId }: { projectId: string }) {
|
||||
const wsId = useWorkspaceId();
|
||||
const router = useNavigation();
|
||||
const workspaceName = useWorkspaceStore((s) => s.workspace?.name);
|
||||
const { data: project, isLoading } = useQuery(projectDetailOptions(wsId, projectId));
|
||||
const { data: allIssues = [] } = useQuery(issueListOptions(wsId));
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const { getActorName } = useActorName();
|
||||
const updateProject = useUpdateProject();
|
||||
const deleteProject = useDeleteProject();
|
||||
const descEditorRef = useRef<ContentEditorRef>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<"overview" | "issues">("overview");
|
||||
|
||||
// Lead popover
|
||||
const [leadOpen, setLeadOpen] = useState(false);
|
||||
const [leadFilter, setLeadFilter] = useState("");
|
||||
const leadQuery = leadFilter.toLowerCase();
|
||||
const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(leadQuery));
|
||||
const filteredAgents = agents.filter((a) => !a.archived_at && a.name.toLowerCase().includes(leadQuery));
|
||||
|
||||
const projectIssues = useMemo(
|
||||
() => allIssues.filter((i) => i.project_id === projectId),
|
||||
[allIssues, projectId],
|
||||
);
|
||||
|
||||
const handleUpdateField = useCallback(
|
||||
(data: Parameters<typeof updateProject.mutate>[0] extends { id: string } & infer R ? R : never) => {
|
||||
if (!project) return;
|
||||
updateProject.mutate({ id: project.id, ...data });
|
||||
},
|
||||
[project, updateProject],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!project) return;
|
||||
deleteProject.mutate(project.id, {
|
||||
onSuccess: () => {
|
||||
toast.success("Project deleted");
|
||||
router.push("/projects");
|
||||
},
|
||||
});
|
||||
}, [project, deleteProject, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<div className="mx-auto w-full max-w-4xl px-8 py-10 space-y-4">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-96" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-40 w-full mt-8" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -81,111 +221,268 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-3 border-b px-6 py-3">
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => router.push("/projects")}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-lg">{project.icon || "📁"}</span>
|
||||
<h1 className="text-sm font-medium flex-1 truncate">{project.title}</h1>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm">
|
||||
<span className={`inline-flex items-center gap-1 ${statusCfg.color}`}>{statusCfg.label}</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
{PROJECT_STATUS_ORDER.map((s) => (
|
||||
<DropdownMenuItem key={s} onClick={() => updateProject.mutate({ id: project.id, status: s })}>
|
||||
<span className={PROJECT_STATUS_CONFIG[s].color}>{PROJECT_STATUS_CONFIG[s].label}</span>
|
||||
{s === project.status && <Check className="ml-auto h-3.5 w-3.5" />}
|
||||
{/* Header bar — breadcrumb */}
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b bg-background px-4 text-sm">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<AppLink href="/projects" className="text-muted-foreground hover:text-foreground transition-colors shrink-0">
|
||||
{workspaceName ?? "Projects"}
|
||||
</AppLink>
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
|
||||
<span className="truncate">{project.title}</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon-xs" className="text-muted-foreground shrink-0">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-48">
|
||||
<DropdownMenuItem onClick={() => {
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
toast.success("Link copied");
|
||||
}}>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
Copy link
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive" onClick={() => setDeleteDialogOpen(true)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete project</AlertDialogTitle>
|
||||
<AlertDialogDescription>This will delete the project. Issues will not be deleted but will be unlinked.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => deleteProject.mutate(project.id, { onSuccess: () => router.push("/projects") })}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete project
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
toast.success("Link copied");
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview" className="flex-1 flex flex-col overflow-hidden">
|
||||
<TabsList className="mx-6 mt-3 w-fit">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="issues">Issues{projectIssues.length > 0 && ` (${projectIssues.length})`}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">Description</label>
|
||||
<Textarea
|
||||
placeholder="Add a description..."
|
||||
defaultValue={project.description ?? ""}
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value;
|
||||
if (val !== (project.description ?? "")) {
|
||||
updateProject.mutate({ id: project.id, description: val || null });
|
||||
}
|
||||
}}
|
||||
className="min-h-[100px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-2 block">Summary</label>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="text-2xl font-semibold">{projectIssues.length}</div>
|
||||
<div className="text-xs text-muted-foreground">Total issues</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="text-2xl font-semibold">{projectIssues.filter((i) => i.status === "done").length}</div>
|
||||
<div className="text-xs text-muted-foreground">Completed</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="text-2xl font-semibold">{projectIssues.filter((i) => i.status === "in_progress" || i.status === "in_review").length}</div>
|
||||
<div className="text-xs text-muted-foreground">In progress</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="text-2xl font-semibold">{projectIssues.filter((i) => i.status === "todo" || i.status === "backlog").length}</div>
|
||||
<div className="text-xs text-muted-foreground">Not started</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="issues" className="flex-1 overflow-y-auto p-6">
|
||||
{projectIssues.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground text-sm">
|
||||
No issues linked to this project yet.
|
||||
<br />
|
||||
<span className="text-xs">Assign issues to this project from the issue detail panel.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{projectIssues.map((issue) => (
|
||||
<IssueRow key={issue.id} issue={issue} />
|
||||
))}
|
||||
</div>
|
||||
{/* Tab bar */}
|
||||
<div className="flex h-10 shrink-0 items-center gap-1 border-b px-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("overview")}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
activeTab === "overview"
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent/50",
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("issues")}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
activeTab === "issues"
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
Issues
|
||||
{projectIssues.length > 0 && (
|
||||
<span className="ml-1.5 tabular-nums text-muted-foreground">{projectIssues.length}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === "overview" ? (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-4xl px-8 py-8">
|
||||
{/* Icon — clickable to change */}
|
||||
<Popover open={iconPickerOpen} onOpenChange={setIconPickerOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="text-3xl cursor-pointer rounded-lg p-1 -ml-1 hover:bg-accent/60 transition-colors"
|
||||
title="Change icon"
|
||||
>
|
||||
{project.icon || "📁"}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-auto p-0">
|
||||
<EmojiPicker
|
||||
onSelect={(emoji) => {
|
||||
handleUpdateField({ icon: emoji });
|
||||
setIconPickerOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Editable title */}
|
||||
<TitleEditor
|
||||
key={`title-${projectId}`}
|
||||
defaultValue={project.title}
|
||||
placeholder="Project title"
|
||||
className="mt-3 w-full text-2xl font-bold leading-snug tracking-tight"
|
||||
onBlur={(value) => {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed && trimmed !== project.title) handleUpdateField({ title: trimmed });
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Properties row — inline pills */}
|
||||
<div className="mt-5 flex items-center gap-4">
|
||||
<span className="text-xs font-medium text-muted-foreground shrink-0 w-20">Properties</span>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{/* Status */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<PropertyPill>
|
||||
<span className={cn("size-2 rounded-full", statusCfg.color.replace("text-", "bg-"))} />
|
||||
<span>{statusCfg.label}</span>
|
||||
</PropertyPill>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-44">
|
||||
{PROJECT_STATUS_ORDER.map((s) => (
|
||||
<DropdownMenuItem key={s} onClick={() => handleUpdateField({ status: s as ProjectStatus })}>
|
||||
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[s].color.replace("text-", "bg-"))} />
|
||||
<span>{PROJECT_STATUS_CONFIG[s].label}</span>
|
||||
{s === project.status && <Check className="ml-auto h-3.5 w-3.5" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Lead */}
|
||||
<Popover open={leadOpen} onOpenChange={(v) => { setLeadOpen(v); if (!v) setLeadFilter(""); }}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<PropertyPill>
|
||||
{project.lead_type && project.lead_id ? (
|
||||
<>
|
||||
<ActorAvatar actorType={project.lead_type} actorId={project.lead_id} size={16} />
|
||||
<span>{getActorName(project.lead_type, project.lead_id)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Lead</span>
|
||||
)}
|
||||
</PropertyPill>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-52 p-0">
|
||||
<div className="px-2 py-1.5 border-b">
|
||||
<input
|
||||
type="text"
|
||||
value={leadFilter}
|
||||
onChange={(e) => setLeadFilter(e.target.value)}
|
||||
placeholder="Assign lead..."
|
||||
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-1 max-h-60 overflow-y-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { handleUpdateField({ lead_type: null, lead_id: null }); setLeadOpen(false); }}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">No lead</span>
|
||||
</button>
|
||||
{filteredMembers.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">Members</div>
|
||||
{filteredMembers.map((m) => (
|
||||
<button
|
||||
type="button"
|
||||
key={m.user_id}
|
||||
onClick={() => { handleUpdateField({ lead_type: "member", lead_id: m.user_id }); setLeadOpen(false); }}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<ActorAvatar actorType="member" actorId={m.user_id} size={16} />
|
||||
<span>{m.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{filteredAgents.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">Agents</div>
|
||||
{filteredAgents.map((a) => (
|
||||
<button
|
||||
type="button"
|
||||
key={a.id}
|
||||
onClick={() => { handleUpdateField({ lead_type: "agent", lead_id: a.id }); setLeadOpen(false); }}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<ActorAvatar actorType="agent" actorId={a.id} size={16} />
|
||||
<span>{a.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{filteredMembers.length === 0 && filteredAgents.length === 0 && leadFilter && (
|
||||
<div className="px-2 py-3 text-center text-sm text-muted-foreground">No results</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="mt-8">
|
||||
<h3 className="text-xs font-medium text-muted-foreground mb-2">Description</h3>
|
||||
<ContentEditor
|
||||
ref={descEditorRef}
|
||||
key={projectId}
|
||||
defaultValue={project.description || ""}
|
||||
placeholder="Add description..."
|
||||
onUpdate={(md) => handleUpdateField({ description: md || null })}
|
||||
debounceMs={1500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Issues tab — reuse existing issue list/board components */
|
||||
<ViewStoreProvider store={projectViewStore}>
|
||||
<IssuesHeader scopedIssues={projectIssues} />
|
||||
<ProjectIssuesTab projectIssues={projectIssues} />
|
||||
<BatchActionToolbar />
|
||||
</ViewStoreProvider>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete project</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will delete the project. Issues will not be deleted but will be unlinked.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-white hover:bg-destructive/90">
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,76 +1,365 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus, FolderKanban } from "lucide-react";
|
||||
import { useState, useRef } from "react";
|
||||
import { Plus, FolderKanban, ChevronRight, Maximize2, Minimize2, X as XIcon, UserMinus } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { projectListOptions } from "@multica/core/projects/queries";
|
||||
import { useCreateProject } from "@multica/core/projects/mutations";
|
||||
import { PROJECT_STATUS_CONFIG } from "@multica/core/projects/config";
|
||||
import { PROJECT_STATUS_CONFIG, PROJECT_STATUS_ORDER } from "@multica/core/projects/config";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { AppLink } from "../../navigation";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { AppLink, useNavigation } from "../../navigation";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import type { Project } from "@multica/core/types";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@multica/ui/components/ui/popover";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import { ContentEditor, type ContentEditorRef } from "../../editor";
|
||||
import { TitleEditor } from "../../editor";
|
||||
import { EmojiPicker } from "@multica/ui/components/common/emoji-picker";
|
||||
import type { Project, ProjectStatus } from "@multica/core/types";
|
||||
|
||||
function ProjectCard({ project }: { project: Project }) {
|
||||
function formatRelativeDate(date: string): string {
|
||||
const diff = Date.now() - new Date(date).getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
if (days < 1) return "Today";
|
||||
if (days === 1) return "1d ago";
|
||||
if (days < 30) return `${days}d ago`;
|
||||
const months = Math.floor(days / 30);
|
||||
return `${months}mo ago`;
|
||||
}
|
||||
|
||||
function ProjectRow({ project }: { project: Project }) {
|
||||
const statusCfg = PROJECT_STATUS_CONFIG[project.status];
|
||||
return (
|
||||
<AppLink
|
||||
href={`/projects/${project.id}`}
|
||||
className="flex items-center gap-3 rounded-lg border px-4 py-3 hover:bg-accent/50 transition-colors"
|
||||
className="group/row flex h-11 items-center gap-2 px-5 text-sm transition-colors hover:bg-accent/40"
|
||||
>
|
||||
<span className="text-lg shrink-0">{project.icon || "📁"}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium truncate">{project.title}</div>
|
||||
{project.description && (
|
||||
<div className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{project.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className={`inline-flex items-center rounded px-2 py-0.5 text-xs font-medium ${statusCfg.badgeBg} ${statusCfg.badgeText}`}>
|
||||
{/* Icon + Name */}
|
||||
<span className="shrink-0 w-[24px] text-center text-base">{project.icon || "📁"}</span>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{project.title}</span>
|
||||
|
||||
{/* Status */}
|
||||
<span className={cn(
|
||||
"inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs font-medium shrink-0 w-28 justify-center",
|
||||
statusCfg.badgeBg, statusCfg.badgeText,
|
||||
)}>
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
|
||||
{/* Lead */}
|
||||
<span className="flex w-10 items-center justify-center shrink-0">
|
||||
{project.lead_type && project.lead_id ? (
|
||||
<ActorAvatar actorType={project.lead_type} actorId={project.lead_id} size={22} />
|
||||
) : (
|
||||
<span className="h-[22px] w-[22px] rounded-full border border-dashed border-muted-foreground/30" />
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Created */}
|
||||
<span className="w-20 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
|
||||
{formatRelativeDate(project.created_at)}
|
||||
</span>
|
||||
</AppLink>
|
||||
);
|
||||
}
|
||||
|
||||
function PillButton({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs",
|
||||
"hover:bg-accent/60 transition-colors cursor-pointer",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateProjectDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const router = useNavigation();
|
||||
const workspaceName = useWorkspaceStore((s) => s.workspace?.name);
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const { getActorName } = useActorName();
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const descEditorRef = useRef<ContentEditorRef>(null);
|
||||
const [status, setStatus] = useState<ProjectStatus>("planned");
|
||||
const [leadType, setLeadType] = useState<"member" | "agent" | undefined>();
|
||||
const [leadId, setLeadId] = useState<string | undefined>();
|
||||
const [icon, setIcon] = useState<string | undefined>();
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Lead popover
|
||||
const [leadOpen, setLeadOpen] = useState(false);
|
||||
const [leadFilter, setLeadFilter] = useState("");
|
||||
|
||||
const leadQuery = leadFilter.toLowerCase();
|
||||
const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(leadQuery));
|
||||
const filteredAgents = agents.filter((a) => !a.archived_at && a.name.toLowerCase().includes(leadQuery));
|
||||
|
||||
const leadLabel =
|
||||
leadType && leadId ? getActorName(leadType, leadId) : "Lead";
|
||||
|
||||
const createProject = useCreateProject();
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!title.trim()) return;
|
||||
createProject.mutate({ title: title.trim() }, {
|
||||
onSuccess: () => { setTitle(""); onOpenChange(false); },
|
||||
});
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim() || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const project = await createProject.mutateAsync({
|
||||
title: title.trim(),
|
||||
description: descEditorRef.current?.getMarkdown()?.trim() || undefined,
|
||||
icon,
|
||||
status,
|
||||
lead_type: leadType,
|
||||
lead_id: leadId,
|
||||
});
|
||||
onOpenChange(false);
|
||||
setTitle("");
|
||||
setIcon(undefined);
|
||||
setStatus("planned");
|
||||
setLeadType(undefined);
|
||||
setLeadId(undefined);
|
||||
toast.success("Project created");
|
||||
router.push(`/projects/${project.id}`);
|
||||
} catch {
|
||||
toast.error("Failed to create project");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create project</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
placeholder="Project title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }}
|
||||
autoFocus
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button onClick={handleCreate} disabled={!title.trim() || createProject.isPending}>Create</Button>
|
||||
</DialogFooter>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
"p-0 gap-0 flex flex-col overflow-hidden",
|
||||
"!top-1/2 !left-1/2 !-translate-x-1/2",
|
||||
"!transition-all !duration-300 !ease-out",
|
||||
isExpanded
|
||||
? "!max-w-4xl !w-full !h-5/6 !-translate-y-1/2"
|
||||
: "!max-w-2xl !w-full !h-96 !-translate-y-1/2",
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">New Project</DialogTitle>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">{workspaceName}</span>
|
||||
<ChevronRight className="size-3 text-muted-foreground/50" />
|
||||
<span className="font-medium">New project</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
||||
>
|
||||
{isExpanded ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">{isExpanded ? "Collapse" : "Expand"}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">Close</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Icon + Title */}
|
||||
<div className="px-5 pb-2 shrink-0">
|
||||
<Popover open={iconPickerOpen} onOpenChange={setIconPickerOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="text-2xl cursor-pointer rounded-lg p-1 -ml-1 hover:bg-accent/60 transition-colors"
|
||||
title="Choose icon"
|
||||
>
|
||||
{icon || "📁"}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-auto p-0">
|
||||
<EmojiPicker
|
||||
onSelect={(emoji) => {
|
||||
setIcon(emoji);
|
||||
setIconPickerOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<TitleEditor
|
||||
autoFocus
|
||||
defaultValue=""
|
||||
placeholder="Project title"
|
||||
className="text-lg font-semibold"
|
||||
onChange={(v) => setTitle(v)}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-5">
|
||||
<ContentEditor
|
||||
ref={descEditorRef}
|
||||
defaultValue=""
|
||||
placeholder="Add description..."
|
||||
debounceMs={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Property toolbar */}
|
||||
<div className="flex items-center gap-1.5 px-4 py-2 shrink-0 flex-wrap">
|
||||
{/* Status */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<PillButton>
|
||||
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[status].color.replace("text-", "bg-"))} />
|
||||
<span>{PROJECT_STATUS_CONFIG[status].label}</span>
|
||||
</PillButton>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-44">
|
||||
{PROJECT_STATUS_ORDER.map((s) => (
|
||||
<DropdownMenuItem key={s} onClick={() => setStatus(s)}>
|
||||
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[s].color.replace("text-", "bg-"))} />
|
||||
<span>{PROJECT_STATUS_CONFIG[s].label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Lead */}
|
||||
<Popover open={leadOpen} onOpenChange={(v) => { setLeadOpen(v); if (!v) setLeadFilter(""); }}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<PillButton>
|
||||
{leadType && leadId ? (
|
||||
<>
|
||||
<ActorAvatar actorType={leadType} actorId={leadId} size={16} />
|
||||
<span>{leadLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Lead</span>
|
||||
)}
|
||||
</PillButton>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-52 p-0">
|
||||
<div className="px-2 py-1.5 border-b">
|
||||
<input
|
||||
type="text"
|
||||
value={leadFilter}
|
||||
onChange={(e) => setLeadFilter(e.target.value)}
|
||||
placeholder="Assign lead..."
|
||||
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-1 max-h-60 overflow-y-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setLeadType(undefined); setLeadId(undefined); setLeadOpen(false); }}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">No lead</span>
|
||||
</button>
|
||||
{filteredMembers.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">Members</div>
|
||||
{filteredMembers.map((m) => (
|
||||
<button
|
||||
type="button"
|
||||
key={m.user_id}
|
||||
onClick={() => { setLeadType("member"); setLeadId(m.user_id); setLeadOpen(false); }}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<ActorAvatar actorType="member" actorId={m.user_id} size={16} />
|
||||
<span>{m.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{filteredAgents.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">Agents</div>
|
||||
{filteredAgents.map((a) => (
|
||||
<button
|
||||
type="button"
|
||||
key={a.id}
|
||||
onClick={() => { setLeadType("agent"); setLeadId(a.id); setLeadOpen(false); }}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<ActorAvatar actorType="agent" actorId={a.id} size={16} />
|
||||
<span>{a.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{filteredMembers.length === 0 && filteredAgents.length === 0 && leadFilter && (
|
||||
<div className="px-2 py-3 text-center text-sm text-muted-foreground">No results</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end px-4 py-3 border-t shrink-0">
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
|
||||
{submitting ? "Creating..." : "Create Project"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -83,10 +372,14 @@ export function ProjectsPage() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between border-b px-6 py-3">
|
||||
{/* Header bar */}
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderKanban className="h-4 w-4 text-muted-foreground" />
|
||||
<h1 className="text-sm font-medium">Projects</h1>
|
||||
{!isLoading && projects.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{projects.length}</span>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
@@ -94,27 +387,38 @@ export function ProjectsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{/* Table */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full rounded-lg" />
|
||||
<div className="p-5 space-y-1">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-11 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<FolderKanban className="h-10 w-10 mb-3 opacity-40" />
|
||||
<div className="flex flex-col items-center justify-center py-24 text-muted-foreground">
|
||||
<FolderKanban className="h-10 w-10 mb-3 opacity-30" />
|
||||
<p className="text-sm">No projects yet</p>
|
||||
<Button size="sm" variant="outline" className="mt-3" onClick={() => setCreateOpen(true)}>
|
||||
Create your first project
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<>
|
||||
{/* Column headers */}
|
||||
<div className="sticky top-0 z-[1] flex h-8 items-center gap-2 border-b bg-muted/30 px-5 text-xs font-medium text-muted-foreground">
|
||||
{/* Icon spacer + Name */}
|
||||
<span className="shrink-0 w-[24px]" />
|
||||
<span className="min-w-0 flex-1">Name</span>
|
||||
<span className="w-28 text-center shrink-0">Status</span>
|
||||
<span className="w-10 text-center shrink-0">Lead</span>
|
||||
<span className="w-20 text-right shrink-0">Created</span>
|
||||
</div>
|
||||
{/* Rows */}
|
||||
{projects.map((project) => (
|
||||
<ProjectCard key={project.id} project={project} />
|
||||
<ProjectRow key={project.id} project={project} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user