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:
1587
pnpm-lock.yaml
generated
1587
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
153
server/src/routes/assets.ts
Normal 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;
|
||||
}
|
||||
|
||||
22
server/src/services/assets.ts
Normal file
22
server/src/services/assets.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
14
ui/src/api/assets.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
159
ui/src/components/MarkdownEditor.tsx
Normal file
159
ui/src/components/MarkdownEditor.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
167
ui/src/index.css
167
ui/src/index.css
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user