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>
293 lines
9.7 KiB
TypeScript
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>
|
|
);
|
|
}
|