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

1587
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,7 @@ import { activityRoutes } from "./routes/activity.js";
import { dashboardRoutes } from "./routes/dashboard.js";
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
import { llmRoutes } from "./routes/llms.js";
import { assetRoutes } from "./routes/assets.js";
type UiMode = "none" | "static" | "vite-dev";
@@ -35,6 +36,7 @@ export async function createApp(db: Db, opts: { uiMode: UiMode; storageService:
api.use("/health", healthRoutes());
api.use("/companies", companyRoutes(db));
api.use(agentRoutes(db));
api.use(assetRoutes(db, opts.storageService));
api.use(projectRoutes(db));
api.use(issueRoutes(db, opts.storageService));
api.use(goalRoutes(db));

153
server/src/routes/assets.ts Normal file
View File

@@ -0,0 +1,153 @@
import { Router, type Request, type Response } from "express";
import multer from "multer";
import type { Db } from "@paperclip/db";
import { createAssetImageMetadataSchema } from "@paperclip/shared";
import type { StorageService } from "../storage/types.js";
import { assetService, logActivity } from "../services/index.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
const MAX_ASSET_IMAGE_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
const ALLOWED_IMAGE_CONTENT_TYPES = new Set([
"image/png",
"image/jpeg",
"image/jpg",
"image/webp",
"image/gif",
]);
export function assetRoutes(db: Db, storage: StorageService) {
const router = Router();
const svc = assetService(db);
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_ASSET_IMAGE_BYTES, files: 1 },
});
async function runSingleFileUpload(req: Request, res: Response) {
await new Promise<void>((resolve, reject) => {
upload.single("file")(req, res, (err: unknown) => {
if (err) reject(err);
else resolve();
});
});
}
router.post("/companies/:companyId/assets/images", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
try {
await runSingleFileUpload(req, res);
} catch (err) {
if (err instanceof multer.MulterError) {
if (err.code === "LIMIT_FILE_SIZE") {
res.status(422).json({ error: `Image exceeds ${MAX_ASSET_IMAGE_BYTES} bytes` });
return;
}
res.status(400).json({ error: err.message });
return;
}
throw err;
}
const file = (req as Request & { file?: { mimetype: string; buffer: Buffer; originalname: string } }).file;
if (!file) {
res.status(400).json({ error: "Missing file field 'file'" });
return;
}
const contentType = (file.mimetype || "").toLowerCase();
if (!ALLOWED_IMAGE_CONTENT_TYPES.has(contentType)) {
res.status(422).json({ error: `Unsupported image type: ${contentType || "unknown"}` });
return;
}
if (file.buffer.length <= 0) {
res.status(422).json({ error: "Image is empty" });
return;
}
const parsedMeta = createAssetImageMetadataSchema.safeParse(req.body ?? {});
if (!parsedMeta.success) {
res.status(400).json({ error: "Invalid image metadata", details: parsedMeta.error.issues });
return;
}
const namespaceSuffix = parsedMeta.data.namespace ?? "general";
const actor = getActorInfo(req);
const stored = await storage.putFile({
companyId,
namespace: `assets/${namespaceSuffix}`,
originalFilename: file.originalname || null,
contentType,
body: file.buffer,
});
const asset = await svc.create(companyId, {
provider: stored.provider,
objectKey: stored.objectKey,
contentType: stored.contentType,
byteSize: stored.byteSize,
sha256: stored.sha256,
originalFilename: stored.originalFilename,
createdByAgentId: actor.agentId,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
});
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "asset.created",
entityType: "asset",
entityId: asset.id,
details: {
originalFilename: asset.originalFilename,
contentType: asset.contentType,
byteSize: asset.byteSize,
},
});
res.status(201).json({
assetId: asset.id,
companyId: asset.companyId,
provider: asset.provider,
objectKey: asset.objectKey,
contentType: asset.contentType,
byteSize: asset.byteSize,
sha256: asset.sha256,
originalFilename: asset.originalFilename,
createdByAgentId: asset.createdByAgentId,
createdByUserId: asset.createdByUserId,
createdAt: asset.createdAt,
updatedAt: asset.updatedAt,
contentPath: `/api/assets/${asset.id}/content`,
});
});
router.get("/assets/:assetId/content", async (req, res, next) => {
const assetId = req.params.assetId as string;
const asset = await svc.getById(assetId);
if (!asset) {
res.status(404).json({ error: "Asset not found" });
return;
}
assertCompanyAccess(req, asset.companyId);
const object = await storage.getObject(asset.companyId, asset.objectKey);
res.setHeader("Content-Type", asset.contentType || object.contentType || "application/octet-stream");
res.setHeader("Content-Length", String(asset.byteSize || object.contentLength || 0));
res.setHeader("Cache-Control", "private, max-age=60");
const filename = asset.originalFilename ?? "asset";
res.setHeader("Content-Disposition", `inline; filename=\"${filename.replaceAll("\"", "")}\"`);
object.stream.on("error", (err) => {
next(err);
});
object.stream.pipe(res);
});
return router;
}

View File

@@ -0,0 +1,22 @@
import { eq } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { assets } from "@paperclip/db";
export function assetService(db: Db) {
return {
create: (companyId: string, data: Omit<typeof assets.$inferInsert, "companyId">) =>
db
.insert(assets)
.values({ ...data, companyId })
.returning()
.then((rows) => rows[0]),
getById: (id: string) =>
db
.select()
.from(assets)
.where(eq(assets.id, id))
.then((rows) => rows[0] ?? null),
};
}

View File

@@ -1,5 +1,6 @@
export { companyService } from "./companies.js";
export { agentService } from "./agents.js";
export { assetService } from "./assets.js";
export { projectService } from "./projects.js";
export { issueService, type IssueFilters } from "./issues.js";
export { issueApprovalService } from "./issue-approvals.js";

View File

@@ -10,6 +10,7 @@
"typecheck": "tsc -b"
},
"dependencies": {
"@mdxeditor/editor": "^3.52.4",
"@paperclip/adapter-claude-local": "workspace:*",
"@paperclip/adapter-codex-local": "workspace:*",
"@paperclip/adapter-utils": "workspace:*",

14
ui/src/api/assets.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { AssetImage } from "@paperclip/shared";
import { api } from "./client";
export const assetsApi = {
uploadImage: (companyId: string, file: File, namespace?: string) => {
const form = new FormData();
form.append("file", file);
if (namespace && namespace.trim().length > 0) {
form.append("namespace", namespace.trim());
}
return api.postForm<AssetImage>(`/companies/${companyId}/assets/images`, form);
},
};

View File

@@ -1,6 +1,8 @@
import { useState, useRef, useEffect, useCallback } from "react";
import Markdown from "react-markdown";
import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { MarkdownEditor } from "./MarkdownEditor";
interface InlineEditorProps {
value: string;
@@ -9,6 +11,7 @@ interface InlineEditorProps {
className?: string;
placeholder?: string;
multiline?: boolean;
imageUploadHandler?: (file: File) => Promise<string>;
}
/** Shared padding so display and edit modes occupy the exact same box. */
@@ -21,6 +24,7 @@ export function InlineEditor({
className,
placeholder = "Click to edit...",
multiline = false,
imageUploadHandler,
}: InlineEditorProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
@@ -68,6 +72,35 @@ export function InlineEditor({
}
if (editing) {
if (multiline) {
return (
<div className={cn("space-y-2", pad)}>
<MarkdownEditor
value={draft}
onChange={setDraft}
placeholder={placeholder}
contentClassName={className}
imageUploadHandler={imageUploadHandler}
/>
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setDraft(value);
setEditing(false);
}}
>
Cancel
</Button>
<Button size="sm" onClick={commit}>
Save
</Button>
</div>
</div>
);
}
const sharedProps = {
ref: inputRef as any,
value: draft,
@@ -81,21 +114,6 @@ export function InlineEditor({
onKeyDown: handleKeyDown,
};
if (multiline) {
return (
<textarea
{...sharedProps}
rows={1}
className={cn(
"w-full resize-none bg-accent/30 rounded outline-none",
pad,
"py-0.5",
className
)}
/>
);
}
return (
<input
type="text"

View File

@@ -0,0 +1,159 @@
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState, type DragEvent } from "react";
import {
MDXEditor,
type MDXEditorMethods,
headingsPlugin,
imagePlugin,
linkDialogPlugin,
linkPlugin,
listsPlugin,
markdownShortcutPlugin,
quotePlugin,
thematicBreakPlugin,
type RealmPlugin,
} from "@mdxeditor/editor";
import { cn } from "../lib/utils";
interface MarkdownEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
contentClassName?: string;
onBlur?: () => void;
imageUploadHandler?: (file: File) => Promise<string>;
bordered?: boolean;
}
export interface MarkdownEditorRef {
focus: () => void;
}
export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(function MarkdownEditor({
value,
onChange,
placeholder,
className,
contentClassName,
onBlur,
imageUploadHandler,
bordered = true,
}: MarkdownEditorProps, forwardedRef) {
const containerRef = useRef<HTMLDivElement>(null);
const ref = useRef<MDXEditorMethods>(null);
const latestValueRef = useRef(value);
const [uploadError, setUploadError] = useState<string | null>(null);
const [isDragOver, setIsDragOver] = useState(false);
const dragDepthRef = useRef(0);
useImperativeHandle(forwardedRef, () => ({
focus: () => {
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
},
}), []);
const plugins = useMemo<RealmPlugin[]>(() => {
const imageHandler = imageUploadHandler
? async (file: File) => {
try {
const src = await imageUploadHandler(file);
setUploadError(null);
return src;
} catch (err) {
const message = err instanceof Error ? err.message : "Image upload failed";
setUploadError(message);
throw err;
}
}
: undefined;
const withImage = Boolean(imageHandler);
const all: RealmPlugin[] = [
headingsPlugin(),
listsPlugin(),
quotePlugin(),
linkPlugin(),
linkDialogPlugin(),
thematicBreakPlugin(),
markdownShortcutPlugin(),
];
if (imageHandler) {
all.push(imagePlugin({ imageUploadHandler: imageHandler }));
}
return all;
}, [imageUploadHandler]);
useEffect(() => {
if (value !== latestValueRef.current) {
ref.current?.setMarkdown(value);
latestValueRef.current = value;
}
}, [value]);
function hasFilePayload(evt: DragEvent<HTMLDivElement>) {
return Array.from(evt.dataTransfer?.types ?? []).includes("Files");
}
const canDropImage = Boolean(imageUploadHandler);
return (
<div
ref={containerRef}
className={cn(
"relative paperclip-mdxeditor-scope",
bordered ? "rounded-md border border-border bg-transparent" : "bg-transparent",
isDragOver && "ring-1 ring-primary/60 bg-accent/20",
className,
)}
onDragEnter={(evt) => {
if (!canDropImage || !hasFilePayload(evt)) return;
dragDepthRef.current += 1;
setIsDragOver(true);
}}
onDragOver={(evt) => {
if (!canDropImage || !hasFilePayload(evt)) return;
evt.preventDefault();
evt.dataTransfer.dropEffect = "copy";
}}
onDragLeave={(evt) => {
if (!canDropImage) return;
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) setIsDragOver(false);
}}
onDrop={() => {
dragDepthRef.current = 0;
setIsDragOver(false);
}}
>
<MDXEditor
ref={ref}
markdown={value}
placeholder={placeholder}
onChange={(next) => {
latestValueRef.current = next;
onChange(next);
}}
onBlur={() => onBlur?.()}
className={cn("paperclip-mdxeditor", !bordered && "paperclip-mdxeditor--borderless")}
contentEditableClassName={cn(
"paperclip-mdxeditor-content focus:outline-none",
contentClassName,
)}
overlayContainer={containerRef.current}
plugins={plugins}
/>
{isDragOver && canDropImage && (
<div
className={cn(
"pointer-events-none absolute inset-1 z-40 flex items-center justify-center rounded-md border border-dashed border-primary/80 bg-primary/10 text-xs font-medium text-primary",
!bordered && "inset-0 rounded-sm",
)}
>
Drop image to upload
</div>
)}
{uploadError && (
<p className="px-3 pb-2 text-xs text-destructive">{uploadError}</p>
)}
</div>
);
});

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>

View File

@@ -1,9 +1,10 @@
import { useState } from "react";
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,
@@ -22,6 +23,7 @@ import {
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";
@@ -46,6 +48,7 @@ export function NewProjectDialog() {
const [statusOpen, setStatusOpen] = useState(false);
const [goalOpen, setGoalOpen] = useState(false);
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
const { data: goals } = useQuery({
queryKey: queryKeys.goals.list(selectedCompanyId!),
@@ -63,6 +66,13 @@ export function NewProjectDialog() {
},
});
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("");
@@ -145,20 +155,29 @@ export function NewProjectDialog() {
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">
<textarea
className={cn(
"w-full bg-transparent outline-none text-sm text-muted-foreground placeholder:text-muted-foreground/40 resize-none",
expanded ? "min-h-[160px]" : "min-h-[48px]"
)}
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>

View File

@@ -191,3 +191,170 @@
animation: none;
}
}
/* MDXEditor theme integration */
.paperclip-mdxeditor-scope,
.paperclip-mdxeditor {
--baseBase: var(--background);
--baseBg: transparent;
--baseBgSubtle: color-mix(in oklab, var(--accent) 35%, transparent);
--baseLine: var(--border);
--baseSolid: var(--muted-foreground);
--baseSolidHover: var(--foreground);
--baseText: var(--muted-foreground);
--baseBorderColor: var(--border);
--baseBorder: var(--border);
--baseBorderHover: var(--ring);
--baseTextContrast: var(--foreground);
--baseTextContrastMuted: var(--muted-foreground);
--baseTextEmphasis: var(--foreground);
--basePageBg: var(--background);
--baseRadius: var(--radius);
--baseLineHeight: 1.5;
--accentBorder: color-mix(in oklab, var(--primary) 35%, var(--border));
--accentSolid: var(--primary);
--accentSolidHover: var(--primary);
--accentLine: color-mix(in oklab, var(--primary) 20%, transparent);
--accentBg: var(--accent);
--accentBgHover: color-mix(in oklab, var(--accent) 80%, var(--background));
--accentBgActive: color-mix(in oklab, var(--accent) 72%, var(--background));
--accentText: var(--accent-foreground);
font-family: inherit;
font-size: 0.875rem;
line-height: 1.5;
color: var(--foreground);
}
.paperclip-mdxeditor-scope [class*="_iconButton_"],
.paperclip-mdxeditor [class*="_iconButton_"] {
color: var(--baseText);
}
.paperclip-mdxeditor-scope [class*="_iconButton_"]:hover,
.paperclip-mdxeditor [class*="_iconButton_"]:hover {
color: var(--baseTextContrast);
}
.paperclip-mdxeditor .mdxeditor-root-contenteditable {
min-height: 2.5rem;
padding: 0.375rem 0.625rem;
line-height: 1.5;
}
.paperclip-mdxeditor--borderless .mdxeditor-root-contenteditable {
padding: 0.2rem 0;
}
.paperclip-mdxeditor [class*="_placeholder_"] {
padding: 0.375rem 0.625rem;
font-family: inherit;
font-size: 0.875rem;
line-height: 1.5;
color: var(--muted-foreground);
}
.paperclip-mdxeditor--borderless [class*="_placeholder_"] {
padding: 0.2rem 0;
}
.paperclip-mdxeditor-content {
font-family: inherit;
font-size: inherit;
line-height: inherit;
color: inherit;
}
.paperclip-mdxeditor-content > *:first-child {
margin-top: 0;
}
.paperclip-mdxeditor-content > *:last-child {
margin-bottom: 0;
}
.paperclip-mdxeditor-content p {
margin: 0;
}
.paperclip-mdxeditor-content p + p {
margin-top: 0.4rem;
}
.paperclip-mdxeditor-content ul,
.paperclip-mdxeditor-content ol {
margin: 0.35rem 0;
padding-left: 1.1rem;
}
.paperclip-mdxeditor-content ul {
list-style: disc;
}
.paperclip-mdxeditor-content ol {
list-style: decimal;
}
.paperclip-mdxeditor-content li {
display: list-item;
margin: 0.15rem 0;
}
.paperclip-mdxeditor-content li::marker {
color: var(--muted-foreground);
}
.paperclip-mdxeditor-content h1,
.paperclip-mdxeditor-content h2,
.paperclip-mdxeditor-content h3 {
margin: 0.4rem 0 0.25rem;
line-height: 1.3;
}
.paperclip-mdxeditor-content img {
max-height: 18rem;
border-radius: calc(var(--radius) - 2px);
}
.paperclip-mdxeditor-content blockquote {
margin: 0.45rem 0;
padding-left: 0.7rem;
border-left: 2px solid var(--border);
color: var(--muted-foreground);
}
.paperclip-mdxeditor-content code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.84em;
}
.paperclip-mdxeditor-content pre {
margin: 0.5rem 0;
padding: 0.45rem 0.55rem;
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 3px);
background: color-mix(in oklab, var(--accent) 50%, transparent);
overflow-x: auto;
}
/* Keep MDXEditor popups above app dialogs when editor is inside a modal. */
.paperclip-mdxeditor-scope [class*="_dialogOverlay_"],
.paperclip-mdxeditor [class*="_dialogOverlay_"] {
z-index: 80;
}
.paperclip-mdxeditor-scope [class*="_dialogContent_"],
.paperclip-mdxeditor-scope [class*="_largeDialogContent_"],
.paperclip-mdxeditor-scope [class*="_popoverContent_"],
.paperclip-mdxeditor-scope [class*="_linkDialogPopoverContent_"],
.paperclip-mdxeditor-scope [class*="_tableColumnEditorPopoverContent_"],
.paperclip-mdxeditor-scope [class*="_toolbarButtonDropdownContainer_"],
.paperclip-mdxeditor-scope [class*="_toolbarNodeKindSelectContainer_"],
.paperclip-mdxeditor [class*="_dialogContent_"],
.paperclip-mdxeditor [class*="_largeDialogContent_"],
.paperclip-mdxeditor [class*="_popoverContent_"],
.paperclip-mdxeditor [class*="_linkDialogPopoverContent_"],
.paperclip-mdxeditor [class*="_tableColumnEditorPopoverContent_"],
.paperclip-mdxeditor [class*="_toolbarButtonDropdownContainer_"],
.paperclip-mdxeditor [class*="_toolbarNodeKindSelectContainer_"] {
z-index: 81 !important;
}

View File

@@ -10,6 +10,7 @@ import { PanelProvider } from "./context/PanelContext";
import { SidebarProvider } from "./context/SidebarContext";
import { DialogProvider } from "./context/DialogContext";
import { TooltipProvider } from "@/components/ui/tooltip";
import "@mdxeditor/editor/style.css";
import "./index.css";
const queryClient = new QueryClient({

View File

@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { goalsApi } from "../api/goals";
import { projectsApi } from "../api/projects";
import { assetsApi } from "../api/assets";
import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
@@ -51,6 +52,13 @@ export function GoalDetail() {
},
});
const uploadImage = useMutation({
mutationFn: async (file: File) => {
if (!selectedCompanyId) throw new Error("No company selected");
return assetsApi.uploadImage(selectedCompanyId, file, `goals/${goalId ?? "draft"}`);
},
});
const childGoals = (allGoals ?? []).filter((g) => g.parentId === goalId);
const linkedProjects = (allProjects ?? []).filter((p) => p.goalId === goalId);
@@ -96,6 +104,10 @@ export function GoalDetail() {
className="text-sm text-muted-foreground"
placeholder="Add a description..."
multiline
imageUploadHandler={async (file) => {
const asset = await uploadImage.mutateAsync(file);
return asset.contentPath;
}}
/>
</div>

View File

@@ -454,6 +454,10 @@ export function IssueDetail() {
className="text-sm text-muted-foreground"
placeholder="Add a description..."
multiline
imageUploadHandler={async (file) => {
const attachment = await uploadAttachment.mutateAsync(file);
return attachment.contentPath;
}}
/>
</div>

View File

@@ -3,6 +3,7 @@ import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { projectsApi } from "../api/projects";
import { issuesApi } from "../api/issues";
import { assetsApi } from "../api/assets";
import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
@@ -48,6 +49,13 @@ export function ProjectDetail() {
onSuccess: invalidateProject,
});
const uploadImage = useMutation({
mutationFn: async (file: File) => {
if (!selectedCompanyId) throw new Error("No company selected");
return assetsApi.uploadImage(selectedCompanyId, file, `projects/${projectId ?? "draft"}`);
},
});
useEffect(() => {
setBreadcrumbs([
{ label: "Projects", href: "/projects" },
@@ -83,6 +91,10 @@ export function ProjectDetail() {
className="text-sm text-muted-foreground"
placeholder="Add a description..."
multiline
imageUploadHandler={async (file) => {
const asset = await uploadImage.mutateAsync(file);
return asset.contentPath;
}}
/>
</div>