fix(ui): streamline issue document chrome
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -16,7 +16,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { FileText, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
|
import { ChevronDown, ChevronRight, FileText, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
|
||||||
|
|
||||||
type DraftState = {
|
type DraftState = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -28,6 +28,24 @@ type DraftState = {
|
|||||||
|
|
||||||
const DOCUMENT_AUTOSAVE_DEBOUNCE_MS = 900;
|
const DOCUMENT_AUTOSAVE_DEBOUNCE_MS = 900;
|
||||||
const DOCUMENT_KEY_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
|
const DOCUMENT_KEY_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
|
||||||
|
const getFoldedDocumentsStorageKey = (issueId: string) => `paperclip:issue-document-folds:${issueId}`;
|
||||||
|
|
||||||
|
function loadFoldedDocumentKeys(issueId: string) {
|
||||||
|
if (typeof window === "undefined") return [];
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(getFoldedDocumentsStorageKey(issueId));
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === "string") : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveFoldedDocumentKeys(issueId: string, keys: string[]) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
window.localStorage.setItem(getFoldedDocumentsStorageKey(issueId), JSON.stringify(keys));
|
||||||
|
}
|
||||||
|
|
||||||
function renderBody(body: string, className?: string) {
|
function renderBody(body: string, className?: string) {
|
||||||
return <MarkdownBody className={className}>{body}</MarkdownBody>;
|
return <MarkdownBody className={className}>{body}</MarkdownBody>;
|
||||||
@@ -58,6 +76,7 @@ export function IssueDocumentsSection({
|
|||||||
const [confirmDeleteKey, setConfirmDeleteKey] = useState<string | null>(null);
|
const [confirmDeleteKey, setConfirmDeleteKey] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [draft, setDraft] = useState<DraftState | null>(null);
|
const [draft, setDraft] = useState<DraftState | null>(null);
|
||||||
|
const [foldedDocumentKeys, setFoldedDocumentKeys] = useState<string[]>(() => loadFoldedDocumentKeys(issue.id));
|
||||||
const [autosaveDocumentKey, setAutosaveDocumentKey] = useState<string | null>(null);
|
const [autosaveDocumentKey, setAutosaveDocumentKey] = useState<string | null>(null);
|
||||||
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const {
|
const {
|
||||||
@@ -139,6 +158,7 @@ export function IssueDocumentsSection({
|
|||||||
const beginEdit = (key: string) => {
|
const beginEdit = (key: string) => {
|
||||||
const doc = sortedDocuments.find((entry) => entry.key === key);
|
const doc = sortedDocuments.find((entry) => entry.key === key);
|
||||||
if (!doc) return;
|
if (!doc) return;
|
||||||
|
setFoldedDocumentKeys((current) => current.filter((entry) => entry !== key));
|
||||||
resetAutosaveState();
|
resetAutosaveState();
|
||||||
setDraft({
|
setDraft({
|
||||||
key: doc.key,
|
key: doc.key,
|
||||||
@@ -263,6 +283,25 @@ export function IssueDocumentsSection({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFoldedDocumentKeys(loadFoldedDocumentKeys(issue.id));
|
||||||
|
}, [issue.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const validKeys = new Set(sortedDocuments.map((doc) => doc.key));
|
||||||
|
setFoldedDocumentKeys((current) => {
|
||||||
|
const next = current.filter((key) => validKeys.has(key));
|
||||||
|
if (next.length !== current.length) {
|
||||||
|
saveFoldedDocumentKeys(issue.id, next);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [issue.id, sortedDocuments]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
saveFoldedDocumentKeys(issue.id, foldedDocumentKeys);
|
||||||
|
}, [foldedDocumentKeys, issue.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (autosaveDebounceRef.current) {
|
if (autosaveDebounceRef.current) {
|
||||||
@@ -302,6 +341,13 @@ export function IssueDocumentsSection({
|
|||||||
const documentBodyShellClassName = "mt-3 overflow-hidden rounded-md";
|
const documentBodyShellClassName = "mt-3 overflow-hidden rounded-md";
|
||||||
const documentBodyPaddingClassName = "";
|
const documentBodyPaddingClassName = "";
|
||||||
const documentBodyContentClassName = "paperclip-edit-in-place-content min-h-[220px] text-[15px] leading-7";
|
const documentBodyContentClassName = "paperclip-edit-in-place-content min-h-[220px] text-[15px] leading-7";
|
||||||
|
const toggleFoldedDocument = (key: string) => {
|
||||||
|
setFoldedDocumentKeys((current) =>
|
||||||
|
current.includes(key)
|
||||||
|
? current.filter((entry) => entry !== key)
|
||||||
|
: [...current, key],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -400,6 +446,7 @@ export function IssueDocumentsSection({
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{sortedDocuments.map((doc) => {
|
{sortedDocuments.map((doc) => {
|
||||||
const activeDraft = draft?.key === doc.key && !draft.isNew ? draft : null;
|
const activeDraft = draft?.key === doc.key && !draft.isNew ? draft : null;
|
||||||
|
const isFolded = foldedDocumentKeys.includes(doc.key);
|
||||||
const showTitle = !isPlanKey(doc.key) && !!doc.title?.trim() && !titlesMatchKey(doc.title, doc.key);
|
const showTitle = !isPlanKey(doc.key) && !!doc.title?.trim() && !titlesMatchKey(doc.title, doc.key);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -407,6 +454,15 @@ export function IssueDocumentsSection({
|
|||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-5 w-5 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||||
|
onClick={() => toggleFoldedDocument(doc.key)}
|
||||||
|
aria-label={isFolded ? `Expand ${doc.key} document` : `Collapse ${doc.key} document`}
|
||||||
|
aria-expanded={!isFolded}
|
||||||
|
>
|
||||||
|
{isFolded ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
<span className="rounded-full border border-border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
<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}
|
{doc.key}
|
||||||
</span>
|
</span>
|
||||||
@@ -442,83 +498,85 @@ export function IssueDocumentsSection({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
{!isFolded ? (
|
||||||
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) => {
|
|
||||||
markDocumentDirty(doc.key);
|
|
||||||
setDraft((current) => current ? { ...current, title: event.target.value } : current);
|
|
||||||
}}
|
|
||||||
placeholder="Optional title"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div
|
<div
|
||||||
className={`${documentBodyShellClassName} ${documentBodyPaddingClassName} ${
|
className="mt-3 space-y-3"
|
||||||
activeDraft ? "" : "hover:bg-accent/10"
|
onFocusCapture={() => {
|
||||||
}`}
|
if (!activeDraft) {
|
||||||
|
beginEdit(doc.key);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlurCapture={async (event) => {
|
||||||
|
if (activeDraft) {
|
||||||
|
await handleDraftBlur(event);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={async (event) => {
|
||||||
|
if (activeDraft) {
|
||||||
|
await handleDraftKeyDown(event);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MarkdownEditor
|
{activeDraft && !isPlanKey(doc.key) && (
|
||||||
value={activeDraft?.body ?? doc.body}
|
<Input
|
||||||
onChange={(body) => {
|
value={activeDraft.title}
|
||||||
markDocumentDirty(doc.key);
|
onChange={(event) => {
|
||||||
setDraft((current) => {
|
markDocumentDirty(doc.key);
|
||||||
if (current && current.key === doc.key && !current.isNew) {
|
setDraft((current) => current ? { ...current, title: event.target.value } : current);
|
||||||
return { ...current, body };
|
}}
|
||||||
}
|
placeholder="Optional title"
|
||||||
return {
|
/>
|
||||||
key: doc.key,
|
)}
|
||||||
title: doc.title ?? "",
|
<div
|
||||||
body,
|
className={`${documentBodyShellClassName} ${documentBodyPaddingClassName} ${
|
||||||
baseRevisionId: doc.latestRevisionId,
|
activeDraft ? "" : "hover:bg-accent/10"
|
||||||
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 && autosaveDocumentKey === doc.key
|
<MarkdownEditor
|
||||||
? autosaveState === "saving"
|
value={activeDraft?.body ?? doc.body}
|
||||||
? "Autosaving..."
|
onChange={(body) => {
|
||||||
: autosaveState === "saved"
|
markDocumentDirty(doc.key);
|
||||||
? "Saved"
|
setDraft((current) => {
|
||||||
: autosaveState === "error"
|
if (current && current.key === doc.key && !current.isNew) {
|
||||||
? "Could not save"
|
return { ...current, body };
|
||||||
: ""
|
}
|
||||||
: ""}
|
return {
|
||||||
</span>
|
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 && autosaveDocumentKey === doc.key
|
||||||
|
? autosaveState === "saving"
|
||||||
|
? "Autosaving..."
|
||||||
|
: autosaveState === "saved"
|
||||||
|
? "Saved"
|
||||||
|
: autosaveState === "error"
|
||||||
|
? "Could not save"
|
||||||
|
: ""
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
|
|
||||||
{confirmDeleteKey === doc.key && (
|
{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">
|
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border border-destructive/20 bg-destructive/5 px-4 py-3">
|
||||||
|
|||||||
@@ -609,12 +609,7 @@ export function IssueDetail() {
|
|||||||
const attachmentList = attachments ?? [];
|
const attachmentList = attachments ?? [];
|
||||||
const hasAttachments = attachmentList.length > 0;
|
const hasAttachments = attachmentList.length > 0;
|
||||||
const attachmentUploadButton = (
|
const attachmentUploadButton = (
|
||||||
<div
|
<>
|
||||||
className={cn(
|
|
||||||
"rounded-md border border-dashed border-border p-1 transition-colors",
|
|
||||||
attachmentDragActive && "border-primary bg-primary/5",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -629,14 +624,14 @@ export function IssueDetail() {
|
|||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={uploadAttachment.isPending || importMarkdownDocument.isPending}
|
disabled={uploadAttachment.isPending || importMarkdownDocument.isPending}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-transparent bg-transparent shadow-none",
|
"shadow-none",
|
||||||
attachmentDragActive && "bg-transparent",
|
attachmentDragActive && "border-primary bg-primary/5",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Paperclip className="h-3.5 w-3.5 mr-1.5" />
|
<Paperclip className="h-3.5 w-3.5 mr-1.5" />
|
||||||
{uploadAttachment.isPending || importMarkdownDocument.isPending ? "Uploading..." : "Upload attachment"}
|
{uploadAttachment.isPending || importMarkdownDocument.isPending ? "Uploading..." : "Upload attachment"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user