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>
This commit is contained in:
Forgotten
2026-02-20 12:50:45 -06:00
parent 0f4ab72888
commit a4ba4a72cd
16 changed files with 2221 additions and 30 deletions

View File

@@ -5,6 +5,7 @@ import { useCompany } from "../context/CompanyContext";
import { issuesApi } from "../api/issues";
import { projectsApi } from "../api/projects";
import { agentsApi } from "../api/agents";
import { assetsApi } from "../api/assets";
import { queryKeys } from "../lib/queryKeys";
import {
Dialog,
@@ -31,6 +32,7 @@ import {
Calendar,
} from "lucide-react";
import { cn } from "../lib/utils";
import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor";
import type { Project, Agent } from "@paperclip/shared";
const DRAFT_KEY = "paperclip:issue-draft";
@@ -98,6 +100,7 @@ export function NewIssueDialog() {
const [assigneeSearch, setAssigneeSearch] = useState("");
const [projectOpen, setProjectOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
@@ -123,6 +126,13 @@ export function NewIssueDialog() {
},
});
const uploadDescriptionImage = useMutation({
mutationFn: async (file: File) => {
if (!selectedCompanyId) throw new Error("No company selected");
return assetsApi.uploadImage(selectedCompanyId, file, "issues/drafts");
},
});
// Debounced draft saving
const scheduleSave = useCallback(
(draft: IssueDraft) => {
@@ -263,20 +273,29 @@ export function NewIssueDialog() {
placeholder="Issue title"
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Tab" && !e.shiftKey) {
e.preventDefault();
descriptionEditorRef.current?.focus();
}
}}
autoFocus
/>
</div>
{/* Description */}
<div className={cn("px-4 pb-2", expanded ? "flex-1 min-h-0" : "")}>
<textarea
className={cn(
"w-full bg-transparent outline-none text-sm text-muted-foreground placeholder:text-muted-foreground/40 resize-none",
expanded ? "h-full" : "min-h-[60px]"
)}
placeholder="Add description..."
<MarkdownEditor
ref={descriptionEditorRef}
value={description}
onChange={(e) => setDescription(e.target.value)}
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>