feat(issues): add issue documents and inline editing
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -12,6 +12,9 @@ const ACTION_VERBS: Record<string, string> = {
|
||||
"issue.comment_added": "commented on",
|
||||
"issue.attachment_added": "attached file to",
|
||||
"issue.attachment_removed": "removed attachment from",
|
||||
"issue.document_created": "created document for",
|
||||
"issue.document_updated": "updated document on",
|
||||
"issue.document_deleted": "deleted document from",
|
||||
"issue.commented": "commented on",
|
||||
"issue.deleted": "deleted",
|
||||
"agent.created": "created",
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
|
||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
|
||||
|
||||
interface InlineEditorProps {
|
||||
value: string;
|
||||
onSave: (value: string) => void;
|
||||
onSave: (value: string) => void | Promise<unknown>;
|
||||
as?: "h1" | "h2" | "p" | "span";
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
@@ -17,6 +16,8 @@ interface InlineEditorProps {
|
||||
|
||||
/** Shared padding so display and edit modes occupy the exact same box. */
|
||||
const pad = "px-1 -mx-1";
|
||||
const markdownPad = "px-1";
|
||||
const AUTOSAVE_DEBOUNCE_MS = 900;
|
||||
|
||||
export function InlineEditor({
|
||||
value,
|
||||
@@ -29,12 +30,30 @@ export function InlineEditor({
|
||||
mentions,
|
||||
}: InlineEditorProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [multilineFocused, setMultilineFocused] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const markdownRef = useRef<MarkdownEditorRef>(null);
|
||||
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const {
|
||||
state: autosaveState,
|
||||
markDirty,
|
||||
reset,
|
||||
runSave,
|
||||
} = useAutosaveIndicator();
|
||||
|
||||
useEffect(() => {
|
||||
if (multiline && multilineFocused) return;
|
||||
setDraft(value);
|
||||
}, [value]);
|
||||
}, [value, multiline, multilineFocused]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const autoSize = useCallback((el: HTMLTextAreaElement | null) => {
|
||||
if (!el) return;
|
||||
@@ -52,58 +71,140 @@ export function InlineEditor({
|
||||
}
|
||||
}, [editing, autoSize]);
|
||||
|
||||
function commit() {
|
||||
const trimmed = draft.trim();
|
||||
useEffect(() => {
|
||||
if (!editing || !multiline) return;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
markdownRef.current?.focus();
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [editing, multiline]);
|
||||
|
||||
const commit = useCallback(async (nextValue = draft) => {
|
||||
const trimmed = nextValue.trim();
|
||||
if (trimmed && trimmed !== value) {
|
||||
onSave(trimmed);
|
||||
await Promise.resolve(onSave(trimmed));
|
||||
} else {
|
||||
setDraft(value);
|
||||
}
|
||||
setEditing(false);
|
||||
}
|
||||
if (!multiline) {
|
||||
setEditing(false);
|
||||
}
|
||||
}, [draft, multiline, onSave, value]);
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Enter" && !multiline) {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
void commit();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
reset();
|
||||
setDraft(value);
|
||||
setEditing(false);
|
||||
if (multiline) {
|
||||
setMultilineFocused(false);
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
} else {
|
||||
setEditing(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
if (multiline) {
|
||||
return (
|
||||
<div className={cn("space-y-2", pad)}>
|
||||
<MarkdownEditor
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
placeholder={placeholder}
|
||||
contentClassName={className}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
mentions={mentions}
|
||||
onSubmit={commit}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!multiline) return;
|
||||
if (!multilineFocused) return;
|
||||
const trimmed = draft.trim();
|
||||
if (!trimmed || trimmed === value) {
|
||||
if (autosaveState !== "saved") {
|
||||
reset();
|
||||
}
|
||||
return;
|
||||
}
|
||||
markDirty();
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
autosaveDebounceRef.current = setTimeout(() => {
|
||||
void runSave(() => commit(trimmed));
|
||||
}, AUTOSAVE_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [autosaveState, commit, draft, markDirty, multiline, multilineFocused, reset, runSave, value]);
|
||||
|
||||
if (multiline) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
markdownPad,
|
||||
"rounded transition-colors",
|
||||
multilineFocused ? "bg-transparent" : "hover:bg-accent/20",
|
||||
)}
|
||||
onFocusCapture={() => setMultilineFocused(true)}
|
||||
onBlurCapture={(event) => {
|
||||
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
setMultilineFocused(false);
|
||||
const trimmed = draft.trim();
|
||||
if (!trimmed || trimmed === value) {
|
||||
reset();
|
||||
void commit();
|
||||
return;
|
||||
}
|
||||
void runSave(() => commit());
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<MarkdownEditor
|
||||
ref={markdownRef}
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
placeholder={placeholder}
|
||||
bordered={false}
|
||||
className="bg-transparent"
|
||||
contentClassName={cn("paperclip-edit-in-place-content", className)}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
mentions={mentions}
|
||||
onSubmit={() => {
|
||||
const trimmed = draft.trim();
|
||||
if (!trimmed || trimmed === value) {
|
||||
reset();
|
||||
void commit();
|
||||
return;
|
||||
}
|
||||
void runSave(() => commit());
|
||||
}}
|
||||
/>
|
||||
<div className="flex min-h-4 items-center justify-end pr-1">
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] transition-opacity duration-150",
|
||||
autosaveState === "error" ? "text-destructive" : "text-muted-foreground",
|
||||
autosaveState === "idle" ? "opacity-0" : "opacity-100",
|
||||
)}
|
||||
>
|
||||
{autosaveState === "saving"
|
||||
? "Autosaving..."
|
||||
: autosaveState === "saved"
|
||||
? "Saved"
|
||||
: autosaveState === "error"
|
||||
? "Could not save"
|
||||
: "Idle"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
|
||||
return (
|
||||
<textarea
|
||||
@@ -114,7 +215,9 @@ export function InlineEditor({
|
||||
setDraft(e.target.value);
|
||||
autoSize(e.target);
|
||||
}}
|
||||
onBlur={commit}
|
||||
onBlur={() => {
|
||||
void commit();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
"w-full bg-transparent rounded outline-none resize-none overflow-hidden",
|
||||
@@ -135,15 +238,11 @@ export function InlineEditor({
|
||||
"cursor-pointer rounded hover:bg-accent/50 transition-colors overflow-hidden",
|
||||
pad,
|
||||
!value && "text-muted-foreground italic",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
{value && multiline ? (
|
||||
<MarkdownBody>{value}</MarkdownBody>
|
||||
) : (
|
||||
value || placeholder
|
||||
)}
|
||||
{value || placeholder}
|
||||
</DisplayTag>
|
||||
);
|
||||
}
|
||||
|
||||
510
ui/src/components/IssueDocumentsSection.tsx
Normal file
510
ui/src/components/IssueDocumentsSection.tsx
Normal file
@@ -0,0 +1,510 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { relativeTime } from "../lib/utils";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { FileText, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
|
||||
|
||||
type DraftState = {
|
||||
key: string;
|
||||
title: string;
|
||||
body: string;
|
||||
baseRevisionId: string | null;
|
||||
isNew: boolean;
|
||||
};
|
||||
|
||||
const DOCUMENT_AUTOSAVE_DEBOUNCE_MS = 900;
|
||||
|
||||
function renderBody(body: string, className?: string) {
|
||||
return <MarkdownBody className={className}>{body}</MarkdownBody>;
|
||||
}
|
||||
|
||||
function isPlanKey(key: string) {
|
||||
return key.trim().toLowerCase() === "plan";
|
||||
}
|
||||
|
||||
export function IssueDocumentsSection({
|
||||
issue,
|
||||
canDeleteDocuments,
|
||||
mentions,
|
||||
imageUploadHandler,
|
||||
}: {
|
||||
issue: Issue;
|
||||
canDeleteDocuments: boolean;
|
||||
mentions?: MentionOption[];
|
||||
imageUploadHandler?: (file: File) => Promise<string>;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [confirmDeleteKey, setConfirmDeleteKey] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<DraftState | null>(null);
|
||||
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const {
|
||||
state: autosaveState,
|
||||
markDirty,
|
||||
reset,
|
||||
runSave,
|
||||
} = useAutosaveIndicator();
|
||||
|
||||
const { data: documents } = useQuery({
|
||||
queryKey: queryKeys.issues.documents(issue.id),
|
||||
queryFn: () => issuesApi.listDocuments(issue.id),
|
||||
});
|
||||
|
||||
const invalidateIssueDocuments = () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issue.id) });
|
||||
};
|
||||
|
||||
const upsertDocument = useMutation({
|
||||
mutationFn: async (nextDraft: DraftState) =>
|
||||
issuesApi.upsertDocument(issue.id, nextDraft.key, {
|
||||
title: isPlanKey(nextDraft.key) ? null : nextDraft.title.trim() || null,
|
||||
format: "markdown",
|
||||
body: nextDraft.body,
|
||||
baseRevisionId: nextDraft.baseRevisionId,
|
||||
}),
|
||||
});
|
||||
|
||||
const deleteDocument = useMutation({
|
||||
mutationFn: (key: string) => issuesApi.deleteDocument(issue.id, key),
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
setConfirmDeleteKey(null);
|
||||
invalidateIssueDocuments();
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err instanceof Error ? err.message : "Failed to delete document");
|
||||
},
|
||||
});
|
||||
|
||||
const sortedDocuments = useMemo(() => {
|
||||
return [...(documents ?? [])].sort((a, b) => {
|
||||
if (a.key === "plan" && b.key !== "plan") return -1;
|
||||
if (a.key !== "plan" && b.key === "plan") return 1;
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
});
|
||||
}, [documents]);
|
||||
|
||||
const hasRealPlan = sortedDocuments.some((doc) => doc.key === "plan");
|
||||
|
||||
const beginNewDocument = () => {
|
||||
reset();
|
||||
setDraft({
|
||||
key: "",
|
||||
title: "",
|
||||
body: "",
|
||||
baseRevisionId: null,
|
||||
isNew: true,
|
||||
});
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const beginEdit = (key: string) => {
|
||||
const doc = sortedDocuments.find((entry) => entry.key === key);
|
||||
if (!doc) return;
|
||||
reset();
|
||||
setDraft({
|
||||
key: doc.key,
|
||||
title: doc.title ?? "",
|
||||
body: doc.body,
|
||||
baseRevisionId: doc.latestRevisionId,
|
||||
isNew: false,
|
||||
});
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const cancelDraft = () => {
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
reset();
|
||||
setDraft(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const commitDraft = useCallback(async (
|
||||
currentDraft: DraftState | null,
|
||||
options?: { clearAfterSave?: boolean; trackAutosave?: boolean },
|
||||
) => {
|
||||
if (!currentDraft || upsertDocument.isPending) return false;
|
||||
const normalizedKey = currentDraft.key.trim().toLowerCase();
|
||||
const normalizedBody = currentDraft.body.trim();
|
||||
const normalizedTitle = currentDraft.title.trim();
|
||||
|
||||
if (!normalizedKey || !normalizedBody) {
|
||||
if (currentDraft.isNew) {
|
||||
setError("Document key and body are required");
|
||||
} else if (!normalizedBody) {
|
||||
setError("Document body cannot be empty");
|
||||
}
|
||||
if (options?.trackAutosave) {
|
||||
reset();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const existing = sortedDocuments.find((doc) => doc.key === normalizedKey);
|
||||
if (
|
||||
!currentDraft.isNew &&
|
||||
existing &&
|
||||
existing.body === currentDraft.body &&
|
||||
(existing.title ?? "") === currentDraft.title
|
||||
) {
|
||||
if (options?.clearAfterSave) {
|
||||
setDraft((value) => (value?.key === normalizedKey ? null : value));
|
||||
}
|
||||
if (options?.trackAutosave) {
|
||||
reset();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
const saved = await upsertDocument.mutateAsync({
|
||||
...currentDraft,
|
||||
key: normalizedKey,
|
||||
title: isPlanKey(normalizedKey) ? "" : normalizedTitle,
|
||||
body: currentDraft.body,
|
||||
});
|
||||
setError(null);
|
||||
setDraft((value) => {
|
||||
if (!value || value.key !== normalizedKey) return value;
|
||||
if (options?.clearAfterSave) return null;
|
||||
return {
|
||||
key: saved.key,
|
||||
title: saved.title ?? "",
|
||||
body: saved.body,
|
||||
baseRevisionId: saved.latestRevisionId,
|
||||
isNew: false,
|
||||
};
|
||||
});
|
||||
invalidateIssueDocuments();
|
||||
};
|
||||
|
||||
try {
|
||||
if (options?.trackAutosave) {
|
||||
await runSave(save);
|
||||
} else {
|
||||
await save();
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save document");
|
||||
return false;
|
||||
}
|
||||
}, [invalidateIssueDocuments, reset, runSave, sortedDocuments, upsertDocument]);
|
||||
|
||||
const handleDraftBlur = async (event: React.FocusEvent<HTMLDivElement>) => {
|
||||
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
await commitDraft(draft, { clearAfterSave: true, trackAutosave: true });
|
||||
};
|
||||
|
||||
const handleDraftKeyDown = async (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
cancelDraft();
|
||||
return;
|
||||
}
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
await commitDraft(draft, { clearAfterSave: false, trackAutosave: true });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft || draft.isNew) return;
|
||||
const existing = sortedDocuments.find((doc) => doc.key === draft.key);
|
||||
if (!existing) return;
|
||||
const hasChanges =
|
||||
existing.body !== draft.body ||
|
||||
(existing.title ?? "") !== draft.title;
|
||||
if (!hasChanges) {
|
||||
if (autosaveState !== "saved") {
|
||||
reset();
|
||||
}
|
||||
return;
|
||||
}
|
||||
markDirty();
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
autosaveDebounceRef.current = setTimeout(() => {
|
||||
void commitDraft(draft, { clearAfterSave: false, trackAutosave: true });
|
||||
}, DOCUMENT_AUTOSAVE_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [autosaveState, commitDraft, draft, markDirty, reset, sortedDocuments]);
|
||||
|
||||
const documentBodyShellClassName = "mt-3 overflow-hidden rounded-md border border-border bg-background";
|
||||
const documentBodyPaddingClassName = "px-3 py-3";
|
||||
const documentBodyContentClassName = "paperclip-edit-in-place-content min-h-[220px] text-[15px] leading-7";
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Documents</h3>
|
||||
<Button variant="outline" size="sm" onClick={beginNewDocument}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
New document
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
{draft?.isNew && (
|
||||
<div
|
||||
className="space-y-3 rounded-lg border border-border bg-accent/10 p-3"
|
||||
onBlurCapture={handleDraftBlur}
|
||||
onKeyDown={handleDraftKeyDown}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
value={draft.key}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => current ? { ...current, key: event.target.value.toLowerCase() } : current)
|
||||
}
|
||||
placeholder="Document key"
|
||||
/>
|
||||
{!isPlanKey(draft.key) && (
|
||||
<Input
|
||||
value={draft.title}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => current ? { ...current, title: event.target.value } : current)
|
||||
}
|
||||
placeholder="Optional title"
|
||||
/>
|
||||
)}
|
||||
<MarkdownEditor
|
||||
value={draft.body}
|
||||
onChange={(body) =>
|
||||
setDraft((current) => current ? { ...current, body } : current)
|
||||
}
|
||||
placeholder="Markdown body"
|
||||
bordered={false}
|
||||
className="bg-transparent"
|
||||
contentClassName="min-h-[220px] px-3 py-3 text-[15px] leading-7"
|
||||
mentions={mentions}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
onSubmit={() => void commitDraft(draft, { clearAfterSave: false, trackAutosave: false })}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={cancelDraft}>
|
||||
<X className="mr-1.5 h-3.5 w-3.5" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void commitDraft(draft, { clearAfterSave: false, trackAutosave: false })}
|
||||
disabled={upsertDocument.isPending}
|
||||
>
|
||||
{upsertDocument.isPending ? "Saving..." : "Create document"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedDocuments.length === 0 && !issue.legacyPlanDocument ? (
|
||||
<p className="text-xs text-muted-foreground">No documents yet.</p>
|
||||
) : null}
|
||||
|
||||
{!hasRealPlan && issue.legacyPlanDocument ? (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-amber-600" />
|
||||
<span className="rounded-full border border-amber-500/30 px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-amber-700 dark:text-amber-300">
|
||||
PLAN
|
||||
</span>
|
||||
</div>
|
||||
<div className={documentBodyPaddingClassName}>
|
||||
{renderBody(issue.legacyPlanDocument.body, documentBodyContentClassName)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
{sortedDocuments.map((doc) => {
|
||||
const activeDraft = draft?.key === doc.key && !draft.isNew ? draft : null;
|
||||
const showTitle = !isPlanKey(doc.key) && !!doc.title?.trim();
|
||||
|
||||
return (
|
||||
<div key={doc.id} className="rounded-lg border border-border p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full border border-border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{doc.key}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
rev {doc.latestRevisionNumber} • updated {relativeTime(doc.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
{showTitle && <p className="mt-2 text-sm font-medium">{doc.title}</p>}
|
||||
</div>
|
||||
{canDeleteDocuments && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
title="Document actions"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setConfirmDeleteKey(doc.key)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete document
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-3 space-y-3"
|
||||
onFocusCapture={() => {
|
||||
if (!activeDraft) {
|
||||
beginEdit(doc.key);
|
||||
}
|
||||
}}
|
||||
onBlurCapture={async (event) => {
|
||||
if (activeDraft) {
|
||||
await handleDraftBlur(event);
|
||||
}
|
||||
}}
|
||||
onKeyDown={async (event) => {
|
||||
if (activeDraft) {
|
||||
await handleDraftKeyDown(event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{activeDraft && !isPlanKey(doc.key) && (
|
||||
<Input
|
||||
value={activeDraft.title}
|
||||
onChange={(event) => {
|
||||
markDirty();
|
||||
setDraft((current) => current ? { ...current, title: event.target.value } : current);
|
||||
}}
|
||||
placeholder="Optional title"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`${documentBodyShellClassName} ${documentBodyPaddingClassName} ${
|
||||
activeDraft ? "" : "hover:bg-accent/10"
|
||||
}`}
|
||||
>
|
||||
<MarkdownEditor
|
||||
value={activeDraft?.body ?? doc.body}
|
||||
onChange={(body) => {
|
||||
markDirty();
|
||||
setDraft((current) => {
|
||||
if (current && current.key === doc.key && !current.isNew) {
|
||||
return { ...current, body };
|
||||
}
|
||||
return {
|
||||
key: doc.key,
|
||||
title: doc.title ?? "",
|
||||
body,
|
||||
baseRevisionId: doc.latestRevisionId,
|
||||
isNew: false,
|
||||
};
|
||||
});
|
||||
}}
|
||||
placeholder="Markdown body"
|
||||
bordered={false}
|
||||
className="bg-transparent"
|
||||
contentClassName={documentBodyContentClassName}
|
||||
mentions={mentions}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-h-4 items-center justify-end px-1">
|
||||
<span
|
||||
className={`text-[11px] transition-opacity duration-150 ${
|
||||
autosaveState === "error" ? "text-destructive" : "text-muted-foreground"
|
||||
} ${activeDraft ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
{activeDraft
|
||||
? autosaveState === "saving"
|
||||
? "Autosaving..."
|
||||
: autosaveState === "saved"
|
||||
? "Saved"
|
||||
: autosaveState === "error"
|
||||
? "Could not save"
|
||||
: ""
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{confirmDeleteKey === doc.key && (
|
||||
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border border-destructive/20 bg-destructive/5 px-4 py-3">
|
||||
<p className="text-sm text-destructive font-medium">
|
||||
Delete this document? This cannot be undone.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setConfirmDeleteKey(null)}
|
||||
disabled={deleteDocument.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => deleteDocument.mutate(doc.key)}
|
||||
disabled={deleteDocument.isPending}
|
||||
>
|
||||
{deleteDocument.isPending ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user