mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 15:18:11 +00:00
fix: the content document
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(``);
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user