{replyError}
diff --git a/src/components/forum/ImageAttachButton.tsx b/src/components/forum/ImageAttachButton.tsx
new file mode 100644
index 0000000..81534c0
--- /dev/null
+++ b/src/components/forum/ImageAttachButton.tsx
@@ -0,0 +1,91 @@
+"use client";
+
+import { useRef, useState } from "react";
+
+/**
+ * Drop-in "📎 Add image" button for any composer textarea. Wires
+ * straight to the shared /api/upload/image pipeline (#62) so every
+ * upload across the site is optimized the same way.
+ *
+ * On successful upload, the markdown `` is appended
+ * to the textarea via the onInsert callback — bodies stay markdown so
+ * server-side rendering can swap to with AVIF on read.
+ *
+ * Props:
+ * onInsert(markdown) — called with the markdown to append
+ * disabled? — optional manual disable
+ * maxBytes? — client-side guard (default 20 MB)
+ */
+interface Props {
+ onInsert: (markdown: string) => void;
+ disabled?: boolean;
+ maxBytes?: number;
+}
+
+export default function ImageAttachButton({ onInsert, disabled, maxBytes = 20 * 1024 * 1024 }: Props) {
+ const fileRef = useRef(null);
+ const [busy, setBusy] = useState(false);
+ const [err, setErr] = useState(null);
+
+ async function handleFile(file: File) {
+ setErr(null);
+ if (file.size > maxBytes) {
+ setErr(`Too large (${(file.size / 1024 / 1024).toFixed(1)} MB). Max 20 MB.`);
+ return;
+ }
+ if (!file.type.startsWith("image/")) {
+ setErr("Not an image file.");
+ return;
+ }
+ setBusy(true);
+ try {
+ const fd = new FormData();
+ fd.append("file", file);
+ const r = await fetch("/api/upload/image", { method: "POST", body: fd });
+ const j = await r.json().catch(() => ({}));
+ if (!r.ok) {
+ setErr(j.error || `Upload failed (${r.status})`);
+ } else if (j.primaryUrl) {
+ const alt = file.name.replace(/\.[^.]+$/, "");
+ onInsert(`\n\n\n\n`);
+ } else {
+ setErr("Upload succeeded but no URL returned.");
+ }
+ } catch (e: unknown) {
+ setErr(e instanceof Error ? e.message : "Upload failed");
+ } finally {
+ setBusy(false);
+ if (fileRef.current) fileRef.current.value = "";
+ }
+ }
+
+ return (
+
+
+
+ {
+ const f = e.target.files?.[0];
+ if (f) void handleFile(f);
+ }}
+ />
+
+ {err &&
{err}
}
+
+ );
+}
diff --git a/src/components/forum/RichBody.tsx b/src/components/forum/RichBody.tsx
new file mode 100644
index 0000000..617e62e
--- /dev/null
+++ b/src/components/forum/RichBody.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import LinkifyPlainText from "@/components/LinkifyPlainText";
+
+/**
+ * Forum body renderer that splits markdown image syntax out of the
+ * plain-text body, renders inline `` images as a
+ * element with AVIF source (when the URL is from our bb-media bucket,
+ * we derive the .avif sibling), and hands the rest of the prose to
+ * LinkifyPlainText for company auto-linking.
+ *
+ * Why a custom mini-parser instead of react-markdown? The body is
+ * 99% plain text with the occasional attached image. Pulling in a
+ * full markdown lib + sanitizer chain for one feature triples the
+ * client bundle for negligible gain.
+ */
+const IMG_RE = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g;
+
+function deriveAvifFromWebp(url: string): string | null {
+ // Our /api/upload/image writes both .webp and .avif to the same path.
+ // If the URL ends in .webp on the bb-media bucket, the .avif sibling
+ // is at the same path with the extension swapped.
+ if (!/\/bb-media\/.+\.webp(\?.*)?$/i.test(url)) return null;
+ return url.replace(/\.webp(\?.*)?$/i, ".avif$1");
+}
+
+export default function RichBody({ body }: { body: string }) {
+ if (!body) return null;
+
+ // Walk the body, splitting on image markdown.
+ const segments: Array<
+ | { type: "text"; text: string }
+ | { type: "image"; alt: string; url: string }
+ > = [];
+ let cursor = 0;
+ IMG_RE.lastIndex = 0;
+ for (let m = IMG_RE.exec(body); m; m = IMG_RE.exec(body)) {
+ if (m.index > cursor) {
+ segments.push({ type: "text", text: body.slice(cursor, m.index) });
+ }
+ segments.push({ type: "image", alt: m[1] || "", url: m[2] });
+ cursor = m.index + m[0].length;
+ }
+ if (cursor < body.length) {
+ segments.push({ type: "text", text: body.slice(cursor) });
+ }
+ if (segments.length === 0) {
+ segments.push({ type: "text", text: body });
+ }
+
+ return (
+