Add MarkdownEditor component, asset image upload, and rich description editing
Introduce MarkdownEditor built on @mdxeditor/editor with headings,
lists, links, quotes, image upload with drag-and-drop, and themed CSS
integration. Add asset image upload API (routes, service, storage) and
wire image upload into InlineEditor multiline mode, NewIssueDialog,
NewProjectDialog, GoalDetail, IssueDetail, and ProjectDetail
description fields. Tighten prompt template editor styling.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:50:45 -06:00
|
|
|
import { useRef, useState } from "react";
|
2026-02-17 12:24:48 -06:00
|
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
2026-02-17 10:53:20 -06:00
|
|
|
import { useDialog } from "../context/DialogContext";
|
|
|
|
|
import { useCompany } from "../context/CompanyContext";
|
|
|
|
|
import { projectsApi } from "../api/projects";
|
|
|
|
|
import { goalsApi } from "../api/goals";
|
Add MarkdownEditor component, asset image upload, and rich description editing
Introduce MarkdownEditor built on @mdxeditor/editor with headings,
lists, links, quotes, image upload with drag-and-drop, and themed CSS
integration. Add asset image upload API (routes, service, storage) and
wire image upload into InlineEditor multiline mode, NewIssueDialog,
NewProjectDialog, GoalDetail, IssueDetail, and ProjectDetail
description fields. Tighten prompt template editor styling.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:50:45 -06:00
|
|
|
import { assetsApi } from "../api/assets";
|
2026-02-17 12:24:48 -06:00
|
|
|
import { queryKeys } from "../lib/queryKeys";
|
2026-02-17 10:53:20 -06:00
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
} from "@/components/ui/dialog";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import {
|
|
|
|
|
Popover,
|
|
|
|
|
PopoverContent,
|
|
|
|
|
PopoverTrigger,
|
|
|
|
|
} from "@/components/ui/popover";
|
|
|
|
|
import {
|
|
|
|
|
Maximize2,
|
|
|
|
|
Minimize2,
|
|
|
|
|
Target,
|
|
|
|
|
Calendar,
|
2026-02-20 15:48:42 -06:00
|
|
|
Plus,
|
|
|
|
|
X,
|
2026-03-19 07:17:49 -05:00
|
|
|
HelpCircle,
|
2026-02-17 10:53:20 -06:00
|
|
|
} from "lucide-react";
|
2026-03-19 07:17:49 -05:00
|
|
|
import {
|
|
|
|
|
Tooltip,
|
|
|
|
|
TooltipContent,
|
|
|
|
|
TooltipTrigger,
|
|
|
|
|
} from "@/components/ui/tooltip";
|
2026-03-03 08:45:26 -06:00
|
|
|
import { PROJECT_COLORS } from "@paperclipai/shared";
|
2026-02-17 10:53:20 -06:00
|
|
|
import { cn } from "../lib/utils";
|
Add MarkdownEditor component, asset image upload, and rich description editing
Introduce MarkdownEditor built on @mdxeditor/editor with headings,
lists, links, quotes, image upload with drag-and-drop, and themed CSS
integration. Add asset image upload API (routes, service, storage) and
wire image upload into InlineEditor multiline mode, NewIssueDialog,
NewProjectDialog, GoalDetail, IssueDetail, and ProjectDetail
description fields. Tighten prompt template editor styling.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:50:45 -06:00
|
|
|
import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor";
|
2026-02-17 10:53:20 -06:00
|
|
|
import { StatusBadge } from "./StatusBadge";
|
2026-03-02 16:09:07 -06:00
|
|
|
import { ChoosePathButton } from "./PathInstructionsModal";
|
2026-02-17 10:53:20 -06:00
|
|
|
|
|
|
|
|
const projectStatuses = [
|
|
|
|
|
{ value: "backlog", label: "Backlog" },
|
|
|
|
|
{ value: "planned", label: "Planned" },
|
|
|
|
|
{ value: "in_progress", label: "In Progress" },
|
|
|
|
|
{ value: "completed", label: "Completed" },
|
|
|
|
|
{ value: "cancelled", label: "Cancelled" },
|
|
|
|
|
];
|
|
|
|
|
|
2026-02-17 12:24:48 -06:00
|
|
|
export function NewProjectDialog() {
|
2026-02-17 10:53:20 -06:00
|
|
|
const { newProjectOpen, closeNewProject } = useDialog();
|
|
|
|
|
const { selectedCompanyId, selectedCompany } = useCompany();
|
2026-02-17 12:24:48 -06:00
|
|
|
const queryClient = useQueryClient();
|
2026-02-17 10:53:20 -06:00
|
|
|
const [name, setName] = useState("");
|
|
|
|
|
const [description, setDescription] = useState("");
|
|
|
|
|
const [status, setStatus] = useState("planned");
|
2026-02-20 15:48:42 -06:00
|
|
|
const [goalIds, setGoalIds] = useState<string[]>([]);
|
2026-02-17 10:53:20 -06:00
|
|
|
const [targetDate, setTargetDate] = useState("");
|
|
|
|
|
const [expanded, setExpanded] = useState(false);
|
2026-02-25 21:36:06 -06:00
|
|
|
const [workspaceLocalPath, setWorkspaceLocalPath] = useState("");
|
|
|
|
|
const [workspaceRepoUrl, setWorkspaceRepoUrl] = useState("");
|
|
|
|
|
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
|
2026-02-17 10:53:20 -06:00
|
|
|
|
|
|
|
|
const [statusOpen, setStatusOpen] = useState(false);
|
|
|
|
|
const [goalOpen, setGoalOpen] = useState(false);
|
Add MarkdownEditor component, asset image upload, and rich description editing
Introduce MarkdownEditor built on @mdxeditor/editor with headings,
lists, links, quotes, image upload with drag-and-drop, and themed CSS
integration. Add asset image upload API (routes, service, storage) and
wire image upload into InlineEditor multiline mode, NewIssueDialog,
NewProjectDialog, GoalDetail, IssueDetail, and ProjectDetail
description fields. Tighten prompt template editor styling.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:50:45 -06:00
|
|
|
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
|
2026-02-17 10:53:20 -06:00
|
|
|
|
2026-02-17 12:24:48 -06:00
|
|
|
const { data: goals } = useQuery({
|
|
|
|
|
queryKey: queryKeys.goals.list(selectedCompanyId!),
|
|
|
|
|
queryFn: () => goalsApi.list(selectedCompanyId!),
|
|
|
|
|
enabled: !!selectedCompanyId && newProjectOpen,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const createProject = useMutation({
|
|
|
|
|
mutationFn: (data: Record<string, unknown>) =>
|
|
|
|
|
projectsApi.create(selectedCompanyId!, data),
|
|
|
|
|
});
|
2026-02-17 10:53:20 -06:00
|
|
|
|
Add MarkdownEditor component, asset image upload, and rich description editing
Introduce MarkdownEditor built on @mdxeditor/editor with headings,
lists, links, quotes, image upload with drag-and-drop, and themed CSS
integration. Add asset image upload API (routes, service, storage) and
wire image upload into InlineEditor multiline mode, NewIssueDialog,
NewProjectDialog, GoalDetail, IssueDetail, and ProjectDetail
description fields. Tighten prompt template editor styling.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:50:45 -06:00
|
|
|
const uploadDescriptionImage = useMutation({
|
|
|
|
|
mutationFn: async (file: File) => {
|
|
|
|
|
if (!selectedCompanyId) throw new Error("No company selected");
|
|
|
|
|
return assetsApi.uploadImage(selectedCompanyId, file, "projects/drafts");
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-17 10:53:20 -06:00
|
|
|
function reset() {
|
|
|
|
|
setName("");
|
|
|
|
|
setDescription("");
|
|
|
|
|
setStatus("planned");
|
2026-02-20 15:48:42 -06:00
|
|
|
setGoalIds([]);
|
2026-02-17 10:53:20 -06:00
|
|
|
setTargetDate("");
|
|
|
|
|
setExpanded(false);
|
2026-02-25 21:36:06 -06:00
|
|
|
setWorkspaceLocalPath("");
|
|
|
|
|
setWorkspaceRepoUrl("");
|
|
|
|
|
setWorkspaceError(null);
|
2026-02-17 10:53:20 -06:00
|
|
|
}
|
|
|
|
|
|
2026-02-25 21:36:06 -06:00
|
|
|
const isAbsolutePath = (value: string) => value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
|
|
|
|
|
|
|
|
|
|
const isGitHubRepoUrl = (value: string) => {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = new URL(value);
|
|
|
|
|
const host = parsed.hostname.toLowerCase();
|
|
|
|
|
if (host !== "github.com" && host !== "www.github.com") return false;
|
|
|
|
|
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
|
|
|
return segments.length >= 2;
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const deriveWorkspaceNameFromPath = (value: string) => {
|
|
|
|
|
const normalized = value.trim().replace(/[\\/]+$/, "");
|
|
|
|
|
const segments = normalized.split(/[\\/]/).filter(Boolean);
|
|
|
|
|
return segments[segments.length - 1] ?? "Local folder";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const deriveWorkspaceNameFromRepo = (value: string) => {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = new URL(value);
|
|
|
|
|
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
|
|
|
const repo = segments[segments.length - 1]?.replace(/\.git$/i, "") ?? "";
|
|
|
|
|
return repo || "GitHub repo";
|
|
|
|
|
} catch {
|
|
|
|
|
return "GitHub repo";
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
async function handleSubmit() {
|
2026-02-17 10:53:20 -06:00
|
|
|
if (!selectedCompanyId || !name.trim()) return;
|
2026-02-25 21:36:06 -06:00
|
|
|
const localPath = workspaceLocalPath.trim();
|
|
|
|
|
const repoUrl = workspaceRepoUrl.trim();
|
|
|
|
|
|
2026-03-19 07:17:49 -05:00
|
|
|
if (localPath && !isAbsolutePath(localPath)) {
|
2026-02-25 21:36:06 -06:00
|
|
|
setWorkspaceError("Local folder must be a full absolute path.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-19 07:17:49 -05:00
|
|
|
if (repoUrl && !isGitHubRepoUrl(repoUrl)) {
|
2026-03-16 15:56:37 -05:00
|
|
|
setWorkspaceError("Repo must use a valid GitHub repo URL.");
|
2026-02-25 21:36:06 -06:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setWorkspaceError(null);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const created = await createProject.mutateAsync({
|
|
|
|
|
name: name.trim(),
|
|
|
|
|
description: description.trim() || undefined,
|
|
|
|
|
status,
|
|
|
|
|
color: PROJECT_COLORS[Math.floor(Math.random() * PROJECT_COLORS.length)],
|
|
|
|
|
...(goalIds.length > 0 ? { goalIds } : {}),
|
|
|
|
|
...(targetDate ? { targetDate } : {}),
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-19 07:17:49 -05:00
|
|
|
if (localPath || repoUrl) {
|
|
|
|
|
const workspacePayload: Record<string, unknown> = {
|
|
|
|
|
name: localPath
|
|
|
|
|
? deriveWorkspaceNameFromPath(localPath)
|
|
|
|
|
: deriveWorkspaceNameFromRepo(repoUrl),
|
|
|
|
|
...(localPath ? { cwd: localPath } : {}),
|
|
|
|
|
...(repoUrl ? { repoUrl } : {}),
|
|
|
|
|
};
|
|
|
|
|
await projectsApi.createWorkspace(created.id, workspacePayload);
|
2026-02-25 21:36:06 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(selectedCompanyId) });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(created.id) });
|
|
|
|
|
reset();
|
|
|
|
|
closeNewProject();
|
|
|
|
|
} catch {
|
|
|
|
|
// surface through createProject.isError
|
|
|
|
|
}
|
2026-02-17 10:53:20 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleKeyDown(e: React.KeyboardEvent) {
|
|
|
|
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
handleSubmit();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 15:48:42 -06:00
|
|
|
const selectedGoals = (goals ?? []).filter((g) => goalIds.includes(g.id));
|
|
|
|
|
const availableGoals = (goals ?? []).filter((g) => !goalIds.includes(g.id));
|
2026-02-17 10:53:20 -06:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dialog
|
|
|
|
|
open={newProjectOpen}
|
|
|
|
|
onOpenChange={(open) => {
|
|
|
|
|
if (!open) {
|
|
|
|
|
reset();
|
|
|
|
|
closeNewProject();
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<DialogContent
|
|
|
|
|
showCloseButton={false}
|
|
|
|
|
className={cn("p-0 gap-0", expanded ? "sm:max-w-2xl" : "sm:max-w-lg")}
|
|
|
|
|
onKeyDown={handleKeyDown}
|
|
|
|
|
>
|
|
|
|
|
{/* Header */}
|
|
|
|
|
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
|
|
|
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
|
|
|
{selectedCompany && (
|
|
|
|
|
<span className="bg-muted px-1.5 py-0.5 rounded text-xs font-medium">
|
|
|
|
|
{selectedCompany.name.slice(0, 3).toUpperCase()}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
<span className="text-muted-foreground/60">›</span>
|
|
|
|
|
<span>New project</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon-xs"
|
|
|
|
|
className="text-muted-foreground"
|
|
|
|
|
onClick={() => setExpanded(!expanded)}
|
|
|
|
|
>
|
|
|
|
|
{expanded ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon-xs"
|
|
|
|
|
className="text-muted-foreground"
|
|
|
|
|
onClick={() => { reset(); closeNewProject(); }}
|
|
|
|
|
>
|
|
|
|
|
<span className="text-lg leading-none">×</span>
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Name */}
|
2026-02-23 14:41:21 -06:00
|
|
|
<div className="px-4 pt-4 pb-2 shrink-0">
|
2026-02-17 10:53:20 -06:00
|
|
|
<input
|
2026-02-23 14:41:21 -06:00
|
|
|
className="w-full text-lg font-semibold bg-transparent outline-none placeholder:text-muted-foreground/50"
|
2026-02-17 10:53:20 -06:00
|
|
|
placeholder="Project name"
|
|
|
|
|
value={name}
|
|
|
|
|
onChange={(e) => setName(e.target.value)}
|
Add MarkdownEditor component, asset image upload, and rich description editing
Introduce MarkdownEditor built on @mdxeditor/editor with headings,
lists, links, quotes, image upload with drag-and-drop, and themed CSS
integration. Add asset image upload API (routes, service, storage) and
wire image upload into InlineEditor multiline mode, NewIssueDialog,
NewProjectDialog, GoalDetail, IssueDetail, and ProjectDetail
description fields. Tighten prompt template editor styling.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:50:45 -06:00
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === "Tab" && !e.shiftKey) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
descriptionEditorRef.current?.focus();
|
|
|
|
|
}
|
|
|
|
|
}}
|
2026-02-17 10:53:20 -06:00
|
|
|
autoFocus
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Description */}
|
|
|
|
|
<div className="px-4 pb-2">
|
Add MarkdownEditor component, asset image upload, and rich description editing
Introduce MarkdownEditor built on @mdxeditor/editor with headings,
lists, links, quotes, image upload with drag-and-drop, and themed CSS
integration. Add asset image upload API (routes, service, storage) and
wire image upload into InlineEditor multiline mode, NewIssueDialog,
NewProjectDialog, GoalDetail, IssueDetail, and ProjectDetail
description fields. Tighten prompt template editor styling.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:50:45 -06:00
|
|
|
<MarkdownEditor
|
|
|
|
|
ref={descriptionEditorRef}
|
2026-02-17 10:53:20 -06:00
|
|
|
value={description}
|
Add MarkdownEditor component, asset image upload, and rich description editing
Introduce MarkdownEditor built on @mdxeditor/editor with headings,
lists, links, quotes, image upload with drag-and-drop, and themed CSS
integration. Add asset image upload API (routes, service, storage) and
wire image upload into InlineEditor multiline mode, NewIssueDialog,
NewProjectDialog, GoalDetail, IssueDetail, and ProjectDetail
description fields. Tighten prompt template editor styling.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:50:45 -06:00
|
|
|
onChange={setDescription}
|
|
|
|
|
placeholder="Add description..."
|
|
|
|
|
bordered={false}
|
|
|
|
|
contentClassName={cn("text-sm text-muted-foreground", expanded ? "min-h-[220px]" : "min-h-[120px]")}
|
|
|
|
|
imageUploadHandler={async (file) => {
|
|
|
|
|
const asset = await uploadDescriptionImage.mutateAsync(file);
|
|
|
|
|
return asset.contentPath;
|
|
|
|
|
}}
|
2026-02-17 10:53:20 -06:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-19 07:26:28 -05:00
|
|
|
<div className="px-4 pt-3 pb-3 space-y-3 border-t border-border">
|
|
|
|
|
<div>
|
2026-03-19 07:17:49 -05:00
|
|
|
<div className="mb-1 flex items-center gap-1.5">
|
|
|
|
|
<label className="block text-xs text-muted-foreground">Repo URL</label>
|
|
|
|
|
<span className="text-xs text-muted-foreground/50">optional</span>
|
|
|
|
|
<Tooltip delayDuration={300}>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<HelpCircle className="h-3 w-3 text-muted-foreground/50 cursor-help" />
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent side="top" className="max-w-[240px] text-xs">
|
|
|
|
|
Link a GitHub repository so agents can clone, read, and push code for this project.
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
</div>
|
|
|
|
|
<input
|
|
|
|
|
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
|
|
|
|
|
value={workspaceRepoUrl}
|
|
|
|
|
onChange={(e) => { setWorkspaceRepoUrl(e.target.value); setWorkspaceError(null); }}
|
|
|
|
|
placeholder="https://github.com/org/repo"
|
|
|
|
|
/>
|
2026-02-25 21:36:06 -06:00
|
|
|
</div>
|
|
|
|
|
|
2026-03-19 07:26:28 -05:00
|
|
|
<div>
|
2026-03-19 07:17:49 -05:00
|
|
|
<div className="mb-1 flex items-center gap-1.5">
|
|
|
|
|
<label className="block text-xs text-muted-foreground">Local folder</label>
|
|
|
|
|
<span className="text-xs text-muted-foreground/50">optional</span>
|
|
|
|
|
<Tooltip delayDuration={300}>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<HelpCircle className="h-3 w-3 text-muted-foreground/50 cursor-help" />
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent side="top" className="max-w-[240px] text-xs">
|
|
|
|
|
Set an absolute path on this machine where local agents will read and write files for this project.
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
</Tooltip>
|
2026-02-25 21:36:06 -06:00
|
|
|
</div>
|
2026-03-19 07:17:49 -05:00
|
|
|
<div className="flex items-center gap-2">
|
2026-02-25 21:36:06 -06:00
|
|
|
<input
|
2026-03-19 07:17:49 -05:00
|
|
|
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
|
|
|
|
|
value={workspaceLocalPath}
|
|
|
|
|
onChange={(e) => { setWorkspaceLocalPath(e.target.value); setWorkspaceError(null); }}
|
|
|
|
|
placeholder="/absolute/path/to/workspace"
|
2026-02-25 21:36:06 -06:00
|
|
|
/>
|
2026-03-19 07:17:49 -05:00
|
|
|
<ChoosePathButton />
|
2026-02-25 21:36:06 -06:00
|
|
|
</div>
|
2026-03-19 07:17:49 -05:00
|
|
|
</div>
|
|
|
|
|
|
2026-02-25 21:36:06 -06:00
|
|
|
{workspaceError && (
|
|
|
|
|
<p className="text-xs text-destructive">{workspaceError}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-02-17 10:53:20 -06:00
|
|
|
{/* Property chips */}
|
|
|
|
|
<div className="flex items-center gap-1.5 px-4 py-2 border-t border-border flex-wrap">
|
|
|
|
|
{/* Status */}
|
|
|
|
|
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
|
|
|
|
|
<StatusBadge status={status} />
|
|
|
|
|
</button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent className="w-40 p-1" align="start">
|
|
|
|
|
{projectStatuses.map((s) => (
|
|
|
|
|
<button
|
|
|
|
|
key={s.value}
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
|
|
|
|
s.value === status && "bg-accent"
|
|
|
|
|
)}
|
|
|
|
|
onClick={() => { setStatus(s.value); setStatusOpen(false); }}
|
|
|
|
|
>
|
|
|
|
|
{s.label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
|
2026-02-20 15:48:42 -06:00
|
|
|
{selectedGoals.map((goal) => (
|
|
|
|
|
<span
|
|
|
|
|
key={goal.id}
|
|
|
|
|
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs"
|
|
|
|
|
>
|
|
|
|
|
<Target className="h-3 w-3 text-muted-foreground" />
|
|
|
|
|
<span className="max-w-[160px] truncate">{goal.title}</span>
|
|
|
|
|
<button
|
|
|
|
|
className="text-muted-foreground hover:text-foreground"
|
|
|
|
|
onClick={() => setGoalIds((prev) => prev.filter((id) => id !== goal.id))}
|
|
|
|
|
aria-label={`Remove goal ${goal.title}`}
|
|
|
|
|
type="button"
|
|
|
|
|
>
|
|
|
|
|
<X className="h-3 w-3" />
|
|
|
|
|
</button>
|
|
|
|
|
</span>
|
|
|
|
|
))}
|
|
|
|
|
|
2026-02-17 10:53:20 -06:00
|
|
|
<Popover open={goalOpen} onOpenChange={setGoalOpen}>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<button
|
2026-02-20 15:48:42 -06:00
|
|
|
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors disabled:opacity-60"
|
|
|
|
|
disabled={selectedGoals.length > 0 && availableGoals.length === 0}
|
2026-02-17 10:53:20 -06:00
|
|
|
>
|
2026-02-20 15:48:42 -06:00
|
|
|
{selectedGoals.length > 0 ? <Plus className="h-3 w-3 text-muted-foreground" /> : <Target className="h-3 w-3 text-muted-foreground" />}
|
|
|
|
|
{selectedGoals.length > 0 ? "+ Goal" : "Goal"}
|
2026-02-17 10:53:20 -06:00
|
|
|
</button>
|
2026-02-20 15:48:42 -06:00
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent className="w-56 p-1" align="start">
|
|
|
|
|
{selectedGoals.length === 0 && (
|
|
|
|
|
<button
|
|
|
|
|
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground"
|
|
|
|
|
onClick={() => setGoalOpen(false)}
|
|
|
|
|
>
|
|
|
|
|
No goal
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
{availableGoals.map((g) => (
|
2026-02-17 10:53:20 -06:00
|
|
|
<button
|
|
|
|
|
key={g.id}
|
2026-02-20 15:48:42 -06:00
|
|
|
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 truncate"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setGoalIds((prev) => [...prev, g.id]);
|
|
|
|
|
setGoalOpen(false);
|
|
|
|
|
}}
|
2026-02-17 10:53:20 -06:00
|
|
|
>
|
|
|
|
|
{g.title}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
2026-02-20 15:48:42 -06:00
|
|
|
{selectedGoals.length > 0 && availableGoals.length === 0 && (
|
|
|
|
|
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
|
|
|
|
All goals already selected.
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-02-17 10:53:20 -06:00
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
|
|
|
|
|
{/* Target date */}
|
|
|
|
|
<div className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs">
|
|
|
|
|
<Calendar className="h-3 w-3 text-muted-foreground" />
|
|
|
|
|
<input
|
|
|
|
|
type="date"
|
|
|
|
|
className="bg-transparent outline-none text-xs w-24"
|
|
|
|
|
value={targetDate}
|
|
|
|
|
onChange={(e) => setTargetDate(e.target.value)}
|
|
|
|
|
placeholder="Target date"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Footer */}
|
2026-02-25 21:36:06 -06:00
|
|
|
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border">
|
|
|
|
|
{createProject.isError ? (
|
|
|
|
|
<p className="text-xs text-destructive">Failed to create project.</p>
|
|
|
|
|
) : (
|
|
|
|
|
<span />
|
|
|
|
|
)}
|
2026-02-17 10:53:20 -06:00
|
|
|
<Button
|
|
|
|
|
size="sm"
|
2026-02-17 12:24:48 -06:00
|
|
|
disabled={!name.trim() || createProject.isPending}
|
2026-02-17 10:53:20 -06:00
|
|
|
onClick={handleSubmit}
|
|
|
|
|
>
|
2026-03-02 16:44:03 -06:00
|
|
|
{createProject.isPending ? "Creating…" : "Create project"}
|
2026-02-17 10:53:20 -06:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|