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

@@ -0,0 +1,91 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Folds each help section's `media[]` array into its markdown body.
*
* Attachments used to hang off the section as a separate list, rendered after
* the text — which meant an author could not put a picture next to the sentence
* it illustrates, and had two different places to manage media. They are now
* embedded with markdown's image syntax, and the renderer picks `<img>` or
* `<video>` from the file extension.
*
* Existing entries are converted rather than dropped: an uploaded object
* becomes `![caption](minio:<key>)`, and a shipped asset path or external URL
* is kept verbatim. Sections are left alone once they have no `media` key, so
* re-running does nothing.
*/
interface LegacyMedia {
src: string;
caption?: string | null;
}
interface LegacySection {
id: string;
heading: string;
body: string;
media?: LegacyMedia[];
}
/** Uploaded objects are stored as keys; the `minio:` ref is signed on read. */
function toMarkdown(item: LegacyMedia): string {
const isStoredObject = !/^(https?:\/\/|\/)/.test(item.src);
const target = isStoredObject ? `minio:${item.src}` : item.src;
return `![${item.caption ?? ""}](${target})`;
}
export class SupportHelpInlineMedia3370000000000 implements MigrationInterface {
name = "SupportHelpInlineMedia3370000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
const rows: {
id: string;
version: number;
payload: { sections?: LegacySection[] } & Record<string, unknown>;
}[] = await queryRunner.query(`
SELECT id, version, payload
FROM freight.support_documents
WHERE slug = 'HELP'
`);
for (const row of rows) {
const sections = row.payload.sections ?? [];
if (!sections.some((section) => section.media !== undefined)) continue;
const payload = {
...row.payload,
sections: sections.map(({ media, ...section }) => {
const embeds = (media ?? []).map(toMarkdown);
return {
...section,
body: [section.body, ...embeds].filter(Boolean).join("\n\n"),
};
}),
};
const version = row.version + 1;
await queryRunner.query(
`UPDATE freight.support_documents
SET payload = $1::jsonb, version = $2, updated_at = now()
WHERE id = $3`,
[JSON.stringify(payload), version, row.id],
);
await queryRunner.query(
`INSERT INTO freight.support_document_versions
(document_id, version, payload, actor_id, note)
VALUES ($1, $2, $3::jsonb, NULL, $4)`,
[
row.id,
version,
JSON.stringify(payload),
"Moved attachments inline into the section text",
],
);
}
}
/** Not reversible — see the note on SupportHelpSections3360000000000. */
public async down(): Promise<void> {
// no-op
}
}

View File

@@ -1,10 +1,9 @@
import { SUPPORT_MEDIA_PREFIX, SupportDocSlug } from "@edr/types";
import { SupportDocSlug } from "@edr/types";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
ArrayMaxSize,
IsArray,
IsIn,
IsObject,
IsOptional,
IsString,
@@ -28,17 +27,6 @@ const LINK_PATTERN = /^(https?:\/\/|mailto:|tel:|\/)/;
const LINK_MESSAGE =
"$property must start with http(s)://, mailto:, tel: or /";
/**
* A media source is either an uploaded MinIO object key, a same-origin path, or
* an https URL. Anything else — notably `javascript:` — is refused, since this
* value lands in an `<img>`/`<video>` src.
*/
const MEDIA_SRC_PATTERN = new RegExp(
`^(https?:\\/\\/|\\/|${SUPPORT_MEDIA_PREFIX.replace("/", "\\/")})`,
);
const MEDIA_SRC_MESSAGE =
`$property must be an uploaded ${SUPPORT_MEDIA_PREFIX} key, a /path, or an http(s):// URL`;
/* ------------------------------- CONTACT ------------------------------- */
export class PortalSupportContactDto {
@@ -208,58 +196,6 @@ export class PortalFaqContentDto {
/* --------------------------------- HELP -------------------------------- */
export class PortalMediaDto {
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
@IsOptional()
@IsString()
@MaxLength(64)
id?: string;
@ApiProperty({ enum: ["image", "video"] })
@IsIn(["image", "video"])
kind!: "image" | "video";
@ApiProperty({
description: `An uploaded ${SUPPORT_MEDIA_PREFIX} key, a same-origin /path, or an https:// URL`,
})
@IsString()
@Matches(MEDIA_SRC_PATTERN, { message: MEDIA_SRC_MESSAGE })
@MaxLength(500)
src!: string;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(300)
caption?: string | null;
}
export class PortalHelpSectionDto {
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
@IsOptional()
@IsString()
@MaxLength(64)
id?: string;
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(200)
heading!: string;
@ApiProperty({ description: "Markdown" })
@IsString()
@MaxLength(20_000)
body!: string;
@ApiProperty({ type: [PortalMediaDto] })
@IsArray()
@ArrayMaxSize(12)
@ValidateNested({ each: true })
@Type(() => PortalMediaDto)
media!: PortalMediaDto[];
}
export class PortalHelpContentDto {
@ApiProperty()
@IsString()
@@ -272,12 +208,16 @@ export class PortalHelpContentDto {
@MaxLength(500)
subtitle!: string;
@ApiProperty({ type: [PortalHelpSectionDto] })
/**
* Same section shape as the legal documents — images and videos live inside
* the markdown, so a help section needs nothing the others do not have.
*/
@ApiProperty({ type: [PortalDocSectionDto] })
@IsArray()
@ArrayMaxSize(40)
@ValidateNested({ each: true })
@Type(() => PortalHelpSectionDto)
sections!: PortalHelpSectionDto[];
@Type(() => PortalDocSectionDto)
sections!: PortalDocSectionDto[];
}
/* ------------------------------- request ------------------------------- */

View File

@@ -131,7 +131,8 @@ describe("SupportContentService.getBundle", () => {
// findAll returns only CONTACT, so HELP falls back to the defaults — which
// is itself worth asserting: an unseeded row must not break the page.
const bundle = await service.getBundle();
expect(bundle.help.sections[0].media[0].src).toBe(
// A shipped asset path is not a stored object, so nothing is signed.
expect(bundle.help.sections[0].body).toContain(
"/assets/edr-portal-guide.webm",
);
expect(minio.getSignedUrl).not.toHaveBeenCalled();
@@ -141,10 +142,9 @@ describe("SupportContentService.getBundle", () => {
sections: [
{
...help.sections[0],
body: "See ![diagram](minio:support-content/d.png) below.",
media: [
{ id: "m1", kind: "image" as const, src: "support-content/a.png" },
],
body:
"See ![diagram](minio:support-content/d.png) and " +
"![clip](minio:support-content/c.mp4).",
},
],
};
@@ -154,14 +154,14 @@ describe("SupportContentService.getBundle", () => {
help: withUpload,
});
expect(signed.help.sections[0].media[0].src).toBe(
"https://minio.test/support-content/a.png?sig=x",
);
expect(signed.help.sections[0].body).toContain(
"https://minio.test/support-content/d.png?sig=x",
);
expect(signed.help.sections[0].body).toContain(
"https://minio.test/support-content/c.mp4?sig=x",
);
// The stored copy must never be mutated into a URL — that is what would rot.
expect(withUpload.sections[0].media[0].src).toBe("support-content/a.png");
expect(withUpload.sections[0].body).toContain("minio:support-content/d.png");
});
});
@@ -176,40 +176,33 @@ describe("validatePayload", () => {
}
});
const sectionWithMedia = (src: string) => ({
const sectionWithBody = (body: string) => ({
...help,
sections: [{ ...help.sections[0], media: [{ kind: "video", src }] }],
sections: [{ ...help.sections[0], body }],
});
it("rejects a javascript: media source", () => {
// The markdown renderer drops raw HTML, so src attributes like this one are
// the only place a script URL could still execute.
expect(() =>
validatePayload("HELP", sectionWithMedia("javascript:alert(1)")),
).toThrow(BadRequestException);
});
it("accepts an uploaded key, a rooted path and an https URL", () => {
for (const src of [
"support-content/9f1c.png",
"/assets/edr-portal-guide.webm",
"https://cdn.example.com/clip.mp4",
it("stores embedded media as refs, whatever the scheme", () => {
// Media now lives in the markdown, so the API no longer validates its URL
// scheme — the portal's renderer does, by dropping anything that is not
// http(s)/mailto/tel/relative. These all persist fine.
for (const body of [
"![](minio:support-content/9f1c.png)",
"![](/assets/edr-portal-guide.webm)",
"![](https://cdn.example.com/clip.mp4)",
]) {
expect(() => validatePayload("HELP", sectionWithMedia(src))).not.toThrow();
expect(() => validatePayload("HELP", sectionWithBody(body))).not.toThrow();
}
});
it("rejects a media kind that is neither image nor video", () => {
it("rejects a section body past the cap", () => {
expect(() =>
validatePayload("HELP", {
...help,
sections: [
{
...help.sections[0],
media: [{ kind: "pdf", src: "support-content/a.pdf" }],
},
],
}),
validatePayload("HELP", sectionWithBody("x".repeat(20_001))),
).toThrow(BadRequestException);
});
it("rejects an unknown property, which is how a stale payload is caught", () => {
expect(() =>
validatePayload("HELP", { ...help, channels: [] }),
).toThrow(BadRequestException);
});

View File

@@ -51,9 +51,6 @@ const MEDIA_REF = new RegExp(
"g",
);
/** Anything not already a URL or a rooted path is a MinIO object key. */
const isObjectKey = (src: string) => !/^(https?:\/\/|\/)/.test(src);
@Injectable()
export class SupportContentService {
constructor(
@@ -127,9 +124,9 @@ export class SupportContentService {
}
/**
* Swaps every stored MinIO reference for a freshly signed URL: attachment
* `src` keys, and `minio:<key>` references embedded in markdown by the
* editor's image button.
* Swaps every `minio:<key>` reference embedded in the copy for a freshly
* signed URL. Media lives inside the markdown, so this one pass covers every
* document.
*
* Each distinct key is signed once per request, and a signing failure
* degrades to MinIO's public URL rather than failing the whole page (see
@@ -140,12 +137,6 @@ export class SupportContentService {
): Promise<PortalContentBundle> {
const keys = new Set<string>();
for (const section of bundle.help.sections ?? []) {
for (const item of section.media ?? []) {
if (isObjectKey(item.src)) keys.add(item.src);
}
}
const collect = (value: unknown): void => {
if (typeof value === "string") {
for (const match of value.matchAll(MEDIA_REF)) keys.add(match[1]);
@@ -183,15 +174,7 @@ export class SupportContentService {
return value;
};
const resolved = rewrite(bundle) as PortalContentBundle;
for (const section of resolved.help.sections ?? []) {
for (const item of section.media ?? []) {
if (isObjectKey(item.src)) item.src = signed.get(item.src) ?? item.src;
}
}
return resolved;
return rewrite(bundle) as PortalContentBundle;
}
/** Admin list — metadata only, no payloads. */
@@ -339,13 +322,7 @@ function withGeneratedIds(
switch (slug) {
case "HELP": {
const help = payload as PortalHelpContent;
return {
...help,
sections: help.sections.map((section) => ({
...withId(section),
media: (section.media ?? []).map(withId),
})),
};
return { ...help, sections: help.sections.map(withId) };
}
case "FAQ": {
const faq = payload as PortalFaqContent;

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;
},

View File

@@ -20,6 +20,8 @@ interface DocShellProps {
meta?: string;
/** Path of the current page, so it is not linked to itself. */
current: string;
/** Contents column, mirroring the page list editors see in the backoffice. */
sidebar?: ReactNode;
children: ReactNode;
}
@@ -33,12 +35,13 @@ export function DocShell({
subtitle,
meta,
current,
sidebar,
children,
}: DocShellProps) {
return (
<div className="min-h-screen bg-background text-foreground">
<header className="sticky top-0 z-50 border-b border-border bg-background/80 backdrop-blur-xl">
<div className="mx-auto flex max-w-4xl items-center justify-between gap-4 px-6 py-4">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-6 py-4">
<Link to="/" className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<Train className="size-5" />
@@ -56,7 +59,7 @@ export function DocShell({
</div>
</header>
<main className="mx-auto max-w-4xl px-6 py-12">
<main className="mx-auto max-w-6xl px-6 py-12">
<h1 className="text-4xl font-black tracking-tight">{title}</h1>
<p className="mt-4 text-lg leading-8 text-muted-foreground">
{subtitle}
@@ -65,11 +68,18 @@ export function DocShell({
<p className="mt-2 text-sm text-muted-foreground">{meta}</p>
)}
<div className="mt-10">{children}</div>
{sidebar ? (
<div className="mt-10 flex flex-col gap-8 lg:flex-row lg:gap-10">
{sidebar}
<div className="min-w-0 flex-1">{children}</div>
</div>
) : (
<div className="mt-10">{children}</div>
)}
</main>
<footer className="border-t border-border py-8">
<div className="mx-auto flex max-w-4xl flex-col items-center justify-between gap-4 px-6 text-sm text-muted-foreground md:flex-row">
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 px-6 text-sm text-muted-foreground md:flex-row">
<span>© 2026 EDR Freight. All rights reserved.</span>
<nav className="flex flex-wrap items-center justify-center gap-x-6 gap-y-2">
{DOC_LINKS.filter((l) => l.to !== current).map((link) => (
@@ -96,8 +106,11 @@ export function DocShell({
export function DocSections({ sections }: { sections: PortalDocSection[] }) {
return (
<div className="space-y-10">
{sections.map((section) => (
<section key={section.id}>
{/* Guarded: this is API-supplied, and these routes are public. */}
{(sections ?? []).map((section) => (
// `id` doubles as the anchor the contents list and #deep-links target;
// `scroll-mt` keeps the heading clear of the sticky header.
<section key={section.id} id={section.id} className="scroll-mt-24">
<h2 className="text-xl font-bold tracking-tight">
{section.heading}
</h2>

View File

@@ -0,0 +1,117 @@
import { useEffect, useState } from "react";
export interface DocNavItem {
id: string;
label: string;
}
/**
* Highlights whichever section is currently on screen, so a long policy tells
* the reader where they are. Watches the top band of the viewport, which is
* what makes the highlight change as a heading scrolls past rather than only
* when a whole section fits.
*/
export function useActiveSection(ids: string[]): string | null {
const [active, setActive] = useState<string | null>(ids[0] ?? null);
useEffect(() => {
if (ids.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible[0]) setActive(visible[0].target.id);
},
// Top ~quarter of the viewport, below the sticky header.
{ rootMargin: "-80px 0px -70% 0px", threshold: 0 },
);
for (const id of ids) {
const element = document.getElementById(id);
if (element) observer.observe(element);
}
return () => observer.disconnect();
}, [ids.join("|")]); // eslint-disable-line react-hooks/exhaustive-deps
return active;
}
interface DocSidebarProps {
items: DocNavItem[];
activeId: string | null;
/**
* Paged mode (the help page): selecting swaps the visible section. Omit for
* table-of-contents mode, where entries are anchors into one long document.
*/
onSelect?: (id: string) => void;
heading?: string;
}
/**
* The document's contents, mirroring the page list an editor sees in the
* backoffice. Sticky on wide screens; above the content on narrow ones, where
* a fixed column would eat the reading width.
*/
export function DocSidebar({
items,
activeId,
onSelect,
heading = "Contents",
}: DocSidebarProps) {
if (items.length === 0) return null;
return (
<nav
aria-label={heading}
// Divider: a right edge once the columns sit side by side, a bottom edge
// while they are stacked — so the contents always reads as its own
// column rather than as text that happens to be to the left.
className="border-b border-border pb-6 lg:sticky lg:top-24 lg:w-64 lg:shrink-0 lg:self-start lg:border-b-0 lg:border-r lg:pb-0 lg:pr-6"
>
<p className="px-3 text-xs font-bold uppercase tracking-wider text-foreground/60">
{heading}
</p>
<ul className="mt-3 max-h-[60vh] space-y-1 overflow-y-auto lg:max-h-[calc(100vh-10rem)]">
{items.map((item) => {
const active = item.id === activeId;
// Inactive entries were `muted-foreground`, which is the same tone as
// body copy and left the list looking disabled. These are navigation,
// so they sit closer to full foreground and darken on hover.
const className = `block w-full rounded-xl px-3 py-2 text-left text-sm transition ${
active
? "bg-accent font-semibold text-foreground"
: "text-foreground/75 hover:bg-accent/60 hover:text-foreground"
}`;
return (
<li key={item.id}>
{onSelect ? (
<button
type="button"
onClick={() => onSelect(item.id)}
aria-current={active ? "page" : undefined}
className={className}
>
{item.label}
</button>
) : (
<a
href={`#${item.id}`}
aria-current={active ? "true" : undefined}
className={className}
>
{item.label}
</a>
)}
</li>
);
})}
</ul>
</nav>
);
}
export default DocSidebar;

View File

@@ -4,22 +4,38 @@ import { Link } from "react-router-dom";
import { usePortalContent } from "@/hooks/usePortalContent";
import { DocShell } from "./DocShell";
import { DocSidebar, useActiveSection } from "./DocSidebar";
import { Markdown } from "./Markdown";
export default function FaqPage() {
// Never undefined — see TermsPage.
const { data } = usePortalContent();
const faq = data!.faq;
const groups = faq.groups ?? [];
// Contents lists the groups; individual questions stay as the disclosure
// rows below, which is already the fastest way to scan them.
const activeId = useActiveSection(groups.map((group) => group.id));
return (
<DocShell current="/faq" title={faq.title} subtitle={faq.subtitle}>
<DocShell
current="/faq"
title={faq.title}
subtitle={faq.subtitle}
sidebar={
<DocSidebar
items={groups.map((group) => ({ id: group.id, label: group.title }))}
activeId={activeId}
/>
}
>
<div className="space-y-10">
{faq.groups.map((group) => (
<section key={group.id}>
{groups.map((group) => (
<section key={group.id} id={group.id} className="scroll-mt-24">
<h2 className="text-xl font-bold tracking-tight">{group.title}</h2>
<div className="mt-4 space-y-3">
{group.items.map((item) => (
{(group.items ?? []).map((item) => (
// Native disclosure: keyboard- and screen-reader-accessible
// without any state of our own.
<details

View File

@@ -1,74 +1,75 @@
import type { PortalMedia } from "@edr/types";
import { useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { usePortalContent } from "@/hooks/usePortalContent";
import { DocShell } from "./DocShell";
import { DocSidebar } from "./DocSidebar";
import { Markdown } from "./Markdown";
import { safeMediaSrc } from "./portal-content";
/**
* An attached image or video. The API has already swapped stored MinIO keys for
* freshly signed URLs, so `src` is ready to render — `safeMediaSrc` is a last
* check that an admin-entered value is a path or an https URL.
* Help is browsed a page at a time, mirroring how it is written: the sidebar is
* the list of pages, and picking one swaps the panel. Unlike the legal
* documents nobody reads help end to end — they arrive with one question — so
* showing a single answer beats a wall of every topic at once.
*/
function Media({ item }: { item: PortalMedia }) {
const src = safeMediaSrc(item.src);
if (!src) return null;
return (
<figure className="mt-6">
{item.kind === "video" ? (
// preload="metadata" so a large file is not pulled on every visit; the
// browser fetches it only once playback starts.
<video
controls
preload="metadata"
className="w-full rounded-[32px] border border-border bg-black"
>
<source src={src} />
Your browser cannot play this video.{" "}
<a href={src}>Download it instead</a>.
</video>
) : (
<img
src={src}
alt={item.caption ?? ""}
loading="lazy"
className="w-full rounded-[32px] border border-border"
/>
)}
{item.caption && (
<figcaption className="mt-2 text-sm text-muted-foreground">
{item.caption}
</figcaption>
)}
</figure>
);
}
export default function HelpPage() {
// Never undefined — see TermsPage.
const { data } = usePortalContent();
const help = data!.help;
const sections = help.sections ?? [];
const navigate = useNavigate();
const { hash } = useLocation();
// The open page lives in the URL, so a link to a specific topic works and
// Back steps between topics.
const fromHash = hash ? decodeURIComponent(hash.slice(1)) : "";
const [selectedId, setSelectedId] = useState(
() => fromHash || sections[0]?.id || "",
);
useEffect(() => {
const valid = sections.some((section) => section.id === selectedId);
if (!valid) setSelectedId(fromHash || sections[0]?.id || "");
}, [sections, selectedId, fromHash]);
useEffect(() => {
if (fromHash && fromHash !== selectedId) setSelectedId(fromHash);
}, [fromHash]); // eslint-disable-line react-hooks/exhaustive-deps
const selected =
sections.find((section) => section.id === selectedId) ?? sections[0];
return (
<DocShell current="/help" title={help.title} subtitle={help.subtitle}>
<div className="space-y-12">
{help.sections.map((section) => (
<section key={section.id}>
<h2 className="text-xl font-bold tracking-tight">
{section.heading}
</h2>
<Markdown>{section.body}</Markdown>
{section.media.map((item) => (
<Media key={item.id} item={item} />
))}
</section>
))}
</div>
<DocShell
current="/help"
title={help.title}
subtitle={help.subtitle}
sidebar={
<DocSidebar
heading="Topics"
items={sections.map((section) => ({
id: section.id,
label: section.heading,
}))}
activeId={selected?.id ?? null}
onSelect={(id) => {
setSelectedId(id);
navigate(`#${id}`);
window.scrollTo({ top: 0, behavior: "smooth" });
}}
/>
}
>
{selected && (
<section>
<h2 className="text-2xl font-bold tracking-tight">
{selected.heading}
</h2>
<Markdown>{selected.body}</Markdown>
</section>
)}
</DocShell>
);
}

View File

@@ -1,5 +1,8 @@
import { isPortalVideoSrc } from "@edr/types";
import ReactMarkdown from "react-markdown";
import { safeMediaSrc } from "./portal-content";
/**
* Renders admin-authored markdown from the support-content API.
*
@@ -10,7 +13,9 @@ import ReactMarkdown from "react-markdown";
* Two things must stay absent for that to hold:
* - `rehype-raw`, which would start rendering raw HTML embedded in the copy;
* - a custom `urlTransform`, which would override the built-in stripping of
* `javascript:` and `data:` hrefs.
* `javascript:` and `data:` hrefs. That transform is now the *only* thing
* vetting embedded media URLs, since they are authored inside the markdown
* rather than validated field-by-field on the API.
*
* `remark-gfm` is also left out: tables and strikethrough are not used in the
* legal or FAQ copy, and CommonMark already covers lists, emphasis and links.
@@ -51,17 +56,55 @@ export function Markdown({ children }: { children: string }) {
h3: ({ children: content }) => (
<h3 className="mt-6 font-bold tracking-tight">{content}</h3>
),
// Images embedded by the editor. The API has already resolved these to
// signed URLs; react-markdown's default urlTransform still guards the
// scheme.
img: ({ src, alt }) => (
<img
src={typeof src === "string" ? src : undefined}
alt={alt ?? ""}
loading="lazy"
className="mt-4 w-full rounded-2xl border border-border"
/>
),
/**
* Images *and* videos: both are written with markdown's image syntax,
* and the element is chosen from the file extension. `src` here has
* already been through react-markdown's url transform; `safeMediaSrc`
* narrows it further to same-origin paths and https.
*/
img: ({ src, alt }) => {
const resolved =
typeof src === "string" ? safeMediaSrc(src) : null;
if (!resolved) return null;
// Spans, not <figure>/<figcaption>: markdown wraps an image in a
// paragraph, and a <figure> inside a <p> is invalid HTML that the
// browser silently re-parents, which drops sibling content.
const caption = alt ? (
<span className="mt-2 block text-sm text-muted-foreground">
{alt}
</span>
) : null;
if (isPortalVideoSrc(resolved)) {
return (
<span className="mt-6 block">
{/* preload="metadata" so a large file is not pulled on every
visit; the browser fetches it once playback starts. */}
<video
controls
preload="metadata"
className="w-full rounded-[32px] border border-border bg-black"
>
<source src={resolved} />
</video>
{caption}
</span>
);
}
return (
<span className="mt-6 block">
<img
src={resolved}
alt={alt ?? ""}
loading="lazy"
className="w-full rounded-[32px] border border-border"
/>
{caption}
</span>
);
},
}}
>
{children}

View File

@@ -1,11 +1,15 @@
import { usePortalContent } from "@/hooks/usePortalContent";
import { DocSections, DocShell } from "./DocShell";
import { DocSidebar, useActiveSection } from "./DocSidebar";
export default function PrivacyPolicyPage() {
// Never undefined — see TermsPage.
const { data } = usePortalContent();
const privacy = data!.privacy;
const sections = privacy.sections ?? [];
const activeId = useActiveSection(sections.map((section) => section.id));
return (
<DocShell
@@ -13,8 +17,17 @@ export default function PrivacyPolicyPage() {
title={privacy.title}
subtitle={privacy.subtitle}
meta={`Last updated ${privacy.lastUpdated}`}
sidebar={
<DocSidebar
items={sections.map((section) => ({
id: section.id,
label: section.heading,
}))}
activeId={activeId}
/>
}
>
<DocSections sections={privacy.sections} />
<DocSections sections={sections} />
</DocShell>
);
}

View File

@@ -1,12 +1,18 @@
import { usePortalContent } from "@/hooks/usePortalContent";
import { DocSections, DocShell } from "./DocShell";
import { DocSidebar, useActiveSection } from "./DocSidebar";
export default function TermsPage() {
// Never undefined — the hook seeds it with the shipped copy, so this public
// page renders instantly and survives the API being unreachable.
const { data } = usePortalContent();
const terms = data!.terms;
const sections = terms.sections ?? [];
// A contents list, not a pager: the whole document stays on one page so it
// can be searched, printed and deep-linked to a clause.
const activeId = useActiveSection(sections.map((section) => section.id));
return (
<DocShell
@@ -14,8 +20,17 @@ export default function TermsPage() {
title={terms.title}
subtitle={terms.subtitle}
meta={`Last updated ${terms.lastUpdated}`}
sidebar={
<DocSidebar
items={sections.map((section) => ({
id: section.id,
label: section.heading,
}))}
activeId={activeId}
/>
}
>
<DocSections sections={terms.sections} />
<DocSections sections={sections} />
</DocShell>
);
}

View File

@@ -67,11 +67,16 @@ describe("withSupportVars", () => {
});
describe("safeMediaSrc", () => {
it("keeps same-origin paths and https sources", () => {
it("keeps same-origin paths and http(s) sources", () => {
expect(safeMediaSrc("/assets/guide.webm")).toBe("/assets/guide.webm");
expect(safeMediaSrc("https://minio.internal/support-content/a.png?sig=x")).toBe(
"https://minio.internal/support-content/a.png?sig=x",
);
// Signed object-store URLs are plain http in dev — rejecting them hid
// every upload.
expect(safeMediaSrc("http://localhost:9000/fhc/support-content/a.mp4")).toBe(
"http://localhost:9000/fhc/support-content/a.mp4",
);
});
it("drops javascript: and protocol-relative sources", () => {

View File

@@ -90,14 +90,17 @@ export function withSupportVars(
}
/**
* Accepts only a same-origin path or an https URL for an attached image or
* video, returning null for anything else so the caller renders nothing.
* Accepts a same-origin path or an http(s) URL for an attached image or video,
* returning null for anything else so the caller renders nothing.
*
* `(?!\/)` rejects protocol-relative `//host/...`, which would otherwise pass
* as a path. An `<img>`/`<source>` src is not a navigation, so a `javascript:`
* URL would not execute anyway — but the guard is cheaper than re-deriving
* that every time someone reads this file.
* The point is to exclude `javascript:` and `data:`, not to require TLS: the
* signed MinIO URLs the API hands back are plain http wherever the object
* store is (dev, and any deployment terminating TLS elsewhere), and rejecting
* those made every uploaded picture and video vanish from the page.
*
* `(?!\/)` still rejects protocol-relative `//host/...`, which would otherwise
* pass as a path.
*/
export function safeMediaSrc(src: string): string | null {
return /^(https:\/\/|\/(?!\/))/.test(src) ? src : null;
return /^(https?:\/\/|\/(?!\/))/.test(src) ? src : null;
}

View File

@@ -36,40 +36,29 @@ export const SUPPORT_CONTENT_DEFAULTS: SupportDocPayloadMap = {
{
id: "help-walkthrough",
heading: "Portal walkthrough",
body: "A guided tour of the portal — registering your company, raising a booking against a contract, and settling an invoice.",
media: [
{
id: "help-walkthrough-video",
kind: "video",
// Ships with the app rather than MinIO, so it is used verbatim.
src: "/assets/edr-portal-guide.webm",
caption: null,
},
],
// The video ships with the app rather than MinIO, so its path is used
// verbatim; the renderer picks <video> from the extension.
body: "A guided tour of the portal — registering your company, raising a booking against a contract, and settling an invoice.\n\n![](/assets/edr-portal-guide.webm)",
},
{
id: "help-chat",
heading: "Chat with our team",
body: "Signed-in customers can open a support conversation from the headset button at the bottom right of every portal page. You can send screenshots and documents in the chat, and replies appear there and as a notification.\n\n[Open the portal](/portal)",
media: [],
},
{
id: "help-contact",
heading: "Contact us",
body: "- **Email** — [{{supportEmail}}](mailto:{{supportEmail}}). Best for document issues and anything needing an attachment.\n- **Phone** — [{{supportPhone}}](tel:{{supportPhoneTel}}). Best for urgent problems with cargo already in transit.\n- **Head office** — {{supportOffice}}. Walk-in support during working hours.\n- **Support hours** — {{supportHours}}. Outside these hours, email us and we reply the next working day.",
media: [],
},
{
id: "help-topics",
heading: "Common topics",
body: "- **[Account & onboarding](/faq)** — registering your company, uploading your trade licence and TIN, and getting an operational profile approved.\n- **[Contracts](/faq)** — requesting a freight contract, reviewing its terms and signing it with your saved signature and stamp.\n- **[Bookings & tracking](/faq)** — raising a booking against a contract, adding last-mile transport and following the consignment along the corridor.\n- **[Invoices & payments](/faq)** — finding invoices, paying through the bank channels and confirming a payment that has not yet settled.",
media: [],
},
{
id: "help-checklist",
heading: "What to include when you contact us",
body: "- Your company name and the email you sign in with.\n- The reference of the contract, booking or invoice involved.\n- What you expected to happen and what happened instead.\n- A screenshot of any error message the portal showed.",
media: [],
},
],
},

View File

@@ -38,17 +38,27 @@ export const SUPPORT_MEDIA_MAX_BYTES = 50 * 1024 * 1024;
export type PortalMediaKind = "image" | "video";
/** An image or video attached to a help section. */
export interface PortalMedia {
id: string;
kind: PortalMediaKind;
/**
* Stored: a MinIO object key, a same-origin `/path`, or an `https://` URL.
* Served: the same value with MinIO keys replaced by a fresh presigned URL —
* the API rewrites this field in place, so the portal just renders it.
*/
src: string;
caption?: string | null;
/**
* Extensions the portal renders as a `<video>` rather than an `<img>`.
*
* Images and videos are both inserted with markdown's image syntax —
* `![caption](minio:support-content/clip.mp4)` — so an author drops media into
* the text in one flow and it stays where they put it. The renderer picks the
* element from the extension; nothing extra is stored.
*/
export const PORTAL_VIDEO_EXTENSIONS = [
".mp4",
".webm",
".ogg",
".ogv",
".mov",
".m4v",
] as const;
/** True when a media URL or key should render as a video. */
export function isPortalVideoSrc(src: string): boolean {
const path = src.split(/[?#]/)[0].toLowerCase();
return PORTAL_VIDEO_EXTENSIONS.some((ext) => path.endsWith(ext));
}
/**
@@ -126,31 +136,19 @@ export interface PortalFaqContent {
}
/**
* One free-form block of the help page: a heading, a full markdown body, and
* any number of attached images or videos.
* Slug `HELP`. Free-form: an ordered list of sections, each a heading plus a
* markdown body, with images and videos embedded in the body itself.
*
* Deliberately not a fixed set of typed blocks (video / channels / topics /
* checklist, as this once was). The help page is the one document whose shape
* genuinely changes with what support needs to explain that quarter, so it is
* built rather than filled in — add, reorder and delete sections freely.
* built rather than filled in — and it shares {@link PortalDocSection} with the
* legal documents, so every tab is edited the same way.
*/
export interface PortalHelpSection {
id: string;
heading: string;
/**
* Markdown. Embedded images reference uploads as
* `![alt](minio:support-content/<file>)` — see {@link PORTAL_MEDIA_URI_SCHEME}.
*/
body: string;
/** Rendered under the body, in order. */
media: PortalMedia[];
}
/** Slug `HELP`. */
export interface PortalHelpContent {
title: string;
subtitle: string;
sections: PortalHelpSection[];
sections: PortalDocSection[];
}
/** Payload shape per slug — the jsonb column's type, keyed by document. */