diff --git a/manifest.ts b/manifest.ts
index 0c1e823..024f292 100644
--- a/manifest.ts
+++ b/manifest.ts
@@ -48,6 +48,25 @@ export const manifest: PluginManifest = {
// Nothing to declare. It talks HTTP to fofos and stores one row; there is no host binary anywhere
// on the path, and fofos itself is a separate deployment rather than something this installs.
+ /*
+ The rich text editor for the site's copy.
+
+ Declared here rather than added to the platform's package.json, which is frozen and shared: these
+ are installed into `plugins/node_modules/` alongside every other plugin's, so nothing about this
+ plugin's editor can move a version the platform depends on.
+
+ `@tiptap/extension-text-align` was already in the platform's dependencies with nothing importing
+ it — a leftover from an earlier attempt. It is asked for here too, so this plugin's copy is
+ resolved by the same rule as the rest rather than by whatever happens to be up the tree.
+ */
+ npmDependencies: {
+ '@tiptap/react': '^3.15.3',
+ '@tiptap/starter-kit': '^3.15.3',
+ '@tiptap/pm': '^3.15.3',
+ '@tiptap/extension-text-align': '^3.15.3',
+ '@tiptap/extension-link': '^3.15.3',
+ },
+
permissions: [
{
key: 'fofos-admin',
diff --git a/web/NavPanel.tsx b/web/NavPanel.tsx
index 626c638..cf801f6 100644
--- a/web/NavPanel.tsx
+++ b/web/NavPanel.tsx
@@ -64,6 +64,16 @@ export const NavPanel = () => {
const search = new URLSearchParams(params);
search.set('section', section.key);
+ /*
+ `media` is Media's own folder, and it goes when you leave.
+
+ It survived every navigation, so `?section=home&media=GALLERY/CALAVERA+1` was an ordinary URL —
+ a parameter naming a folder in a section that is not open, carried into every link, and shared
+ or bookmarked that way. It is scoped to one section, so it is that section's to keep and nobody
+ else's to inherit.
+ */
+ if (section.key !== 'media') search.delete('media');
+
const body = (
<>
diff --git a/web/RichText.tsx b/web/RichText.tsx
new file mode 100644
index 0000000..5322f7b
--- /dev/null
+++ b/web/RichText.tsx
@@ -0,0 +1,218 @@
+import { useEffect } from 'react';
+import { EditorContent, useEditor, type Editor } from '@tiptap/react';
+import StarterKit from '@tiptap/starter-kit';
+import Link from '@tiptap/extension-link';
+import TextAlign from '@tiptap/extension-text-align';
+import {
+ AlignCenter,
+ AlignLeft,
+ AlignRight,
+ Bold,
+ Heading2,
+ Italic,
+ Link2,
+ List,
+ ListOrdered,
+ Redo2,
+ Strikethrough,
+ Undo2,
+} from 'lucide-react';
+
+/**
+ * The editor for the site's copy.
+ *
+ * ── Why HTML in and HTML out ──
+ *
+ * `intro_text` and the rest are already HTML in the site's database, written by the old admin and
+ * rendered straight into the page. So the editor's job is to read that and give it back — not to
+ * introduce a second format with a conversion at each end, which is a migration and a class of bug
+ * (round-tripping) bought for nothing.
+ *
+ * ── The toolbar is what the site can actually render ──
+ *
+ * Bold, italic, strike, a heading, two lists, three alignments, a link. Not because tiptap cannot do
+ * more, but because anything offered here that the site's stylesheet has no rule for produces copy that
+ * looks right while editing and wrong when published — the worst kind of editor, since the person only
+ * finds out after saying it is done.
+ *
+ * ── Uncontrolled after the first render, deliberately ──
+ *
+ * tiptap owns its own document. Pushing `value` in on every render would move the caret to the end
+ * mid-sentence, which is the classic controlled-contentEditable bug. So the parent's value seeds it,
+ * and after that the editor is the source of truth until it is unmounted or the value changes from
+ * somewhere that is not typing — see the effect below.
+ */
+
+const Tool = ({
+ onClick,
+ active,
+ disabled,
+ label,
+ children,
+}: {
+ onClick: () => void;
+ active?: boolean;
+ disabled?: boolean;
+ label: string;
+ children: React.ReactNode;
+}) => (
+
+);
+
+const Toolbar = ({ editor }: { editor: Editor }) => (
+
+ editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')}>
+
+
+ editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')}>
+
+
+ editor.chain().focus().toggleStrike().run()}
+ active={editor.isActive('strike')}
+ >
+
+
+
+
+
+ editor.chain().focus().toggleHeading({ level: 2 }).run()}
+ active={editor.isActive('heading', { level: 2 })}
+ >
+
+
+ editor.chain().focus().toggleBulletList().run()}
+ active={editor.isActive('bulletList')}
+ >
+
+
+ editor.chain().focus().toggleOrderedList().run()}
+ active={editor.isActive('orderedList')}
+ >
+
+
+
+
+
+ {(['left', 'center', 'right'] as const).map((align) => (
+ editor.chain().focus().setTextAlign(align).run()}
+ active={editor.isActive({ textAlign: align })}
+ >
+ {align === 'left' ? (
+
+ ) : align === 'center' ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+
+
+ {
+ if (editor.isActive('link')) {
+ editor.chain().focus().unsetLink().run();
+ return;
+ }
+ // `prompt` rather than a dialog: it is one string, it is modal either way, and a bespoke
+ // dialog here would be a component to maintain for a field with no validation worth doing.
+ const href = window.prompt('Link to');
+ if (href) editor.chain().focus().setLink({ href }).run();
+ }}
+ >
+
+
+
+
+ editor.chain().focus().undo().run()} disabled={!editor.can().undo()}>
+
+
+ editor.chain().focus().redo().run()} disabled={!editor.can().redo()}>
+
+
+
+
+);
+
+export const RichText = ({
+ value,
+ onChange,
+ placeholder,
+}: {
+ value: string;
+ onChange: (html: string) => void;
+ placeholder?: string;
+}) => {
+ const editor = useEditor({
+ extensions: [
+ StarterKit,
+ TextAlign.configure({ types: ['heading', 'paragraph'] }),
+ // `openOnClick: false` — inside an editor a click is a cursor, not navigation. Without it,
+ // clicking a link to edit its text opens it and loses whatever was unsaved.
+ Link.configure({ openOnClick: false }),
+ ],
+ content: value,
+ editorProps: {
+ attributes: {
+ class:
+ 'prose prose-sm prose-invert max-w-none min-h-40 px-3 py-2 focus:outline-none [&_a]:text-primary [&_a]:underline',
+ },
+ },
+ onUpdate: ({ editor: current }) => onChange(current.getHTML()),
+ });
+
+ /*
+ Only when the value changes from somewhere that is NOT this editor.
+
+ Comparing against `getHTML()` first is what makes that distinction: while somebody types, the two
+ already agree, so nothing is written back and the caret stays where it is. When a save returns the
+ server's version — or the panel loads a different record — they differ, and the document is
+ replaced. Without the comparison this is the classic controlled-contentEditable bug: the caret
+ jumps to the end on every keystroke.
+ */
+ useEffect(() => {
+ if (editor && value !== editor.getHTML()) editor.commands.setContent(value, { emitUpdate: false });
+ }, [editor, value]);
+
+ if (!editor) return null;
+
+ return (
+
+
+
+ {!value && placeholder && (
+
{placeholder}
+ )}
+
+ );
+};
diff --git a/web/sections/HomePanel.tsx b/web/sections/HomePanel.tsx
index 9506c5f..9a3cbe9 100644
--- a/web/sections/HomePanel.tsx
+++ b/web/sections/HomePanel.tsx
@@ -3,9 +3,9 @@ import { ArrowDown, ArrowUp, Check, ImagePlus, Loader2, Trash2 } from 'lucide-re
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
-import { Textarea } from '@/components/ui/textarea';
import { readableError } from '../errors';
import { useFlash } from '../useFlash';
+import { RichText } from '../RichText';
/**
* The Home page: a hero, an intro, and an ordered gallery.
@@ -174,13 +174,8 @@ export const HomePanel = () => {