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,
|
getLeaderAggregate,
|
||||||
upsertEvaluation,
|
upsertEvaluation,
|
||||||
} from "@/lib/evaluations";
|
} 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> {
|
interface ActionResult<T = undefined> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|
@ -150,3 +157,114 @@ export async function getEvaluationSummary(
|
||||||
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
|
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 { headers as nextHeaders } from "next/headers";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Award, Assignment, Media, Qualification, Rank, User } from "@/payload-types";
|
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 { ClientDate } from "@/components/frontend/ClientDate";
|
||||||
import { resolveLevel } from "@/utils/xp/resolveLevel";
|
import { resolveLevel } from "@/utils/xp/resolveLevel";
|
||||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
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 { isDirectLeaderOf, isMissionEvaluable } from "@/lib/evaluations";
|
||||||
import { PERFORMANCE_LEVELS } from "@/lib/evaluations/levels";
|
import { PERFORMANCE_LEVELS } from "@/lib/evaluations/levels";
|
||||||
import { getEvaluationSummary } from "./actions";
|
import { getEvaluationSummary } from "./actions";
|
||||||
|
|
@ -20,9 +19,7 @@ import {
|
||||||
CrosshairIcon,
|
CrosshairIcon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
HashIcon,
|
HashIcon,
|
||||||
HourglassIcon,
|
|
||||||
PlaneIcon,
|
PlaneIcon,
|
||||||
ShieldCheck,
|
|
||||||
ShieldIcon,
|
ShieldIcon,
|
||||||
SkullIcon,
|
SkullIcon,
|
||||||
SparkleIcon,
|
SparkleIcon,
|
||||||
|
|
@ -43,6 +40,7 @@ import AwardCard from "@/components/frontend/account/AwardCard";
|
||||||
import { ProfileGrantAwardButton } from "@/components/frontend/account/ProfileGrantAwardButton";
|
import { ProfileGrantAwardButton } from "@/components/frontend/account/ProfileGrantAwardButton";
|
||||||
import { RibbonRack } from "@/components/frontend/account/RibbonRack";
|
import { RibbonRack } from "@/components/frontend/account/RibbonRack";
|
||||||
import { LeadershipEvaluationSection } from "@/components/frontend/profile/LeadershipEvaluationSection";
|
import { LeadershipEvaluationSection } from "@/components/frontend/profile/LeadershipEvaluationSection";
|
||||||
|
import { DossierExcerptEditors } from "@/components/frontend/profile/DossierExcerptEditors";
|
||||||
import { getLeadershipDisplayLabel } from "@/lib/evaluations/levels";
|
import { getLeadershipDisplayLabel } from "@/lib/evaluations/levels";
|
||||||
import type {
|
import type {
|
||||||
EvaluationMissionOption,
|
EvaluationMissionOption,
|
||||||
|
|
@ -206,12 +204,16 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||||
viewer && !isProfileOwner
|
viewer && !isProfileOwner
|
||||||
? isDirectLeaderOf(payload, viewer.id, user.id)
|
? isDirectLeaderOf(payload, viewer.id, user.id)
|
||||||
: Promise.resolve(false);
|
: 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([
|
await Promise.all([
|
||||||
evaluationSummaryPromise,
|
evaluationSummaryPromise,
|
||||||
eligibleMissionsPromise,
|
eligibleMissionsPromise,
|
||||||
privilegedViewerPromise,
|
privilegedViewerPromise,
|
||||||
directLeaderPromise,
|
directLeaderPromise,
|
||||||
|
canEditIntelPromise,
|
||||||
]);
|
]);
|
||||||
const canViewSubordinate =
|
const canViewSubordinate =
|
||||||
isProfileOwner || isLegacyPrivileged || isSuperuserViewer || isDirectLeader;
|
isProfileOwner || isLegacyPrivileged || isSuperuserViewer || isDirectLeader;
|
||||||
|
|
@ -449,38 +451,13 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||||
Personnel Dossier
|
Personnel Dossier
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<DossierExcerptEditors
|
||||||
<Item variant="outline">
|
profileId={profile.id}
|
||||||
<ItemHeader>
|
personalExcerpt={profile.dossier?.personalExcerpt ?? null}
|
||||||
<ItemTitle>Personal Excerpt</ItemTitle>
|
intelExcerpt={profile.dossier?.intelExcerpt ?? null}
|
||||||
</ItemHeader>
|
canEditPersonal={isProfileOwner}
|
||||||
<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">
|
canEditIntel={canEditIntel}
|
||||||
<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>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Qualifications ─────────────────────────────────── */}
|
{/* ── Qualifications ─────────────────────────────────── */}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Award } from "@/payload-types";
|
||||||
import { broadcastToUser } from "@/lib/realtime/bus";
|
import { broadcastToUser } from "@/lib/realtime/bus";
|
||||||
import { notifyAwardGrants } from "@/lib/awards";
|
import { notifyAwardGrants } from "@/lib/awards";
|
||||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||||
|
import { lexicalPlaintextLength } from "@/lib/lexical/text";
|
||||||
|
|
||||||
export const Profiles: CollectionConfig = {
|
export const Profiles: CollectionConfig = {
|
||||||
slug: "profiles",
|
slug: "profiles",
|
||||||
|
|
@ -73,7 +74,14 @@ export const Profiles: CollectionConfig = {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "personalExcerpt",
|
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",
|
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_20260905_215658 from './20260905_215658';
|
||||||
import * as migration_20260906_045502 from './20260906_045502';
|
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_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 = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -184,6 +185,11 @@ export const migrations = [
|
||||||
{
|
{
|
||||||
up: migration_20260906_050900_add_evaluation_reminders_field.up,
|
up: migration_20260906_050900_add_evaluation_reminders_field.up,
|
||||||
down: migration_20260906_050900_add_evaluation_reminders_field.down,
|
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;
|
profileTitle?: string | null;
|
||||||
dossier: {
|
dossier: {
|
||||||
enlistmentDate: string;
|
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?: {
|
intelExcerpt?: {
|
||||||
root: {
|
root: {
|
||||||
type: string;
|
type: string;
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,10 @@ export const EventTypes = {
|
||||||
AccountDiscordRequest: "account:discord-request",
|
AccountDiscordRequest: "account:discord-request",
|
||||||
AccountPreferencesUpdate: "account:preferences-update",
|
AccountPreferencesUpdate: "account:preferences-update",
|
||||||
|
|
||||||
|
// Dossier
|
||||||
|
DossierPersonalExcerptUpdate: "dossier:personal-excerpt-update",
|
||||||
|
DossierIntelExcerptUpdate: "dossier:intel-excerpt-update",
|
||||||
|
|
||||||
// Attendance
|
// Attendance
|
||||||
MissionAttendanceChange: "mission:attendance-change",
|
MissionAttendanceChange: "mission:attendance-change",
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue