fix: the content document

This commit is contained in:
Nathnael
2026-08-09 13:27:05 +00:00
parent 182787e143
commit ff9bb4954a
30 changed files with 1624 additions and 1054 deletions

View File

@@ -1,95 +0,0 @@
import { Accordion, ActionIcon, Center, Group, Text, Tooltip } from "@mantine/core";
import { ChevronDown, ChevronUp, Trash2 } from "lucide-react";
import type { ReactNode } from "react";
interface AccordionRowProps {
value: string;
/** Collapsed summary — the heading, question or card title. */
title: string;
/** Small dimmed line under the title, e.g. a body excerpt. */
subtitle?: string;
index: number;
length: number;
onMove: (delta: number) => void;
onRemove: () => void;
children: ReactNode;
}
/**
* One collapsible item with reorder and delete controls in its header.
*
* Collapsing is the point: a legal document has fifteen sections and the FAQ
* seventeen answers, and rendering every textarea expanded turned each tab into
* an unnavigable mile of boxes. Collapsed, the tab reads as the list of
* headings the customer actually sees.
*
* The buttons sit outside `Accordion.Control` so clicking one does not also
* toggle the panel.
*/
export function AccordionRow({
value,
title,
subtitle,
index,
length,
onMove,
onRemove,
children,
}: AccordionRowProps) {
return (
<Accordion.Item value={value}>
<Center>
<Accordion.Control>
<div style={{ minWidth: 0 }}>
<Text fw={500} truncate>
{title || <Text span c="dimmed">(untitled)</Text>}
</Text>
{subtitle && (
<Text size="xs" c="dimmed" truncate>
{subtitle}
</Text>
)}
</div>
</Accordion.Control>
<Group gap={2} wrap="nowrap" pr="sm">
<Tooltip label="Move up">
<ActionIcon
variant="subtle"
color="gray"
disabled={index === 0}
onClick={() => onMove(-1)}
>
<ChevronUp size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Move down">
<ActionIcon
variant="subtle"
color="gray"
disabled={index === length - 1}
onClick={() => onMove(1)}
>
<ChevronDown size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Remove">
<ActionIcon variant="subtle" color="red" onClick={onRemove}>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Center>
<Accordion.Panel>{children}</Accordion.Panel>
</Accordion.Item>
);
}
/** First line of a markdown body, for an accordion subtitle. */
export function excerpt(markdown: string, max = 90): string {
const line = markdown.replace(/[#*`>-]/g, "").trim().split("\n")[0] ?? "";
return line.length > max ? `${line.slice(0, max)}` : line;
}
export default AccordionRow;

View File

@@ -0,0 +1,163 @@
import { ActionIcon, Box, Button, Group, Stack, Text, Tooltip } from "@mantine/core";
import { ChevronDown, ChevronUp, Plus } from "lucide-react";
export interface RailItem {
id: string;
label: string;
/** Renders as a small caps heading above the items that follow it. */
heading?: string;
/** Nested one level (an FAQ question under its group). */
indented?: boolean;
}
interface DocumentRailProps {
items: RailItem[];
selectedId: string | null;
onSelect: (id: string) => void;
onMove: (id: string, delta: number) => void;
/** Whether this row can move in the given direction. */
canMove: (id: string, delta: number) => boolean;
addLabel: string;
onAdd: () => void;
emptyLabel: string;
}
/**
* The list of pages that make up a document, and the only navigation on the
* tab. Replaces the accordion stack: a public officer editing the privacy
* policy sees fifteen page names, clicks one, and edits it — instead of
* scrolling a mile of open text boxes trying to find the right one.
*
* Reorder arrows appear only on the selected row, so the resting state is a
* clean list rather than fifteen rows of buttons.
*/
export function DocumentRail({
items,
selectedId,
onSelect,
onMove,
canMove,
addLabel,
onAdd,
emptyLabel,
}: DocumentRailProps) {
return (
<Stack
gap={4}
p="sm"
style={{
width: 280,
flexShrink: 0,
alignSelf: "flex-start",
background: "#FFFFFF",
border: "1px solid var(--mantine-color-gray-2)",
borderRadius: "var(--mantine-radius-lg)",
}}
>
{items.length === 0 && (
<Text size="sm" c="dimmed" p="sm">
{emptyLabel}
</Text>
)}
{items.map((item) => {
const selected = item.id === selectedId;
return (
<Box key={item.id}>
{item.heading && (
<Text
size="xs"
fw={700}
c="dimmed"
tt="uppercase"
mt="sm"
mb={4}
px="xs"
style={{ letterSpacing: "0.04em" }}
>
{item.heading}
</Text>
)}
<Group
gap={4}
wrap="nowrap"
px="xs"
py={6}
ml={item.indented ? "sm" : 0}
onClick={() => onSelect(item.id)}
style={{
borderRadius: "var(--mantine-radius-sm)",
cursor: "pointer",
background: selected
? "var(--mantine-primary-color-light)"
: "transparent",
}}
>
<Text
size="sm"
fw={selected ? 600 : 400}
truncate
style={{ flex: 1, minWidth: 0 }}
>
{item.label || (
<Text span c="dimmed" size="sm">
Untitled
</Text>
)}
</Text>
{selected && (
<Group gap={0} wrap="nowrap">
<Tooltip label="Move up">
<ActionIcon
size="sm"
variant="subtle"
color="gray"
disabled={!canMove(item.id, -1)}
onClick={(e) => {
e.stopPropagation();
onMove(item.id, -1);
}}
>
<ChevronUp size={14} />
</ActionIcon>
</Tooltip>
<Tooltip label="Move down">
<ActionIcon
size="sm"
variant="subtle"
color="gray"
disabled={!canMove(item.id, 1)}
onClick={(e) => {
e.stopPropagation();
onMove(item.id, 1);
}}
>
<ChevronDown size={14} />
</ActionIcon>
</Tooltip>
</Group>
)}
</Group>
</Box>
);
})}
<Button
variant="subtle"
size="sm"
mt="xs"
leftSection={<Plus size={16} />}
onClick={onAdd}
styles={{ inner: { justifyContent: "flex-start" } }}
fullWidth
>
{addLabel}
</Button>
</Stack>
);
}
export default DocumentRail;

View File

@@ -0,0 +1,98 @@
import { Box, Button, Group, Stack, Text, TextInput } from "@mantine/core";
import { Trash2 } from "lucide-react";
import type { ReactNode } from "react";
interface EditorPaneProps {
/** Large borderless field at the top — the page's own name. */
title: string;
titlePlaceholder: string;
onTitleChange: (next: string) => void;
onRemove: () => void;
removeLabel: string;
children: ReactNode;
}
/**
* The open page. One title, one body, one Remove — everything else about the
* document lives in the rail, so this pane stays as close to a blank sheet as
* the feature set allows.
*/
export function EditorPane({
title,
titlePlaceholder,
onTitleChange,
onRemove,
removeLabel,
children,
}: EditorPaneProps) {
return (
<Stack
gap="md"
p="xl"
style={{
flex: 1,
minWidth: 0,
background: "#FFFFFF",
border: "1px solid var(--mantine-color-gray-2)",
borderRadius: "var(--mantine-radius-lg)",
}}
>
<TextInput
value={title}
placeholder={titlePlaceholder}
onChange={(e) => onTitleChange(e.currentTarget.value)}
variant="unstyled"
styles={{
input: {
fontSize: "1.6rem",
fontWeight: 700,
lineHeight: 1.3,
height: "auto",
padding: 0,
},
}}
/>
<Box
style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}
pt="md"
>
{children}
</Box>
<Group justify="flex-end" pt="xs">
<Button
variant="subtle"
color="red"
size="compact-sm"
leftSection={<Trash2 size={14} />}
onClick={onRemove}
>
{removeLabel}
</Button>
</Group>
</Stack>
);
}
/** Shown when a document has no pages yet. */
export function EmptyPane({ message }: { message: string }) {
return (
<Stack
align="center"
justify="center"
p="xl"
style={{
flex: 1,
minHeight: 320,
background: "#FFFFFF",
border: "1px solid var(--mantine-color-gray-2)",
borderRadius: "var(--mantine-radius-lg)",
}}
>
<Text c="dimmed">{message}</Text>
</Stack>
);
}
export default EditorPane;

View File

@@ -1,242 +0,0 @@
import type { PortalFaqContent, PortalFaqGroup } from "@edr/types";
import {
Accordion,
Badge,
Button,
Card,
Group,
Stack,
Switch,
TextInput,
} from "@mantine/core";
import { Plus } from "lucide-react";
import { AccordionRow, excerpt } from "./AccordionRow";
import { moveAt, newId, removeAt, replaceAt } from "./array-helpers";
import { MarkdownEditor, MarkdownHint } from "./MarkdownEditor";
interface FaqEditorProps {
value: PortalFaqContent;
onChange: (next: PortalFaqContent) => void;
}
const EMPTY_FOOTER = {
heading: "Still need a hand?",
body: "",
ctaLabel: "Go to Help & Support",
ctaTo: "/help",
};
export function FaqEditor({ value, onChange }: FaqEditorProps) {
const setGroups = (groups: PortalFaqGroup[]) => onChange({ ...value, groups });
const setGroup = (index: number, next: PortalFaqGroup) =>
setGroups(replaceAt(value.groups, index, next));
return (
<Stack gap="lg">
<Card withBorder padding="md" radius="md">
<Stack gap="md">
<TextInput
label="Page title"
value={value.title}
onChange={(e) =>
onChange({ ...value, title: e.currentTarget.value })
}
/>
<TextInput
label="Subtitle"
value={value.subtitle}
onChange={(e) =>
onChange({ ...value, subtitle: e.currentTarget.value })
}
/>
</Stack>
</Card>
<MarkdownHint />
<Accordion variant="separated" radius="md" chevronPosition="left">
{value.groups.map((group, groupIndex) => (
<AccordionRow
key={group.id}
value={group.id}
title={group.title}
subtitle={`${group.items.length} question${group.items.length === 1 ? "" : "s"}`}
index={groupIndex}
length={value.groups.length}
onMove={(delta) => setGroups(moveAt(value.groups, groupIndex, delta))}
onRemove={() => setGroups(removeAt(value.groups, groupIndex))}
>
<Stack gap="md">
<TextInput
label="Group title"
value={group.title}
onChange={(e) =>
setGroup(groupIndex, {
...group,
title: e.currentTarget.value,
})
}
/>
<Accordion variant="contained" radius="sm" chevronPosition="left">
{group.items.map((item, itemIndex) => (
<AccordionRow
key={item.id}
value={item.id}
title={item.question}
subtitle={excerpt(item.answer, 70)}
index={itemIndex}
length={group.items.length}
onMove={(delta) =>
setGroup(groupIndex, {
...group,
items: moveAt(group.items, itemIndex, delta),
})
}
onRemove={() =>
setGroup(groupIndex, {
...group,
items: removeAt(group.items, itemIndex),
})
}
>
<Stack gap="md">
<TextInput
label="Question"
value={item.question}
onChange={(e) =>
setGroup(groupIndex, {
...group,
items: replaceAt(group.items, itemIndex, {
...item,
question: e.currentTarget.value,
}),
})
}
/>
<MarkdownEditor
label="Answer"
value={item.answer}
onChange={(answer) =>
setGroup(groupIndex, {
...group,
items: replaceAt(group.items, itemIndex, {
...item,
answer,
}),
})
}
/>
</Stack>
</AccordionRow>
))}
</Accordion>
<Button
variant="subtle"
size="xs"
leftSection={<Plus size={14} />}
style={{ alignSelf: "flex-start" }}
onClick={() =>
setGroup(groupIndex, {
...group,
items: [
...group.items,
{ id: newId(), question: "New question", answer: "" },
],
})
}
>
Add question
</Button>
</Stack>
</AccordionRow>
))}
</Accordion>
<Button
variant="light"
leftSection={<Plus size={16} />}
style={{ alignSelf: "flex-start" }}
onClick={() =>
setGroups([
...value.groups,
{ id: newId(), title: "New group", items: [] },
])
}
>
Add group
</Button>
<Card withBorder padding="md" radius="md">
<Stack gap="md">
<Group justify="space-between">
<Switch
label="Closing card"
checked={Boolean(value.footer)}
onChange={(e) =>
onChange({
...value,
footer: e.currentTarget.checked ? EMPTY_FOOTER : null,
})
}
/>
{!value.footer && <Badge variant="light" color="gray">Hidden</Badge>}
</Group>
{value.footer && (
<>
<TextInput
label="Heading"
value={value.footer.heading}
onChange={(e) =>
onChange({
...value,
footer: { ...value.footer!, heading: e.currentTarget.value },
})
}
/>
<MarkdownEditor
label="Body"
value={value.footer.body}
onChange={(body) =>
onChange({ ...value, footer: { ...value.footer!, body } })
}
/>
<Group grow>
<TextInput
label="Button label"
value={value.footer.ctaLabel}
onChange={(e) =>
onChange({
...value,
footer: {
...value.footer!,
ctaLabel: e.currentTarget.value,
},
})
}
/>
<TextInput
label="Button link"
description="A portal route (/help) or an https:// URL"
value={value.footer.ctaTo}
onChange={(e) =>
onChange({
...value,
footer: { ...value.footer!, ctaTo: e.currentTarget.value },
})
}
/>
</Group>
</>
)}
</Stack>
</Card>
</Stack>
);
}
export default FaqEditor;

View File

@@ -0,0 +1,227 @@
import type { PortalFaqContent, PortalFaqGroup } from "@edr/types";
import { Button, Group, Stack, Text, TextInput } from "@mantine/core";
import { Plus } from "lucide-react";
import { useEffect, useState } from "react";
import { moveAt, newId, removeAt, replaceAt } from "./array-helpers";
import { DocumentRail, type RailItem } from "./DocumentRail";
import { EditorPane, EmptyPane } from "./EditorPane";
import { MarkdownEditor } from "./MarkdownEditor";
interface FaqWorkspaceProps {
value: PortalFaqContent;
onChange: (next: PortalFaqContent) => void;
/** Reseed counter — see the `key` on the editor below. */
seed: number;
}
/**
* The FAQ is two levels deep, so the rail shows each group as a heading with
* its questions beneath — the same outline a customer sees on the page. Picking
* a question opens it; picking the group's own row renames or removes it.
*/
export function FaqWorkspace({
value,
onChange,
seed,
}: FaqWorkspaceProps) {
const groups = value.groups ?? [];
const [selectedId, setSelectedId] = useState<string | null>(
groups[0]?.items[0]?.id ?? groups[0]?.id ?? null,
);
const setGroups = (next: PortalFaqGroup[]) =>
onChange({ ...value, groups: next });
// Every selectable row, in display order: the group's own row, then its
// questions. Flattening once keeps selection and reordering simple.
const rows: RailItem[] = groups.flatMap((group) => [
{ id: group.id, label: group.title || "Untitled group", heading: "Group" },
...group.items.map((item) => ({
id: item.id,
label: item.question,
indented: true,
})),
]);
useEffect(() => {
if (!rows.some((row) => row.id === selectedId)) {
setSelectedId(rows[0]?.id ?? null);
}
}, [rows, selectedId]);
const groupIndex = groups.findIndex((group) => group.id === selectedId);
const selectedGroup = groupIndex >= 0 ? groups[groupIndex] : null;
const ownerIndex = groups.findIndex((group) =>
group.items.some((item) => item.id === selectedId),
);
const owner = ownerIndex >= 0 ? groups[ownerIndex] : null;
const itemIndex =
owner?.items.findIndex((item) => item.id === selectedId) ?? -1;
const selectedItem = owner && itemIndex >= 0 ? owner.items[itemIndex] : null;
const move = (id: string, delta: number) => {
const asGroup = groups.findIndex((group) => group.id === id);
if (asGroup >= 0) {
setGroups(moveAt(groups, asGroup, delta));
return;
}
const at = groups.findIndex((group) =>
group.items.some((item) => item.id === id),
);
if (at < 0) return;
const within = groups[at].items.findIndex((item) => item.id === id);
setGroups(
replaceAt(groups, at, {
...groups[at],
items: moveAt(groups[at].items, within, delta),
}),
);
};
const canMove = (id: string, delta: number) => {
const asGroup = groups.findIndex((group) => group.id === id);
if (asGroup >= 0) {
const target = asGroup + delta;
return target >= 0 && target < groups.length;
}
const at = groups.findIndex((group) =>
group.items.some((item) => item.id === id),
);
if (at < 0) return false;
const within = groups[at].items.findIndex((item) => item.id === id);
const target = within + delta;
return target >= 0 && target < groups[at].items.length;
};
const addQuestion = () => {
// Add into the group the author is currently in, or the last one.
const target = owner ?? selectedGroup ?? groups[groups.length - 1];
if (!target) return;
const at = groups.findIndex((group) => group.id === target.id);
const question = { id: newId(), question: "", answer: "" };
setGroups(
replaceAt(groups, at, {
...target,
items: [...target.items, question],
}),
);
setSelectedId(question.id);
};
const addGroup = () => {
const group = { id: newId(), title: "New group", items: [] };
setGroups([...groups, group]);
setSelectedId(group.id);
};
return (
<Group align="flex-start" gap="lg" wrap="nowrap">
<Stack gap="xs" style={{ flexShrink: 0 }}>
<DocumentRail
items={rows}
selectedId={selectedId}
onSelect={setSelectedId}
onMove={move}
canMove={canMove}
addLabel="Add a question"
onAdd={addQuestion}
emptyLabel="No groups yet — add one to start."
/>
<Button
variant="subtle"
size="sm"
leftSection={<Plus size={16} />}
onClick={addGroup}
>
Add a group
</Button>
</Stack>
{selectedItem && owner ? (
<EditorPane
title={selectedItem.question}
titlePlaceholder="What is the customer asking?"
onTitleChange={(question) =>
setGroups(
replaceAt(groups, ownerIndex, {
...owner,
items: replaceAt(owner.items, itemIndex, {
...selectedItem,
question,
}),
}),
)
}
onRemove={() =>
setGroups(
replaceAt(groups, ownerIndex, {
...owner,
items: removeAt(owner.items, itemIndex),
}),
)
}
removeLabel="Delete this question"
>
<MarkdownEditor
// MDXEditor reads `markdown` only on mount — without a key,
// switching questions kept the previous answer on screen.
key={`${seed}:${selectedItem.id}`}
value={selectedItem.answer}
onChange={(answer) =>
setGroups(
replaceAt(groups, ownerIndex, {
...owner,
items: replaceAt(owner.items, itemIndex, {
...selectedItem,
answer,
}),
}),
)
}
/>
</EditorPane>
) : selectedGroup ? (
<EditorPane
title={selectedGroup.title}
titlePlaceholder="Group name"
onTitleChange={(title) =>
setGroups(replaceAt(groups, groupIndex, { ...selectedGroup, title }))
}
onRemove={() => setGroups(removeAt(groups, groupIndex))}
removeLabel="Delete this group and its questions"
>
<Stack gap="sm">
<Text size="sm" c="dimmed">
A group is just a heading on the FAQ page. It holds{" "}
{selectedGroup.items.length} question
{selectedGroup.items.length === 1 ? "" : "s"} pick one on the
left to edit it.
</Text>
<TextInput
label="Group name"
value={selectedGroup.title}
onChange={(e) =>
setGroups(
replaceAt(groups, groupIndex, {
...selectedGroup,
title: e.currentTarget.value,
}),
)
}
/>
</Stack>
</EditorPane>
) : (
<EmptyPane message="Add a group to get started." />
)}
</Group>
);
}
export default FaqWorkspace;

View File

@@ -1,125 +0,0 @@
import type { PortalHelpContent, PortalHelpSection } from "@edr/types";
import { Accordion, Button, Card, Divider, Stack, TextInput } from "@mantine/core";
import { Plus } from "lucide-react";
import { AccordionRow, excerpt } from "./AccordionRow";
import { moveAt, newId, removeAt, replaceAt } from "./array-helpers";
import { MarkdownEditor, MarkdownHint } from "./MarkdownEditor";
import { MediaManager } from "./MediaManager";
interface HelpEditorProps {
value: PortalHelpContent;
onChange: (next: PortalHelpContent) => void;
}
/**
* The help page is built, not filled in: an ordered list of sections, each a
* heading plus free markdown plus any images or videos. Nothing about the page
* is fixed except its title, so support can add, reorder or drop a section
* without a code change.
*/
export function HelpEditor({ value, onChange }: HelpEditorProps) {
// A row written before the free-form conversion has no `sections` at all.
// Tolerate it rather than crashing the tab: the migration rewrites it, but
// an environment can be mid-deploy.
const sections = value.sections ?? [];
const setSections = (next: PortalHelpSection[]) =>
onChange({ ...value, sections: next });
return (
<Stack gap="lg">
<Card withBorder padding="md" radius="md">
<Stack gap="md">
<TextInput
label="Page title"
value={value.title}
onChange={(e) =>
onChange({ ...value, title: e.currentTarget.value })
}
/>
<TextInput
label="Subtitle"
value={value.subtitle}
onChange={(e) =>
onChange({ ...value, subtitle: e.currentTarget.value })
}
/>
</Stack>
</Card>
<MarkdownHint />
<Accordion variant="separated" radius="md" chevronPosition="left">
{sections.map((section, index) => (
<AccordionRow
key={section.id}
value={section.id}
title={section.heading}
subtitle={
section.media.length
? `${excerpt(section.body, 60)} · ${section.media.length} attachment${section.media.length === 1 ? "" : "s"}`
: excerpt(section.body)
}
index={index}
length={sections.length}
onMove={(delta) => setSections(moveAt(sections, index, delta))}
onRemove={() => setSections(removeAt(sections, index))}
>
<Stack gap="md">
<TextInput
label="Heading"
value={section.heading}
onChange={(e) =>
setSections(
replaceAt(sections, index, {
...section,
heading: e.currentTarget.value,
}),
)
}
/>
<MarkdownEditor
label="Body"
value={section.body}
onChange={(body) =>
setSections(
replaceAt(sections, index, { ...section, body }),
)
}
/>
<Divider />
<MediaManager
value={section.media}
onChange={(media) =>
setSections(
replaceAt(sections, index, { ...section, media }),
)
}
/>
</Stack>
</AccordionRow>
))}
</Accordion>
<Button
variant="light"
leftSection={<Plus size={16} />}
style={{ alignSelf: "flex-start" }}
onClick={() =>
setSections([
...sections,
{ id: newId(), heading: "New section", body: "", media: [] },
])
}
>
Add section
</Button>
</Stack>
);
}
export default HelpEditor;

View File

@@ -1,110 +0,0 @@
import type { PortalLegalContent } from "@edr/types";
import { Accordion, Button, Card, Group, Stack, TextInput } from "@mantine/core";
import { Plus } from "lucide-react";
import { AccordionRow, excerpt } from "./AccordionRow";
import { moveAt, newId, removeAt, replaceAt } from "./array-helpers";
import { MarkdownEditor, MarkdownHint } from "./MarkdownEditor";
interface LegalDocEditorProps {
value: PortalLegalContent;
onChange: (next: PortalLegalContent) => void;
}
/** Shared by the Privacy and Terms tabs — the two documents have one shape. */
export function LegalDocEditor({ value, onChange }: LegalDocEditorProps) {
const setSections = (sections: PortalLegalContent["sections"]) =>
onChange({ ...value, sections });
return (
<Stack gap="lg">
<Card withBorder padding="md" radius="md">
<Stack gap="md">
<Group grow align="flex-start">
<TextInput
label="Page title"
value={value.title}
onChange={(e) =>
onChange({ ...value, title: e.currentTarget.value })
}
/>
<TextInput
label="Last updated"
description="Free text, e.g. 6 August 2026"
value={value.lastUpdated}
onChange={(e) =>
onChange({ ...value, lastUpdated: e.currentTarget.value })
}
/>
</Group>
<TextInput
label="Subtitle"
value={value.subtitle}
onChange={(e) =>
onChange({ ...value, subtitle: e.currentTarget.value })
}
/>
</Stack>
</Card>
<MarkdownHint />
<Accordion variant="separated" radius="md" chevronPosition="left">
{value.sections.map((section, index) => (
<AccordionRow
key={section.id}
value={section.id}
title={section.heading}
subtitle={excerpt(section.body)}
index={index}
length={value.sections.length}
onMove={(delta) => setSections(moveAt(value.sections, index, delta))}
onRemove={() => setSections(removeAt(value.sections, index))}
>
<Stack gap="md">
<TextInput
label="Heading"
value={section.heading}
onChange={(e) =>
setSections(
replaceAt(value.sections, index, {
...section,
heading: e.currentTarget.value,
}),
)
}
/>
<MarkdownEditor
label="Body"
value={section.body}
onChange={(body) =>
setSections(
replaceAt(value.sections, index, { ...section, body }),
)
}
/>
</Stack>
</AccordionRow>
))}
</Accordion>
<Button
variant="light"
leftSection={<Plus size={16} />}
style={{ alignSelf: "flex-start" }}
onClick={() =>
setSections([
...value.sections,
{ id: newId(), heading: "New section", body: "" },
])
}
>
Add section
</Button>
</Stack>
);
}
export default LegalDocEditor;

View File

@@ -1,22 +1,101 @@
import ReactMarkdown from "react-markdown";
import { isPortalVideoSrc, PORTAL_MEDIA_URI_SCHEME } from "@edr/types";
import { Text } from "@mantine/core";
import { useEffect, useState } from "react";
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
import { resolvePreview } from "./MarkdownEditor";
// Same preflight fix the editor needs — Mantine's `Typography` defines its list
// and margin rules with `:where()`, which Tailwind's preflight outranks, so
// bullets rendered without markers here too.
import "./markdown-editor.css";
/**
* Read-only markdown rendering for the version-history preview. Editing goes
* through `MarkdownEditor` (MDXEditor); this is only for showing what an old
* version said.
* Renders one embedded picture or video. Stored copy holds `minio:<key>`, so
* the URL has to be signed before it can be shown; until it resolves the slot
* stays empty rather than flashing a broken image.
*/
function Embed({ src, alt }: { src: string; alt?: string }) {
const [resolved, setResolved] = useState<string | null>(
src.startsWith(PORTAL_MEDIA_URI_SCHEME) ? null : src,
);
useEffect(() => {
let active = true;
void resolvePreview(src).then((url) => {
if (active) setResolved(url);
});
return () => {
active = false;
};
}, [src]);
if (!resolved) return null;
// Spans, not <figure>: markdown wraps an image in a paragraph, and a
// <figure> inside a <p> is invalid HTML the browser silently re-parents —
// which dropped every embed after the first.
return (
<span style={{ display: "block", margin: "1rem 0" }}>
{isPortalVideoSrc(resolved) ? (
<video
src={resolved}
controls
preload="metadata"
style={{ width: "100%", borderRadius: 8, background: "#000" }}
/>
) : (
<img
src={resolved}
alt={alt ?? ""}
style={{ width: "100%", borderRadius: 8 }}
/>
)}
{alt && (
<Text component="span" display="block" size="sm" c="dimmed" mt={4}>
{alt}
</Text>
)}
</span>
);
}
/**
* Read-only markdown rendering, used by the Preview toggle and the version
* history. Editing goes through `MarkdownEditor` (MDXEditor).
*
* Same options as the portal's renderer — no `rehype-raw`, no custom
* `urlTransform` — so neither app grows an HTML-injection surface.
*/
/**
* Module scope on purpose. Declared inline, this object is rebuilt on every
* render, so React sees a brand-new component type for `img` and remounts
* `Embed` each time — which threw away the URL it had just resolved and left
* every uploaded picture and video permanently blank.
*/
const COMPONENTS = {
img: ({ src, alt }: { src?: string; alt?: string }) =>
typeof src === "string" ? <Embed src={src} alt={alt} /> : null,
};
/**
* Lets our own `minio:` refs through, and defers everything else to
* react-markdown's default vetting (which drops `javascript:` and friends).
*
* Needed because the default transform allows only http/https/mailto/tel, so
* an unresolved `minio:` ref was silently blanked and every uploaded picture
* rendered as an empty paragraph. Only the backoffice needs this: the public
* bundle has already had these refs replaced with signed https URLs, so the
* portal's renderer keeps the stock transform untouched.
*/
const urlTransform = (url: string) =>
url.startsWith(PORTAL_MEDIA_URI_SCHEME) ? url : defaultUrlTransform(url);
export function Markdown({ children }: { children: string }) {
return (
<div className="edr-md-content">
<ReactMarkdown>{children}</ReactMarkdown>
<ReactMarkdown components={COMPONENTS} urlTransform={urlTransform}>
{children}
</ReactMarkdown>
</div>
);
}

View File

@@ -1,13 +1,13 @@
import { PORTAL_MEDIA_URI_SCHEME } from "@edr/types";
import { Box, Stack, Text } from "@mantine/core";
import { Box, Button, Group, SegmentedControl, Stack, Text } from "@mantine/core";
import {
BlockTypeSelect,
BoldItalicUnderlineToggles,
CreateLink,
InsertImage,
InsertThematicBreak,
ListsToggle,
MDXEditor,
type MDXEditorMethods,
UndoRedo,
headingsPlugin,
imagePlugin,
@@ -19,18 +19,20 @@ import {
thematicBreakPlugin,
toolbarPlugin,
} from "@mdxeditor/editor";
import { ImagePlus } from "lucide-react";
import { useRef, useState } from "react";
import "@mdxeditor/editor/style.css";
import { portalContentService } from "@/services/portal-content.service";
import { Markdown } from "./Markdown";
import { MediaDialog } from "./MediaDialog";
// Undoes Tailwind's preflight inside the editor's content area — see the file.
import "./markdown-editor.css";
interface MarkdownEditorProps {
label: string;
value: string;
onChange: (next: string) => void;
description?: string;
}
/**
@@ -40,12 +42,12 @@ interface MarkdownEditorProps {
const previewCache = new Map<string, Promise<string>>();
/**
* Inserted images are stored as `minio:<key>`, never as the signed URL the
* upload returns: a presigned URL expires, so persisting one would leave every
* embedded image broken a few hours later. `imagePreviewHandler` resolves the
* ref back to a temporary URL purely for display, on both sides of the wire.
* Inserted media is stored as `minio:<key>`, never as the signed URL the upload
* returns: a presigned URL expires, so persisting one would leave every
* embedded picture broken a few hours later. This resolves the ref back to a
* temporary URL purely for display.
*/
function resolvePreview(url: string): Promise<string> {
export function resolvePreview(url: string): Promise<string> {
if (!url.startsWith(PORTAL_MEDIA_URI_SCHEME)) return Promise.resolve(url);
const key = url.slice(PORTAL_MEDIA_URI_SCHEME.length);
@@ -59,82 +61,124 @@ function resolvePreview(url: string): Promise<string> {
return pending;
}
export function MarkdownEditor({
label,
value,
onChange,
description,
}: MarkdownEditorProps) {
return (
<Stack gap={6}>
<Text size="sm" fw={500}>
{label}
</Text>
{description && (
<Text size="xs" c="dimmed">
{description}
</Text>
)}
export function MarkdownEditor({ value, onChange }: MarkdownEditorProps) {
const editorRef = useRef<MDXEditorMethods>(null);
const [mediaOpen, setMediaOpen] = useState(false);
const [mode, setMode] = useState<"write" | "preview">("write");
<Box
style={{
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: "var(--mantine-radius-sm)",
}}
>
<MDXEditor
markdown={value}
contentEditableClassName="edr-md-content"
// MDXEditor re-serialises the markdown once on mount, which differs
// harmlessly from what was stored (spacing, escaping). Reporting that
// as an edit made every tab open "unsaved" and let a Save write a
// no-op version, so the normalisation pass is ignored.
onChange={(markdown, initialMarkdownNormalize) => {
if (!initialMarkdownNormalize) onChange(markdown);
}}
plugins={[
headingsPlugin(),
listsPlugin(),
quotePlugin(),
linkPlugin(),
linkDialogPlugin(),
thematicBreakPlugin(),
imagePlugin({
imageUploadHandler: async (file) => {
const { key } = await portalContentService.uploadMedia(file);
return `${PORTAL_MEDIA_URI_SCHEME}${key}`;
},
imagePreviewHandler: resolvePreview,
}),
markdownShortcutPlugin(),
toolbarPlugin({
toolbarContents: () => (
<>
<UndoRedo />
<BoldItalicUnderlineToggles />
<BlockTypeSelect />
<ListsToggle />
<CreateLink />
<InsertImage />
<InsertThematicBreak />
</>
),
}),
return (
<Stack gap="sm" style={{ flex: 1, minHeight: 0 }}>
<Group justify="space-between" align="center">
<Text size="sm" fw={600} c="dimmed">
Page text
</Text>
<SegmentedControl
size="xs"
value={mode}
onChange={(next) => setMode(next as "write" | "preview")}
data={[
{ label: "Write", value: "write" },
{ label: "Preview", value: "preview" },
]}
/>
</Box>
</Group>
{mode === "write" ? (
<Box
style={{
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: "var(--mantine-radius-md)",
background: "#FFFFFF",
overflow: "hidden",
}}
>
<MDXEditor
ref={editorRef}
markdown={value}
contentEditableClassName="edr-md-content"
// MDXEditor re-serialises the markdown once on mount, which differs
// harmlessly from what was stored (spacing, escaping). Reporting
// that as an edit made every tab open "unsaved" and let a Save
// write a no-op version, so the normalisation pass is ignored.
onChange={(markdown, initialMarkdownNormalize) => {
if (!initialMarkdownNormalize) onChange(markdown);
}}
plugins={[
headingsPlugin(),
listsPlugin(),
quotePlugin(),
linkPlugin(),
linkDialogPlugin(),
thematicBreakPlugin(),
// Kept for rendering existing images; its own insert button is
// replaced by the one below, which also handles video.
imagePlugin({ imagePreviewHandler: resolvePreview }),
markdownShortcutPlugin(),
toolbarPlugin({
toolbarContents: () => (
<>
<UndoRedo />
{/* No underline: markdown has none, so MDXEditor emits a
raw <u> tag — and the portal's renderer drops raw HTML
by design, so the author's emphasis would silently
vanish for the customer. */}
<BoldItalicUnderlineToggles options={["Bold", "Italic"]} />
<BlockTypeSelect />
<ListsToggle />
<CreateLink />
<Button
size="compact-sm"
variant="subtle"
leftSection={<ImagePlus size={16} />}
onClick={() => setMediaOpen(true)}
>
Picture or video
</Button>
<InsertThematicBreak />
</>
),
}),
]}
/>
</Box>
) : (
<Box
p="lg"
style={{
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: "var(--mantine-radius-md)",
background: "#FFFFFF",
minHeight: 240,
}}
>
{/* The same renderer the customer gets — the only place an author can
watch an embedded video actually play before publishing. */}
<Markdown>{value || "_This page is empty._"}</Markdown>
</Box>
)}
<MediaDialog
opened={mediaOpen}
onClose={() => setMediaOpen(false)}
onInsert={(markdown) => {
editorRef.current?.insertMarkdown(`\n\n${markdown}\n\n`);
// Inserting through the ref bypasses onChange, so push it ourselves.
const next = editorRef.current?.getMarkdown();
if (next !== undefined) onChange(next);
}}
/>
</Stack>
);
}
/** Reminder of the substitution tokens, rendered once per tab. */
/** Reminder of the substitution tokens, shown once per document. */
export function MarkdownHint() {
return (
<Text size="xs" c="dimmed">
Placeholders resolve from the Contact tab, so one edit there updates every
page: <code>{"{{supportEmail}}"}</code> · <code>{"{{supportPhone}}"}</code>{" "}
· <code>{"{{supportOffice}}"}</code> · <code>{"{{supportHours}}"}</code> ·{" "}
<code>{"{{supportPhoneTel}}"}</code> (inside a tel: link).
Type <code>{"{{supportEmail}}"}</code>, <code>{"{{supportPhone}}"}</code>,{" "}
<code>{"{{supportOffice}}"}</code> or <code>{"{{supportHours}}"}</code>{" "}
anywhere and it fills in from the Contact tab change it once there and
every page updates.
</Text>
);
}

View File

@@ -0,0 +1,230 @@
import { PORTAL_MEDIA_URI_SCHEME, SUPPORT_MEDIA_MAX_BYTES } from "@edr/types";
import {
Box,
Button,
Group,
Loader,
Modal,
Progress,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { Film, Image as ImageIcon, UploadCloud } from "lucide-react";
import { useRef, useState } from "react";
import toast from "react-hot-toast";
import { portalContentService } from "@/services/portal-content.service";
interface MediaDialogProps {
opened: boolean;
onClose: () => void;
/** Receives the markdown to drop at the cursor. */
onInsert: (markdown: string) => void;
}
const MAX_MB = Math.round(SUPPORT_MEDIA_MAX_BYTES / (1024 * 1024));
/**
* Adds a picture or video to the text — drop a file, or browse for one.
*
* Replaces MDXEditor's built-in image dialog, which asks for a URL, only takes
* images, and leaves an author who just wants to show a screenshot with nothing
* to do. There is deliberately no "paste a link" field: everything lives in the
* platform, so nothing an editor inserts can rot because someone else's server
* moved a file.
*
* Uploads return an object key; the markdown stores `minio:<key>` and the API
* signs it fresh on every read.
*/
export function MediaDialog({ opened, onClose, onInsert }: MediaDialogProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState<number | null>(null);
const [dragging, setDragging] = useState(false);
const [uploaded, setUploaded] = useState<{
key: string;
kind: "image" | "video";
url: string;
} | null>(null);
const [caption, setCaption] = useState("");
const reset = () => {
setUploaded(null);
setCaption("");
setDragging(false);
if (inputRef.current) inputRef.current.value = "";
};
const close = () => {
reset();
onClose();
};
const upload = async (file: File) => {
if (file.size > SUPPORT_MEDIA_MAX_BYTES) {
toast.error(`That file is over ${MAX_MB} MB.`);
return;
}
setUploading(true);
setProgress(0);
try {
setUploaded(await portalContentService.uploadMedia(file, setProgress));
} catch (error) {
const err = error as {
code?: string;
response?: { data?: { message?: string } };
};
const message =
err.code === "ECONNABORTED"
? "That upload timed out. Check your connection and try again."
: (err.response?.data?.message ?? "Upload failed");
toast.error(Array.isArray(message) ? message.join(", ") : message);
} finally {
setUploading(false);
setProgress(null);
if (inputRef.current) inputRef.current.value = "";
}
};
const insert = () => {
if (!uploaded) return;
// The caption doubles as alt text, so it is worth prompting for.
onInsert(`![${caption}](${PORTAL_MEDIA_URI_SCHEME}${uploaded.key})`);
close();
};
return (
<Modal
opened={opened}
onClose={close}
title="Add a picture or video"
size="lg"
centered
>
<Stack gap="md">
{!uploaded ? (
<Box
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
const file = e.dataTransfer.files?.[0];
if (file) void upload(file);
}}
onClick={() => inputRef.current?.click()}
style={{
border: `2px dashed var(--mantine-color-${dragging ? "green" : "gray"}-4)`,
background: dragging
? "var(--mantine-color-green-0)"
: "var(--mantine-color-gray-0)",
borderRadius: "var(--mantine-radius-md)",
padding: "2.5rem 1.5rem",
textAlign: "center",
cursor: "pointer",
transition: "background 120ms, border-color 120ms",
}}
>
{uploading ? (
<Stack align="center" gap="xs">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{progress === null
? "Uploading…"
: `Uploading… ${progress}%`}
</Text>
{progress !== null && (
<Progress value={progress} w="60%" size="sm" />
)}
</Stack>
) : (
<Stack align="center" gap="xs">
<UploadCloud size={32} strokeWidth={1.5} />
<Text fw={500}>Drop a file here, or click to browse</Text>
<Text size="sm" c="dimmed">
Pictures (PNG, JPG, GIF) and videos (MP4, WebM) up to {MAX_MB} MB
</Text>
</Stack>
)}
</Box>
) : (
<Stack gap="md">
<Box
style={{
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: "var(--mantine-radius-md)",
padding: "0.75rem",
background: "var(--mantine-color-gray-0)",
}}
>
{uploaded.kind === "video" ? (
<video
src={uploaded.url}
controls
style={{ width: "100%", maxHeight: 320, borderRadius: 8 }}
/>
) : (
<img
src={uploaded.url}
alt=""
style={{
width: "100%",
maxHeight: 320,
objectFit: "contain",
borderRadius: 8,
}}
/>
)}
</Box>
<Group gap="xs" c="dimmed">
{uploaded.kind === "video" ? (
<Film size={16} />
) : (
<ImageIcon size={16} />
)}
<Text size="sm">
{uploaded.kind === "video" ? "Video" : "Picture"} uploaded
</Text>
</Group>
<TextInput
label="Caption"
description="Shown under the picture, and read aloud by screen readers. Optional."
value={caption}
onChange={(e) => setCaption(e.currentTarget.value)}
autoFocus
/>
</Stack>
)}
<input
ref={inputRef}
type="file"
accept="image/*,video/*"
hidden
onChange={(e) => {
const file = e.currentTarget.files?.[0];
if (file) void upload(file);
}}
/>
<Group justify="space-between">
<Button variant="subtle" onClick={uploaded ? reset : close}>
{uploaded ? "Choose a different file" : "Cancel"}
</Button>
<Button onClick={insert} disabled={!uploaded}>
Add to the page
</Button>
</Group>
</Stack>
</Modal>
);
}
export default MediaDialog;

View File

@@ -1,122 +0,0 @@
import type { PortalMedia } from "@edr/types";
import {
ActionIcon,
Button,
Group,
Paper,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { Film, Image as ImageIcon, Trash2, Upload } from "lucide-react";
import { useRef, useState } from "react";
import toast from "react-hot-toast";
import { portalContentService } from "@/services/portal-content.service";
import { newId, removeAt, replaceAt } from "./array-helpers";
interface MediaManagerProps {
value: PortalMedia[];
onChange: (next: PortalMedia[]) => void;
}
/**
* Attachments for one help section. Uploads store the MinIO object *key*; the
* signed URL the upload returns is short-lived and is never persisted, so the
* list shows the key rather than pretending to be a gallery.
*/
export function MediaManager({ value, onChange }: MediaManagerProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const upload = async (file: File) => {
setUploading(true);
try {
const { key, kind } = await portalContentService.uploadMedia(file);
onChange([...value, { id: newId(), kind, src: key, caption: null }]);
} catch (error) {
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Upload failed";
toast.error(Array.isArray(message) ? message.join(", ") : message);
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = "";
}
};
return (
<Stack gap="xs">
<Text size="sm" fw={500}>
Attachments
</Text>
{value.map((item, index) => (
<Paper key={item.id} withBorder p="xs" radius="sm">
<Group wrap="nowrap" align="center" gap="sm">
{item.kind === "video" ? (
<Film size={18} />
) : (
<ImageIcon size={18} />
)}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="xs" c="dimmed" truncate>
{item.src}
</Text>
<TextInput
size="xs"
placeholder="Caption (optional)"
value={item.caption ?? ""}
onChange={(e) =>
onChange(
replaceAt(value, index, {
...item,
caption: e.currentTarget.value || null,
}),
)
}
/>
</Stack>
<Tooltip label="Remove attachment">
<ActionIcon
variant="subtle"
color="red"
onClick={() => onChange(removeAt(value, index))}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Paper>
))}
<input
ref={inputRef}
type="file"
accept="image/*,video/*"
hidden
onChange={(e) => {
const file = e.currentTarget.files?.[0];
if (file) void upload(file);
}}
/>
<Button
variant="light"
size="xs"
loading={uploading}
leftSection={<Upload size={14} />}
style={{ alignSelf: "flex-start" }}
onClick={() => inputRef.current?.click()}
>
Upload image or video
</Button>
</Stack>
);
}
export default MediaManager;

View File

@@ -26,9 +26,9 @@ import {
useUpdatePortalDoc,
} from "@/hooks/portal-content/usePortalContentAdmin";
import { FaqEditor } from "./FaqEditor";
import { HelpEditor } from "./HelpEditor";
import { LegalDocEditor } from "./LegalDocEditor";
import { FaqWorkspace } from "./FaqWorkspace";
import { MarkdownHint } from "./MarkdownEditor";
import { SectionWorkspace } from "./SectionWorkspace";
import { VersionHistoryModal } from "./VersionHistoryModal";
const TABS: { slug: SupportDocSlug; label: string }[] = [
@@ -89,6 +89,14 @@ function DocumentTab({ slug }: { slug: SupportDocSlug }) {
const [note, setNote] = useState("");
const [historyOpen, setHistoryOpen] = useState(false);
/**
* Bumped every time the draft is replaced wholesale rather than edited —
* initial load, save, restore, Reset. The editors key off it to remount,
* because MDXEditor reads its markdown only on mount and would otherwise
* keep showing text the draft no longer holds.
*/
const [seed, setSeed] = useState(0);
// Reseed only when the server's version number moves (load, save, restore).
// Keying off `data` itself would let a background refetch wipe edits that are
// still in progress.
@@ -98,6 +106,7 @@ function DocumentTab({ slug }: { slug: SupportDocSlug }) {
seededVersion.current = data.version;
setDraft(data.payload);
setNote("");
setSeed((n) => n + 1);
}
}, [data]);
@@ -108,6 +117,7 @@ function DocumentTab({ slug }: { slug: SupportDocSlug }) {
const reset = () => {
setDraft(data.payload);
setNote("");
setSeed((n) => n + 1);
};
return (
@@ -174,7 +184,12 @@ function DocumentTab({ slug }: { slug: SupportDocSlug }) {
</Group>
</Card>
<DocumentEditor slug={slug} value={draft} onChange={setDraft} />
<DocumentEditor
slug={slug}
value={draft}
onChange={setDraft}
seed={seed}
/>
<VersionHistoryModal
slug={slug}
@@ -191,10 +206,13 @@ function DocumentEditor({
slug,
value,
onChange,
seed,
}: {
slug: SupportDocSlug;
value: SupportDocPayload;
onChange: (next: SupportDocPayload) => void;
/** Reseed counter; bumping it remounts the editors. */
seed: number;
}) {
switch (slug) {
case "CONTACT":
@@ -204,23 +222,98 @@ function DocumentEditor({
onChange={onChange}
/>
);
case "HELP":
return (
<HelpEditor value={value as PortalHelpContent} onChange={onChange} />
);
case "FAQ":
return <FaqEditor value={value as PortalFaqContent} onChange={onChange} />;
case "PRIVACY":
case "TERMS":
return (
<LegalDocEditor
value={value as PortalLegalContent}
<FaqWorkspace
value={value as PortalFaqContent}
onChange={onChange}
seed={seed}
/>
);
case "HELP":
case "PRIVACY":
case "TERMS": {
// All three are a title, a subtitle and a list of pages, so they share
// one workspace. `sections` is guarded because a row written before the
// free-form conversion has none.
const doc = value as PortalHelpContent | PortalLegalContent;
return (
<DocumentShell
value={doc}
onChange={onChange}
showLastUpdated={slug !== "HELP"}
seed={seed}
/>
);
}
}
}
/**
* Title, subtitle and (for the legal documents) the "last updated" line, above
* the page list. These three fields describe the whole document, so they sit
* apart from the page being edited.
*/
function DocumentShell({
value,
onChange,
showLastUpdated,
seed,
}: {
value: PortalHelpContent | PortalLegalContent;
onChange: (next: SupportDocPayload) => void;
showLastUpdated: boolean;
seed: number;
}) {
const legal = value as PortalLegalContent;
return (
<Stack gap="lg">
<Card withBorder padding="lg" radius="lg" bg="#FFFFFF">
<Stack gap="md">
<Group grow align="flex-start">
<TextInput
label="Page title"
description="The big heading customers see"
value={value.title}
onChange={(e) =>
onChange({ ...value, title: e.currentTarget.value })
}
/>
{showLastUpdated && (
<TextInput
label="Last updated"
description="Free text, e.g. 6 August 2026"
value={legal.lastUpdated}
onChange={(e) =>
onChange({ ...legal, lastUpdated: e.currentTarget.value })
}
/>
)}
</Group>
<TextInput
label="Intro line"
description="One sentence under the heading"
value={value.subtitle}
onChange={(e) =>
onChange({ ...value, subtitle: e.currentTarget.value })
}
/>
<MarkdownHint />
</Stack>
</Card>
<SectionWorkspace
sections={value.sections ?? []}
onChange={(sections) => onChange({ ...value, sections })}
seed={seed}
/>
</Stack>
);
}
/**
* Four fields, so no separate file. These values feed the help page's contact
* cards and resolve the `{{supportEmail}}`-style placeholders used throughout

View File

@@ -0,0 +1,107 @@
import type { PortalDocSection } from "@edr/types";
import { Group, Stack } from "@mantine/core";
import { useEffect, useState } from "react";
import { moveAt, newId, removeAt, replaceAt } from "./array-helpers";
import { DocumentRail } from "./DocumentRail";
import { EditorPane, EmptyPane } from "./EditorPane";
import { MarkdownEditor } from "./MarkdownEditor";
interface SectionWorkspaceProps {
sections: PortalDocSection[];
onChange: (next: PortalDocSection[]) => void;
/** Reseed counter — see the `key` on the editor below. */
seed: number;
}
/**
* Page-at-a-time editing for any document that is a list of sections — the help
* page and both legal documents. The list on the left is the document's table
* of contents; the pane on the right is the page you are working on.
*/
export function SectionWorkspace({
sections,
onChange,
seed,
}: SectionWorkspaceProps) {
const [selectedId, setSelectedId] = useState<string | null>(
sections[0]?.id ?? null,
);
// Keep a valid selection when the open page is deleted, or when the tab is
// reseeded after a save or a restore.
useEffect(() => {
if (!sections.some((section) => section.id === selectedId)) {
setSelectedId(sections[0]?.id ?? null);
}
}, [sections, selectedId]);
const index = sections.findIndex((section) => section.id === selectedId);
const selected = index >= 0 ? sections[index] : null;
const addSection = () => {
const section = { id: newId(), heading: "", body: "" };
onChange([...sections, section]);
setSelectedId(section.id);
};
return (
<Group align="flex-start" gap="lg" wrap="nowrap">
<DocumentRail
items={sections.map((section) => ({
id: section.id,
label: section.heading,
}))}
selectedId={selectedId}
onSelect={setSelectedId}
onMove={(id, delta) =>
onChange(
moveAt(
sections,
sections.findIndex((section) => section.id === id),
delta,
),
)
}
canMove={(id, delta) => {
const at = sections.findIndex((section) => section.id === id);
const target = at + delta;
return target >= 0 && target < sections.length;
}}
addLabel="Add a page"
onAdd={addSection}
emptyLabel="This document has no pages yet."
/>
{selected ? (
<EditorPane
title={selected.heading}
titlePlaceholder="Page heading"
onTitleChange={(heading) =>
onChange(replaceAt(sections, index, { ...selected, heading }))
}
onRemove={() => onChange(removeAt(sections, index))}
removeLabel="Delete this page"
>
<Stack gap="md">
<MarkdownEditor
// MDXEditor reads `markdown` only when it mounts, so without a
// key it keeps showing the previously-opened page's text. The
// counter is in the key too, so save, restore and Reset reseed it
// instead of leaving stale content on screen.
key={`${seed}:${selected.id}`}
value={selected.body}
onChange={(body) =>
onChange(replaceAt(sections, index, { ...selected, body }))
}
/>
</Stack>
</EditorPane>
) : (
<EmptyPane message="Add a page to get started." />
)}
</Group>
);
}
export default SectionWorkspace;

View File

@@ -39,11 +39,11 @@ export function summarizeVersion(
return [
{ label: "Title", body: help.title },
{ label: "Subtitle", body: help.subtitle },
...help.sections.map((section) => ({
// Pictures and videos live in the body, so they render in the preview
// alongside the text they belong to — nothing to summarise separately.
...(help.sections ?? []).map((section) => ({
label: section.heading,
body: section.media.length
? `${section.body}\n\n_${section.media.length} attachment${section.media.length === 1 ? "" : "s"}: ${section.media.map((m) => m.src).join(", ")}_`
: section.body,
body: section.body,
})),
];
}

View File

@@ -64,6 +64,7 @@ export const portalContentService = {
*/
async uploadMedia(
file: File,
onProgress?: (percent: number | null) => void,
): Promise<{ key: string; kind: PortalMediaKind; url: string }> {
const form = new FormData();
form.append("file", file);
@@ -72,7 +73,15 @@ export const portalContentService = {
key: string;
kind: PortalMediaKind;
url: string;
}>(`${ROOT}/media`, form);
}>(`${ROOT}/media`, form, {
// A video is big enough that a stalled connection would otherwise sit on
// a spinner forever with nothing to tell the author it had failed.
timeout: 2 * 60 * 1000,
onUploadProgress: (event) =>
onProgress?.(
event.total ? Math.round((event.loaded / event.total) * 100) : null,
),
});
return data;
},