diff --git a/src/app/forum/[slug]/page.tsx b/src/app/forum/[slug]/page.tsx index 6a81b7c..1b6355b 100644 --- a/src/app/forum/[slug]/page.tsx +++ b/src/app/forum/[slug]/page.tsx @@ -4,6 +4,7 @@ import Link from 'next/link'; import { useParams } from 'next/navigation'; import Header from '@/components/Header'; import Footer from '@/components/Footer'; +import ImageAttachButton from '@/components/forum/ImageAttachButton'; import { createClient } from '@/lib/supabase/client'; interface ForumCategory { @@ -336,6 +337,10 @@ export default function ForumCategoryPage() { rows={5} className="w-full bg-[#111] border border-[#333] rounded-lg px-3 py-2 text-white font-body text-sm placeholder-[#555] focus:outline-none focus:border-[#3b82f6] resize-none" /> +
+ setNewBody((b) => (b || "") + md)} /> + Auto-optimized, never > 20 MB +
{newError && (
{newError} diff --git a/src/app/forum/thread/[id]/page.tsx b/src/app/forum/thread/[id]/page.tsx index c668bc1..d13de4d 100644 --- a/src/app/forum/thread/[id]/page.tsx +++ b/src/app/forum/thread/[id]/page.tsx @@ -9,6 +9,8 @@ import { pickAds, rotateAll } from '@/lib/ads'; import { createClient } from '@/lib/supabase/client'; import SidebarAdStack from "@/components/SidebarAdStack"; import LinkifyPlainText from "@/components/LinkifyPlainText"; +import RichBody from "@/components/forum/RichBody"; +import ImageAttachButton from "@/components/forum/ImageAttachButton"; import CompanyMentionsHover from "@/components/CompanyMentionsHover"; // Inline ad slot inserted between forum replies every N posts. @@ -371,7 +373,7 @@ export default function ForumThreadPage() { {thread.view_count} views

- +

{timeAgo(reply.created_at)}

- +

+
+ setReplyBody((b) => (b || "") + md)} /> + Auto-optimized, never > 20 MB +
{replyError && (
{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 `![alt](primaryUrl)` 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![${alt}](${j.primaryUrl})\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 `![alt](url)` 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 ( +
+ {segments.map((seg, i) => { + if (seg.type === "image") { + const avif = deriveAvifFromWebp(seg.url); + return ( +
+ + {avif && } + + {/* eslint-disable-next-line @next/next/no-img-element */} + {seg.alt} + + {seg.alt && ( +
{seg.alt}
+ )} +
+ ); + } + // For text segments, drop into LinkifyPlainText (company links) + return seg.text.trim() + ? + : null; + })} +
+ ); +}