mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-22 17:49:48 +02:00
* feat(search): add light/dark/system theme toggle actions to cmd+k The command palette now surfaces an "Actions" section with theme toggle items (Light / Dark / System), searchable via keywords like "theme", "light", "dark", "appearance", or "mode". The active theme is marked with a check icon. * feat(search): add quick-win commands to cmd+k palette Extends the command palette with a "Commands" group that consolidates theme toggles plus four new actions: - New Issue / New Project — trigger the global create modals - Copy Issue Link / Copy Identifier (MUL-xxx) — only when the current route is an issue detail page; mirrors the copy-link dropdown logic from issue-detail Adds a "Switch Workspace" group that lists the user's other workspaces (filtered by name/slug, or by typing "workspace"/"switch") and navigates to the selected workspace's issues page. To make "New Project" work from anywhere, the inline CreateProjectDialog on ProjectsPage is extracted into a global CreateProjectModal mounted via the existing ModalRegistry + modal store (same pattern as create-issue / create-workspace). The modal store type gains a "create-project" variant. * feat(search): show Commands by default so they're discoverable Before, cmd+k actions (New Issue / New Project / Copy link / Copy ID / theme toggles) only appeared when the user typed a matching keyword, leaving them invisible unless the user already knew they existed. Now the Commands group renders as soon as the palette opens (no query), with the whole command list shown; typing narrows it down as before. Also trims the redundant "⌘K to open this anytime" hint from the empty state — the palette is already open.
353 lines
13 KiB
TypeScript
353 lines
13 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 {
|
|
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 [title, setTitle] = useState("");
|
|
const descEditorRef = useRef<ContentEditorRef>(null);
|
|
const [status, setStatus] = useState<ProjectStatus>("planned");
|
|
const [priority, setPriority] = useState<ProjectPriority>("none");
|
|
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);
|
|
|
|
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,
|
|
});
|
|
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) => {
|
|
setIcon(emoji);
|
|
setIconPickerOpen(false);
|
|
}}
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
<TitleEditor
|
|
autoFocus
|
|
defaultValue=""
|
|
placeholder="Project title"
|
|
className="text-lg font-semibold"
|
|
onChange={(v) => setTitle(v)}
|
|
onSubmit={handleSubmit}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex-1 min-h-0 overflow-y-auto px-5">
|
|
<ContentEditor
|
|
ref={descEditorRef}
|
|
defaultValue=""
|
|
placeholder="Add description..."
|
|
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={() => setStatus(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={() => setPriority(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} />
|
|
<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>
|
|
|
|
<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>
|
|
);
|
|
}
|