feat(profile): add rich-text dossier excerpt editors
Convert the dossier personal excerpt from a plain textarea to Lexical rich text (matching the intel excerpt) with a 1000-character plain-text limit, and replace the static read-only cards on the profile page with inline DossierExcerptEditors: owners edit their personal excerpt, profiles:intel_excerpt:update holders edit the intelligence record. Changes are validated server-side (lexicalPlaintextLength) and emit dossier:personal-excerpt-update / dossier:intel-excerpt-update events.
This commit is contained in:
parent
c37a848d66
commit
60da363a93
11 changed files with 23075 additions and 40 deletions
|
|
@ -13,6 +13,13 @@ import {
|
|||
getLeaderAggregate,
|
||||
upsertEvaluation,
|
||||
} from "@/lib/evaluations";
|
||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||
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";
|
||||
|
||||
interface ActionResult<T = undefined> {
|
||||
success: boolean;
|
||||
|
|
@ -150,3 +157,114 @@ export async function getEvaluationSummary(
|
|||
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
async function findProfileOwner(
|
||||
payload: Awaited<ReturnType<typeof getPayload>>,
|
||||
profileId: number,
|
||||
): Promise<{ ownerId: number } | null> {
|
||||
const profile = await payload.findByID({
|
||||
collection: "profiles",
|
||||
id: profileId,
|
||||
depth: 0,
|
||||
select: { user: true },
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
if (!profile) return null;
|
||||
|
||||
const ownerId = typeof profile.user === "object" ? profile.user?.id : profile.user;
|
||||
if (typeof ownerId !== "number") return null;
|
||||
|
||||
return { ownerId };
|
||||
}
|
||||
|
||||
export async function updatePersonalExcerpt(input: {
|
||||
profileId: number;
|
||||
content: LexicalStateValue;
|
||||
}): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
|
||||
const profile = await findProfileOwner(payload, input.profileId);
|
||||
if (!profile) {
|
||||
return { success: false, error: "Profile not found." };
|
||||
}
|
||||
|
||||
if (profile.ownerId !== user.id) {
|
||||
return { success: false, error: "You can only edit your own personal excerpt." };
|
||||
}
|
||||
|
||||
if (lexicalPlaintextLength(input.content) > PERSONAL_EXCERPT_MAX_LENGTH) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Personal excerpt must be ${PERSONAL_EXCERPT_MAX_LENGTH} characters or fewer.`,
|
||||
};
|
||||
}
|
||||
|
||||
await payload.update({
|
||||
collection: "profiles",
|
||||
id: input.profileId,
|
||||
data: { dossier: { personalExcerpt: input.content } },
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.DossierPersonalExcerptUpdate,
|
||||
message: "A personal excerpt was updated.",
|
||||
targetCollection: "users",
|
||||
targetId: profile.ownerId,
|
||||
data: { profileId: input.profileId },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === "Unauthorized") throw e;
|
||||
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateIntelExcerpt(input: {
|
||||
profileId: number;
|
||||
content: LexicalStateValue;
|
||||
}): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
|
||||
const permitted = await hasPermission(payload, user, "profiles:intel_excerpt:update");
|
||||
if (!permitted) {
|
||||
return { success: false, error: "You are not allowed to edit intelligence records." };
|
||||
}
|
||||
|
||||
const profile = await findProfileOwner(payload, input.profileId);
|
||||
if (!profile) {
|
||||
return { success: false, error: "Profile not found." };
|
||||
}
|
||||
|
||||
if (lexicalPlaintextLength(input.content) > INTEL_EXCERPT_MAX_LENGTH) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Intelligence record must be ${INTEL_EXCERPT_MAX_LENGTH} characters or fewer.`,
|
||||
};
|
||||
}
|
||||
|
||||
await payload.update({
|
||||
collection: "profiles",
|
||||
id: input.profileId,
|
||||
data: { dossier: { intelExcerpt: input.content } },
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.DossierIntelExcerptUpdate,
|
||||
message: "An intelligence record was updated.",
|
||||
targetCollection: "users",
|
||||
targetId: profile.ownerId,
|
||||
data: { profileId: input.profileId },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === "Unauthorized") throw e;
|
||||
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,10 @@ import { notFound } from "next/navigation";
|
|||
import { headers as nextHeaders } from "next/headers";
|
||||
import Link from "next/link";
|
||||
import type { Award, Assignment, Media, Qualification, Rank, User } from "@/payload-types";
|
||||
import { RichText } from "@payloadcms/richtext-lexical/react";
|
||||
import { ClientDate } from "@/components/frontend/ClientDate";
|
||||
import { resolveLevel } from "@/utils/xp/resolveLevel";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||
import { isSuperuser, hasPermission } from "@/utils/access-control/hasPermission";
|
||||
import { isDirectLeaderOf, isMissionEvaluable } from "@/lib/evaluations";
|
||||
import { PERFORMANCE_LEVELS } from "@/lib/evaluations/levels";
|
||||
import { getEvaluationSummary } from "./actions";
|
||||
|
|
@ -20,9 +19,7 @@ import {
|
|||
CrosshairIcon,
|
||||
FileTextIcon,
|
||||
HashIcon,
|
||||
HourglassIcon,
|
||||
PlaneIcon,
|
||||
ShieldCheck,
|
||||
ShieldIcon,
|
||||
SkullIcon,
|
||||
SparkleIcon,
|
||||
|
|
@ -43,6 +40,7 @@ import AwardCard from "@/components/frontend/account/AwardCard";
|
|||
import { ProfileGrantAwardButton } from "@/components/frontend/account/ProfileGrantAwardButton";
|
||||
import { RibbonRack } from "@/components/frontend/account/RibbonRack";
|
||||
import { LeadershipEvaluationSection } from "@/components/frontend/profile/LeadershipEvaluationSection";
|
||||
import { DossierExcerptEditors } from "@/components/frontend/profile/DossierExcerptEditors";
|
||||
import { getLeadershipDisplayLabel } from "@/lib/evaluations/levels";
|
||||
import type {
|
||||
EvaluationMissionOption,
|
||||
|
|
@ -206,12 +204,16 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
|||
viewer && !isProfileOwner
|
||||
? isDirectLeaderOf(payload, viewer.id, user.id)
|
||||
: Promise.resolve(false);
|
||||
const [evaluationSummaryResult, eligibleMissions, isSuperuserViewer, isDirectLeader] =
|
||||
const canEditIntelPromise: Promise<boolean> = viewer
|
||||
? hasPermission(payload, viewer, "profiles:intel_excerpt:update")
|
||||
: Promise.resolve(false);
|
||||
const [evaluationSummaryResult, eligibleMissions, isSuperuserViewer, isDirectLeader, canEditIntel] =
|
||||
await Promise.all([
|
||||
evaluationSummaryPromise,
|
||||
eligibleMissionsPromise,
|
||||
privilegedViewerPromise,
|
||||
directLeaderPromise,
|
||||
canEditIntelPromise,
|
||||
]);
|
||||
const canViewSubordinate =
|
||||
isProfileOwner || isLegacyPrivileged || isSuperuserViewer || isDirectLeader;
|
||||
|
|
@ -449,38 +451,13 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
|||
Personnel Dossier
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Item variant="outline">
|
||||
<ItemHeader>
|
||||
<ItemTitle>Personal Excerpt</ItemTitle>
|
||||
</ItemHeader>
|
||||
<ItemContent className="flex flex-col justify-start items-start text-sm text-muted-foreground leading-relaxed 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">
|
||||
<p>{profile.dossier?.personalExcerpt || "No personal excerpt on file."}</p>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
<Item variant="outline">
|
||||
<ItemHeader>
|
||||
<ItemTitle>Intelligence Record</ItemTitle>
|
||||
{profile.dossier?.intelExcerpt ? (
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</ItemHeader>
|
||||
<ItemContent className="flex flex-col justify-start items-start text-sm text-muted-foreground leading-relaxed 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">
|
||||
{profile.dossier?.intelExcerpt ? (
|
||||
<RichText data={profile.dossier.intelExcerpt} />
|
||||
) : (
|
||||
<p>No intelligence record on file.</p>
|
||||
)}
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</div>
|
||||
<DossierExcerptEditors
|
||||
profileId={profile.id}
|
||||
personalExcerpt={profile.dossier?.personalExcerpt ?? null}
|
||||
intelExcerpt={profile.dossier?.intelExcerpt ?? null}
|
||||
canEditPersonal={isProfileOwner}
|
||||
canEditIntel={canEditIntel}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* ── Qualifications ─────────────────────────────────── */}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Award } from "@/payload-types";
|
|||
import { broadcastToUser } from "@/lib/realtime/bus";
|
||||
import { notifyAwardGrants } from "@/lib/awards";
|
||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||
import { lexicalPlaintextLength } from "@/lib/lexical/text";
|
||||
|
||||
export const Profiles: CollectionConfig = {
|
||||
slug: "profiles",
|
||||
|
|
@ -73,7 +74,14 @@ export const Profiles: CollectionConfig = {
|
|||
},
|
||||
{
|
||||
name: "personalExcerpt",
|
||||
type: "textarea",
|
||||
label: "Personal Excerpt",
|
||||
type: "richText",
|
||||
validate: (value: unknown) => {
|
||||
if (lexicalPlaintextLength(value) > 1000) {
|
||||
return "Personal excerpt must be 1000 characters or fewer.";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "intelExcerpt",
|
||||
|
|
|
|||
286
src/components/frontend/profile/DossierExcerptEditors.tsx
Normal file
286
src/components/frontend/profile/DossierExcerptEditors.tsx
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
"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 { $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,
|
||||
];
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
67
src/lib/lexical/text.ts
Normal file
67
src/lib/lexical/text.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* Pure (client-safe) helpers for reading Lexical richText editor states.
|
||||
*
|
||||
* No Payload or DOM imports — usable from server actions, collection field
|
||||
* validators, and client components alike.
|
||||
*/
|
||||
|
||||
interface LexicalNodeLike {
|
||||
type?: string;
|
||||
text?: string;
|
||||
children?: LexicalNodeLike[];
|
||||
}
|
||||
|
||||
interface LexicalStateLike {
|
||||
root?: LexicalNodeLike;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural mirror of Payload's generated richText field data shape.
|
||||
* Loose on purpose (type/children as any) so a value flows unchanged between
|
||||
* payload-types reads, payload.update data, and RichText rendering —
|
||||
* SerializedEditorState itself lacks the index signature Payload's generated
|
||||
* types carry, so it cannot be used in these positions directly.
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
export interface LexicalStateValue {
|
||||
root: {
|
||||
type: any;
|
||||
children: any[];
|
||||
direction: "ltr" | "rtl" | null;
|
||||
format: "" | "left" | "start" | "center" | "right" | "end" | "justify";
|
||||
indent: number;
|
||||
version: number;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
/**
|
||||
* Concatenates the text of every text node in a serialized Lexical editor
|
||||
* state (no separators, so length checks measure actual typed characters).
|
||||
* Returns "" for null/undefined/malformed values.
|
||||
*/
|
||||
export function lexicalToPlaintext(value: unknown): string {
|
||||
if (!value || typeof value !== "object") return "";
|
||||
const root = (value as LexicalStateLike).root;
|
||||
if (!root) return "";
|
||||
|
||||
let out = "";
|
||||
const walk = (node: LexicalNodeLike): void => {
|
||||
if (node.type === "text" && typeof node.text === "string") {
|
||||
out += node.text;
|
||||
}
|
||||
if (Array.isArray(node.children)) {
|
||||
node.children.forEach(walk);
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Character limit for dossier excerpts, measured on plain text content.
|
||||
*/
|
||||
export function lexicalPlaintextLength(value: unknown): number {
|
||||
return lexicalToPlaintext(value).length;
|
||||
}
|
||||
7
src/lib/profile/excerpt-limits.ts
Normal file
7
src/lib/profile/excerpt-limits.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* Shared character limits for dossier excerpts (plaintext content, not raw
|
||||
* JSON). Imported by the server actions, the collection field validator
|
||||
* wiring, and the client editors.
|
||||
*/
|
||||
export const PERSONAL_EXCERPT_MAX_LENGTH = 1000;
|
||||
export const INTEL_EXCERPT_MAX_LENGTH = 5000;
|
||||
22455
src/migrations/20260906_063753_add_personal_excerpt_richtext.json
Normal file
22455
src/migrations/20260906_063753_add_personal_excerpt_richtext.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,93 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
import { plaintextToLexical } from '../lib/tickets/lexical'
|
||||
|
||||
// dossier.personalExcerpt changed from textarea (varchar) to lexical richText
|
||||
// (jsonb). Existing plain-text values are wrapped into a minimal Lexical
|
||||
// document using the same plaintextToLexical helper the app uses elsewhere,
|
||||
// guaranteeing the produced shape matches what the editor/RichText expect.
|
||||
// The conversion runs row-by-row in TypeScript instead of SQL to keep the
|
||||
// logic in one place; profile counts are small (per-unit personnel records).
|
||||
|
||||
interface LegacyRow extends Record<string, unknown> {
|
||||
id: number
|
||||
dossier_personal_excerpt: string | null
|
||||
}
|
||||
|
||||
interface JsonbRow extends Record<string, unknown> {
|
||||
id: number
|
||||
dossier_personal_excerpt: string | null
|
||||
}
|
||||
|
||||
/** Flattens a serialized Lexical state back to the legacy textarea string. */
|
||||
function lexicalToLegacyText(value: string): string {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
const children =
|
||||
parsed && typeof parsed === "object"
|
||||
? ((parsed as { root?: { children?: unknown[] } }).root?.children ?? [])
|
||||
: []
|
||||
const paragraphs: string[] = []
|
||||
for (const para of children) {
|
||||
if (!para || typeof para !== "object") continue
|
||||
const inline = (para as { children?: unknown[] }).children ?? []
|
||||
let text = ""
|
||||
for (const node of inline) {
|
||||
if (!node || typeof node !== "object") continue
|
||||
const type = (node as { type?: string }).type
|
||||
if (type === "text") text += (node as { text?: string }).text ?? ""
|
||||
if (type === "linebreak") text += "\n"
|
||||
}
|
||||
paragraphs.push(text)
|
||||
}
|
||||
return paragraphs.join("\n\n")
|
||||
}
|
||||
|
||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "profiles" ADD COLUMN "dossier_personal_excerpt_new" jsonb;`,
|
||||
)
|
||||
|
||||
const result = await db.execute<LegacyRow>(
|
||||
sql`SELECT "id", "dossier_personal_excerpt" FROM "profiles" WHERE "dossier_personal_excerpt" IS NOT NULL`,
|
||||
)
|
||||
const rows = result.rows ?? []
|
||||
|
||||
for (const row of rows) {
|
||||
const lexical = plaintextToLexical(row.dossier_personal_excerpt ?? "")
|
||||
await db.execute(
|
||||
sql`UPDATE "profiles" SET "dossier_personal_excerpt_new" = ${JSON.stringify(lexical)}::jsonb WHERE "id" = ${row.id}`,
|
||||
)
|
||||
}
|
||||
|
||||
await db.execute(sql`ALTER TABLE "profiles" DROP COLUMN "dossier_personal_excerpt";`)
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "profiles" RENAME COLUMN "dossier_personal_excerpt_new" TO "dossier_personal_excerpt";`,
|
||||
)
|
||||
}
|
||||
|
||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "profiles" ADD COLUMN "dossier_personal_excerpt_old" varchar;`,
|
||||
)
|
||||
|
||||
const result = await db.execute<JsonbRow>(
|
||||
sql`SELECT "id", "dossier_personal_excerpt"::text AS "dossier_personal_excerpt" FROM "profiles" WHERE "dossier_personal_excerpt" IS NOT NULL`,
|
||||
)
|
||||
const rows = result.rows ?? []
|
||||
|
||||
for (const row of rows) {
|
||||
const legacy = lexicalToLegacyText(row.dossier_personal_excerpt ?? "")
|
||||
await db.execute(
|
||||
sql`UPDATE "profiles" SET "dossier_personal_excerpt_old" = ${legacy} WHERE "id" = ${row.id}`,
|
||||
)
|
||||
}
|
||||
|
||||
await db.execute(sql`ALTER TABLE "profiles" DROP COLUMN "dossier_personal_excerpt";`)
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "profiles" RENAME COLUMN "dossier_personal_excerpt_old" TO "dossier_personal_excerpt";`,
|
||||
)
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ import * as migration_20260905_213824 from './20260905_213824';
|
|||
import * as migration_20260905_215658 from './20260905_215658';
|
||||
import * as migration_20260906_045502 from './20260906_045502';
|
||||
import * as migration_20260906_050900_add_evaluation_reminders_field from './20260906_050900_add_evaluation_reminders_field';
|
||||
import * as migration_20260906_063753_add_personal_excerpt_richtext from './20260906_063753_add_personal_excerpt_richtext';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -184,6 +185,11 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260906_050900_add_evaluation_reminders_field.up,
|
||||
down: migration_20260906_050900_add_evaluation_reminders_field.down,
|
||||
name: '20260906_050900_add_evaluation_reminders_field'
|
||||
name: '20260906_050900_add_evaluation_reminders_field',
|
||||
},
|
||||
{
|
||||
up: migration_20260906_063753_add_personal_excerpt_richtext.up,
|
||||
down: migration_20260906_063753_add_personal_excerpt_richtext.down,
|
||||
name: '20260906_063753_add_personal_excerpt_richtext',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1866,7 +1866,21 @@ export interface Profile {
|
|||
profileTitle?: string | null;
|
||||
dossier: {
|
||||
enlistmentDate: string;
|
||||
personalExcerpt?: string | null;
|
||||
personalExcerpt?: {
|
||||
root: {
|
||||
type: string;
|
||||
children: {
|
||||
type: any;
|
||||
version: number;
|
||||
[k: string]: unknown;
|
||||
}[];
|
||||
direction: ('ltr' | 'rtl') | null;
|
||||
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
|
||||
indent: number;
|
||||
version: number;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
} | null;
|
||||
intelExcerpt?: {
|
||||
root: {
|
||||
type: string;
|
||||
|
|
|
|||
|
|
@ -99,6 +99,10 @@ export const EventTypes = {
|
|||
AccountDiscordRequest: "account:discord-request",
|
||||
AccountPreferencesUpdate: "account:preferences-update",
|
||||
|
||||
// Dossier
|
||||
DossierPersonalExcerptUpdate: "dossier:personal-excerpt-update",
|
||||
DossierIntelExcerptUpdate: "dossier:intel-excerpt-update",
|
||||
|
||||
// Attendance
|
||||
MissionAttendanceChange: "mission:attendance-change",
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue