1
0
Fork 0
polaris-task-force/src/components/frontend/profile/DossierExcerptEditors.tsx
Z8MB1E 56b8b9d320 feat(profile): add code, mark, and horizontal rule nodes to dossier editors
Register CodeNode/CodeHighlightNode, MarkNode, and HorizontalRuleNode in
the DossierExcerptEditors Lexical config so excerpts can use code blocks
with highlighting, inline marks, and divider rules.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-06 05:36:55 -04:00

293 lines
9.7 KiB
TypeScript

"use client";
import { useCallback, useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { HourglassIcon, PencilIcon, Save, ShieldCheck, X } from "lucide-react";
import { RichText } from "@payloadcms/richtext-lexical/react";
import { LexicalComposer } from "@payloadcms/richtext-lexical/lexical/react/LexicalComposer";
import { ContentEditable } from "@payloadcms/richtext-lexical/lexical/react/LexicalContentEditable";
import { LexicalErrorBoundary } from "@payloadcms/richtext-lexical/lexical/react/LexicalErrorBoundary";
import { HistoryPlugin } from "@payloadcms/richtext-lexical/lexical/react/LexicalHistoryPlugin";
import { MarkdownShortcutPlugin } from "@payloadcms/richtext-lexical/lexical/react/LexicalMarkdownShortcutPlugin";
import { OnChangePlugin } from "@payloadcms/richtext-lexical/lexical/react/LexicalOnChangePlugin";
import { RichTextPlugin } from "@payloadcms/richtext-lexical/lexical/react/LexicalRichTextPlugin";
import { HeadingNode, QuoteNode } from "@payloadcms/richtext-lexical/lexical/rich-text";
import { ListItemNode, ListNode } from "@payloadcms/richtext-lexical/lexical/list";
import { AutoLinkNode, LinkNode } from "@payloadcms/richtext-lexical/lexical/link";
import { MarkNode } from "@payloadcms/richtext-lexical/lexical/mark";
import { CodeHighlightNode, CodeNode } from "@lexical/code";
import { HorizontalRuleNode } from "@payloadcms/richtext-lexical/lexical/react/LexicalHorizontalRuleNode";
import { $getRoot } from "@payloadcms/richtext-lexical/lexical";
import type { LexicalEditor } from "@payloadcms/richtext-lexical/lexical";
import { Button } from "@/components/ui/button";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { lexicalPlaintextLength } from "@/lib/lexical/text";
import type { LexicalStateValue } from "@/lib/lexical/text";
import {
INTEL_EXCERPT_MAX_LENGTH,
PERSONAL_EXCERPT_MAX_LENGTH,
} from "@/lib/profile/excerpt-limits";
import {
updateIntelExcerpt,
updatePersonalExcerpt,
} from "@/app/(frontend)/profile/[username]/actions";
import { cn } from "@/lib/utils";
const EDITOR_NODES = [
HeadingNode,
QuoteNode,
ListNode,
ListItemNode,
LinkNode,
AutoLinkNode,
HorizontalRuleNode,
CodeNode,
CodeHighlightNode,
MarkNode,
];
const EDITOR_CONTENT_CLASSES =
"prose-invert prose-sm max-w-none [&_p]:mb-2 [&_ul]:list-disc [&_ul]:ml-4 [&_ol]:list-decimal [&_ol]:ml-4 [&_strong]:text-foreground [&_em]:italic";
interface ExcerptEditorProps {
value: LexicalStateValue | null;
maxLength: number;
placeholder: string;
onEditChange: (draft: LexicalStateValue | null, length: number) => void;
}
function ExcerptEditor({ value, maxLength, placeholder, onEditChange }: ExcerptEditorProps) {
const [length, setLength] = useState<number>(() => lexicalPlaintextLength(value));
const handleChange = useCallback(
(editorState: ReturnType<LexicalEditor["getEditorState"]>) => {
const serialized: LexicalStateValue = JSON.parse(JSON.stringify(editorState.toJSON()));
setLength(lexicalPlaintextLength(serialized));
onEditChange(serialized, lexicalPlaintextLength(serialized));
},
[onEditChange],
);
return (
<LexicalComposer
initialConfig={{
namespace: `excerpt-editor-${maxLength}`,
nodes: EDITOR_NODES,
editable: true,
editorState: value ? JSON.stringify(value) : undefined,
onError: (error: Error) => {
console.error(error);
},
theme: {
heading: {
h2: "text-lg font-semibold text-foreground mb-2",
h3: "text-base font-semibold text-foreground mb-2",
},
quote: "border-l-2 border-border pl-3 italic text-muted-foreground mb-2",
list: { ul: "list-disc ml-4 mb-2", ol: "list-decimal ml-4 mb-2" },
link: "text-primary underline underline-offset-2",
},
}}
>
<OnChangePlugin onChange={handleChange} ignoreSelectionChange />
<HistoryPlugin />
<MarkdownShortcutPlugin />
<RichTextPlugin
contentEditable={
<div className="relative">
<ContentEditable
className={cn(
"min-h-[140px] w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm text-foreground leading-relaxed outline-none focus-visible:ring-1 focus-visible:ring-ring",
EDITOR_CONTENT_CLASSES,
)}
/>
<p className="absolute bottom-2 right-3 pointer-events-none text-xs text-muted-foreground">
<span className={cn(length > maxLength && "text-red-500 font-medium")}>
{length}
</span>
/{maxLength}
</p>
</div>
}
placeholder={
<div className="pointer-events-none absolute top-2 left-3 text-sm text-muted-foreground">
{placeholder}
</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
</LexicalComposer>
);
}
interface ExcerptCardProps {
profileId: number;
kind: "personal" | "intel";
title: string;
value: LexicalStateValue | null;
maxLength: number;
canEdit: boolean;
emptyMessage: string;
placeholder: string;
hasIntelBadge?: boolean;
}
function ExcerptCard({
profileId,
kind,
title,
value,
maxLength,
canEdit,
emptyMessage,
placeholder,
hasIntelBadge,
}: ExcerptCardProps) {
const router = useRouter();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<LexicalStateValue | null>(null);
const [draftLength, setDraftLength] = useState<number>(() => lexicalPlaintextLength(value));
const [saving, setSaving] = useState(false);
const handleEditChange = useCallback((next: LexicalStateValue | null, length: number) => {
setDraft(next);
setDraftLength(length);
}, []);
const handleEdit = () => {
setDraft(null);
setDraftLength(lexicalPlaintextLength(value));
setEditing(true);
};
const handleCancel = () => {
setDraft(null);
setEditing(false);
};
const handleSave = async () => {
const content = draft ?? value;
if (!content) {
handleCancel();
return;
}
if (draftLength > maxLength) return;
setSaving(true);
const result =
kind === "personal"
? await updatePersonalExcerpt({ profileId, content })
: await updateIntelExcerpt({ profileId, content });
setSaving(false);
if (result.success) {
toast.success(kind === "personal" ? "Personal excerpt saved." : "Intelligence record saved.");
setDraft(null);
setEditing(false);
router.refresh();
} else {
toast.error(result.error ?? "Failed to save.");
}
};
const showBadge = kind === "intel" && hasIntelBadge;
return (
<Item variant="outline">
<ItemHeader>
<ItemTitle>{title}</ItemTitle>
{showBadge ? (
<div className="inline-flex flex-row items-center gap-1 text-xs text-amber-300">
<span>Verified by the Intelligence Division</span> <ShieldCheck className="w-4" />
</div>
) : kind === "intel" ? (
<div className="inline-flex flex-row items-center gap-1 text-xs opacity-25">
<span>Undergoing review by the Intelligence Division</span>{" "}
<HourglassIcon className="w-4" />
</div>
) : null}
{canEdit && !editing ? (
<Button variant="ghost" size="sm" onClick={handleEdit} className="ml-auto">
<PencilIcon className="size-3.5" />
Edit
</Button>
) : null}
</ItemHeader>
<ItemContent
className={cn(
"flex flex-col justify-start items-start text-sm text-muted-foreground leading-relaxed",
EDITOR_CONTENT_CLASSES,
)}
>
{editing ? (
<div className="w-full flex flex-col gap-3">
<ExcerptEditor
value={value}
maxLength={maxLength}
placeholder={placeholder}
onEditChange={handleEditChange}
/>
<div className="flex items-center gap-2">
<Button size="sm" onClick={handleSave} disabled={saving || draftLength > maxLength}>
<Save className="size-3.5" />
{saving ? "Saving..." : "Save"}
</Button>
<Button variant="outline" size="sm" onClick={handleCancel} disabled={saving}>
<X className="size-3.5" />
Cancel
</Button>
</div>
</div>
) : value ? (
<RichText data={value} />
) : (
<p>{emptyMessage}</p>
)}
</ItemContent>
</Item>
);
}
interface DossierExcerptEditorsProps {
profileId: number;
personalExcerpt: LexicalStateValue | null;
intelExcerpt: LexicalStateValue | null;
canEditPersonal: boolean;
canEditIntel: boolean;
}
export function DossierExcerptEditors({
profileId,
personalExcerpt,
intelExcerpt,
canEditPersonal,
canEditIntel,
}: DossierExcerptEditorsProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<ExcerptCard
profileId={profileId}
kind="personal"
title="Personal Excerpt"
value={personalExcerpt}
maxLength={PERSONAL_EXCERPT_MAX_LENGTH}
canEdit={canEditPersonal}
emptyMessage="No personal excerpt on file."
placeholder="Write a short personal introduction..."
/>
<ExcerptCard
profileId={profileId}
kind="intel"
title="Intelligence Record"
value={intelExcerpt}
maxLength={INTEL_EXCERPT_MAX_LENGTH}
canEdit={canEditIntel}
emptyMessage="No intelligence record on file."
placeholder="Draft the intelligence record..."
hasIntelBadge={Boolean(intelExcerpt)}
/>
</div>
);
}