mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-29 06:28:23 +02:00
Add Zustand persisted draft stores for the create-project and feedback modals, following the same pattern as the existing issue draft store. Drafts are saved to localStorage on every field change and restored when the modal reopens, preventing accidental data loss on close. Draft is cleared on successful submit.
367 lines
14 KiB
TypeScript
367 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef } from "react";
|
|
import { ChevronRight, Maximize2, Minimize2, X as XIcon, UserMinus } from "lucide-react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useCreateProject } from "@multica/core/projects/mutations";
|
|
import { useProjectDraftStore } from "@multica/core/projects";
|
|
import {
|
|
PROJECT_STATUS_CONFIG,
|
|
PROJECT_STATUS_ORDER,
|
|
PROJECT_PRIORITY_CONFIG,
|
|
PROJECT_PRIORITY_ORDER,
|
|
} from "@multica/core/projects/config";
|
|
import { useWorkspaceId } from "@multica/core/hooks";
|
|
import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
|
|
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
|
|
import { useActorName } from "@multica/core/workspace/hooks";
|
|
import type { ProjectStatus, ProjectPriority } from "@multica/core/types";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
import { toast } from "sonner";
|
|
import { Dialog, DialogContent, DialogTitle } from "@multica/ui/components/ui/dialog";
|
|
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 { Button } from "@multica/ui/components/ui/button";
|
|
import { EmojiPicker } from "@multica/ui/components/common/emoji-picker";
|
|
import { ContentEditor, type ContentEditorRef, TitleEditor } from "../editor";
|
|
import { PriorityIcon } from "../issues/components/priority-icon";
|
|
import { ActorAvatar } from "../common/actor-avatar";
|
|
import { useNavigation } from "../navigation";
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
|
const router = useNavigation();
|
|
const workspace = useCurrentWorkspace();
|
|
const workspaceName = workspace?.name;
|
|
const wsPaths = useWorkspacePaths();
|
|
const wsId = useWorkspaceId();
|
|
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
|
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
|
const { getActorName } = useActorName();
|
|
|
|
const draft = useProjectDraftStore((s) => s.draft);
|
|
const setDraft = useProjectDraftStore((s) => s.setDraft);
|
|
const clearDraft = useProjectDraftStore((s) => s.clearDraft);
|
|
|
|
const [title, setTitle] = useState(draft.title);
|
|
const descEditorRef = useRef<ContentEditorRef>(null);
|
|
const [status, setStatus] = useState<ProjectStatus>(draft.status);
|
|
const [priority, setPriority] = useState<ProjectPriority>(draft.priority);
|
|
const [leadType, setLeadType] = useState<"member" | "agent" | undefined>(draft.leadType);
|
|
const [leadId, setLeadId] = useState<string | undefined>(draft.leadId);
|
|
const [icon, setIcon] = useState<string | undefined>(draft.icon);
|
|
const [iconPickerOpen, setIconPickerOpen] = useState(false);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
|
|
// Sync field changes to draft store
|
|
const updateTitle = (v: string) => { setTitle(v); setDraft({ title: v }); };
|
|
const updateStatus = (v: ProjectStatus) => { setStatus(v); setDraft({ status: v }); };
|
|
const updatePriority = (v: ProjectPriority) => { setPriority(v); setDraft({ priority: v }); };
|
|
const updateLead = (type?: "member" | "agent", id?: string) => {
|
|
setLeadType(type); setLeadId(id);
|
|
setDraft({ leadType: type, leadId: id });
|
|
};
|
|
const updateIcon = (v: string | undefined) => { setIcon(v); setDraft({ icon: v }); };
|
|
|
|
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 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,
|
|
priority,
|
|
lead_type: leadType,
|
|
lead_id: leadId,
|
|
});
|
|
clearDraft();
|
|
onClose();
|
|
toast.success("Project created");
|
|
router.push(wsPaths.projectDetail(project.id));
|
|
} catch {
|
|
toast.error("Failed to create project");
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
|
|
<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>
|
|
|
|
<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={onClose}
|
|
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>
|
|
|
|
<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) => {
|
|
updateIcon(emoji);
|
|
setIconPickerOpen(false);
|
|
}}
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
<TitleEditor
|
|
autoFocus
|
|
defaultValue={draft.title}
|
|
placeholder="Project title"
|
|
className="text-lg font-semibold"
|
|
onChange={(v) => updateTitle(v)}
|
|
onSubmit={handleSubmit}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex-1 min-h-0 overflow-y-auto px-5">
|
|
<ContentEditor
|
|
ref={descEditorRef}
|
|
defaultValue={draft.description}
|
|
placeholder="Add description..."
|
|
onUpdate={(md) => setDraft({ description: md })}
|
|
debounceMs={500}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1.5 px-4 py-2 shrink-0 flex-wrap">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<PillButton>
|
|
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[status].dotColor)} />
|
|
<span>{PROJECT_STATUS_CONFIG[status].label}</span>
|
|
</PillButton>
|
|
}
|
|
/>
|
|
<DropdownMenuContent align="start" className="w-44">
|
|
{PROJECT_STATUS_ORDER.map((s) => (
|
|
<DropdownMenuItem key={s} onClick={() => updateStatus(s)}>
|
|
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[s].dotColor)} />
|
|
<span>{PROJECT_STATUS_CONFIG[s].label}</span>
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<PillButton>
|
|
<PriorityIcon priority={priority} />
|
|
<span>{PROJECT_PRIORITY_CONFIG[priority].label}</span>
|
|
</PillButton>
|
|
}
|
|
/>
|
|
<DropdownMenuContent align="start" className="w-44">
|
|
{PROJECT_PRIORITY_ORDER.map((pr) => (
|
|
<DropdownMenuItem key={pr} onClick={() => updatePriority(pr)}>
|
|
<PriorityIcon priority={pr} />
|
|
<span>{PROJECT_PRIORITY_CONFIG[pr].label}</span>
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<Popover
|
|
open={leadOpen}
|
|
onOpenChange={(v) => {
|
|
setLeadOpen(v);
|
|
if (!v) setLeadFilter("");
|
|
}}
|
|
>
|
|
<PopoverTrigger
|
|
render={
|
|
<PillButton>
|
|
{leadType && leadId ? (
|
|
<>
|
|
<ActorAvatar actorType={leadType} actorId={leadId} size={16} showStatusDot />
|
|
<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={() => {
|
|
updateLead(undefined, 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={() => {
|
|
updateLead("member", 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={() => {
|
|
updateLead("agent", 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} showStatusDot />
|
|
<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 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>
|
|
);
|
|
}
|