forked from farhoodlabs/paperclip
a4ba4a72cd
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>
269 lines
9.2 KiB
TypeScript
269 lines
9.2 KiB
TypeScript
import { useRef, useState } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { useDialog } from "../context/DialogContext";
|
|
import { useCompany } from "../context/CompanyContext";
|
|
import { projectsApi } from "../api/projects";
|
|
import { goalsApi } from "../api/goals";
|
|
import { assetsApi } from "../api/assets";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
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,
|
|
} from "lucide-react";
|
|
import { cn } from "../lib/utils";
|
|
import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor";
|
|
import { StatusBadge } from "./StatusBadge";
|
|
import type { Goal } from "@paperclip/shared";
|
|
|
|
const projectStatuses = [
|
|
{ value: "backlog", label: "Backlog" },
|
|
{ value: "planned", label: "Planned" },
|
|
{ value: "in_progress", label: "In Progress" },
|
|
{ value: "completed", label: "Completed" },
|
|
{ value: "cancelled", label: "Cancelled" },
|
|
];
|
|
|
|
export function NewProjectDialog() {
|
|
const { newProjectOpen, closeNewProject } = useDialog();
|
|
const { selectedCompanyId, selectedCompany } = useCompany();
|
|
const queryClient = useQueryClient();
|
|
const [name, setName] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [status, setStatus] = useState("planned");
|
|
const [goalId, setGoalId] = useState("");
|
|
const [targetDate, setTargetDate] = useState("");
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
const [statusOpen, setStatusOpen] = useState(false);
|
|
const [goalOpen, setGoalOpen] = useState(false);
|
|
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
|
|
|
|
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),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(selectedCompanyId!) });
|
|
reset();
|
|
closeNewProject();
|
|
},
|
|
});
|
|
|
|
const uploadDescriptionImage = useMutation({
|
|
mutationFn: async (file: File) => {
|
|
if (!selectedCompanyId) throw new Error("No company selected");
|
|
return assetsApi.uploadImage(selectedCompanyId, file, "projects/drafts");
|
|
},
|
|
});
|
|
|
|
function reset() {
|
|
setName("");
|
|
setDescription("");
|
|
setStatus("planned");
|
|
setGoalId("");
|
|
setTargetDate("");
|
|
setExpanded(false);
|
|
}
|
|
|
|
function handleSubmit() {
|
|
if (!selectedCompanyId || !name.trim()) return;
|
|
createProject.mutate({
|
|
name: name.trim(),
|
|
description: description.trim() || undefined,
|
|
status,
|
|
...(goalId ? { goalId } : {}),
|
|
...(targetDate ? { targetDate } : {}),
|
|
});
|
|
}
|
|
|
|
function handleKeyDown(e: React.KeyboardEvent) {
|
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
e.preventDefault();
|
|
handleSubmit();
|
|
}
|
|
}
|
|
|
|
const currentGoal = (goals ?? []).find((g) => g.id === goalId);
|
|
|
|
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 */}
|
|
<div className="px-4 pt-3">
|
|
<input
|
|
className="w-full text-base font-medium bg-transparent outline-none placeholder:text-muted-foreground/50"
|
|
placeholder="Project name"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Tab" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
descriptionEditorRef.current?.focus();
|
|
}
|
|
}}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div className="px-4 pb-2">
|
|
<MarkdownEditor
|
|
ref={descriptionEditorRef}
|
|
value={description}
|
|
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;
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* 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>
|
|
|
|
{/* Goal */}
|
|
<Popover open={goalOpen} onOpenChange={setGoalOpen}>
|
|
<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">
|
|
<Target className="h-3 w-3 text-muted-foreground" />
|
|
{currentGoal ? currentGoal.title : "Goal"}
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-48 p-1" align="start">
|
|
<button
|
|
className={cn(
|
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
|
!goalId && "bg-accent"
|
|
)}
|
|
onClick={() => { setGoalId(""); setGoalOpen(false); }}
|
|
>
|
|
No goal
|
|
</button>
|
|
{(goals ?? []).map((g) => (
|
|
<button
|
|
key={g.id}
|
|
className={cn(
|
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 truncate",
|
|
g.id === goalId && "bg-accent"
|
|
)}
|
|
onClick={() => { setGoalId(g.id); setGoalOpen(false); }}
|
|
>
|
|
{g.title}
|
|
</button>
|
|
))}
|
|
</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 */}
|
|
<div className="flex items-center justify-end px-4 py-2.5 border-t border-border">
|
|
<Button
|
|
size="sm"
|
|
disabled={!name.trim() || createProject.isPending}
|
|
onClick={handleSubmit}
|
|
>
|
|
{createProject.isPending ? "Creating..." : "Create project"}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|