forum: image attachments on threads + replies, AVIF picture render

New ImageAttachButton drops onto any composer textarea: file picker
upload → /api/upload/image (the universal pipeline) → markdown
![alt](primaryUrl) appended to the body. New thread + reply composers
both gain the button with a 'Auto-optimized, never > 20 MB' caption.

RichBody is the read-path companion: parses body markdown for image
syntax and renders each as a <picture> element with the AVIF source
first (derived from the .webp sibling on our bb-media bucket), then the
WebP, then the original-format fallback img. Prose around images still
runs through LinkifyPlainText so company auto-links still work.

Forum users now have a way to include screenshots, gear photos, NAB
booth shots, etc. in their posts — every image flows through the same
optimization that gives <10% of original byte weight at visually
identical quality.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ryan Salazar
2026-05-29 16:08:59 +00:00
parent e24d78cd15
commit 39e9777e0a
4 changed files with 187 additions and 2 deletions

View File

@@ -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"
/>
<div className="flex items-center gap-3">
<ImageAttachButton onInsert={(md) => setNewBody((b) => (b || "") + md)} />
<span className="text-[10px] text-[#666]">Auto-optimized, never &gt; 20 MB</span>
</div>
{newError && (
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
{newError}

View File

@@ -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() {
<span className="text-[#555] font-body text-xs">{thread.view_count} views</span>
</div>
<p className="text-[#d0d0d0] font-body text-sm leading-relaxed">
<LinkifyPlainText body={thread.body} />
<RichBody body={thread.body} />
</p>
<VoteButtons
targetType="thread"
@@ -413,7 +415,7 @@ export default function ForumThreadPage() {
<span className="text-[#555] font-body text-xs">{timeAgo(reply.created_at)}</span>
</div>
<p className="text-[#d0d0d0] font-body text-sm leading-relaxed">
<LinkifyPlainText body={reply.body} />
<RichBody body={reply.body} />
</p>
<VoteButtons
targetType="reply"
@@ -495,6 +497,10 @@ export default function ForumThreadPage() {
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"
/>
<div className="flex items-center gap-3">
<ImageAttachButton onInsert={(md) => setReplyBody((b) => (b || "") + md)} />
<span className="text-[10px] text-[#666]">Auto-optimized, never &gt; 20 MB</span>
</div>
{replyError && (
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
{replyError}

View File

@@ -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 <picture> 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<HTMLInputElement | null>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(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 (
<div className="inline-flex flex-col">
<div className="inline-flex items-center gap-2">
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={busy || disabled}
className="inline-flex items-center gap-1.5 bg-[#252525] hover:bg-[#333] disabled:opacity-50 text-[#aaa] font-body text-xs px-3 py-1.5 rounded-md transition-colors border border-[#333]"
title="Attach an image (auto-optimized, max 20 MB)"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 1 1-8.49-8.49l9.19-9.19a4 4 0 1 1 5.66 5.66l-9.2 9.19a2 2 0 1 1-2.83-2.83l8.49-8.48" />
</svg>
{busy ? "Uploading…" : "Attach image"}
</button>
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleFile(f);
}}
/>
</div>
{err && <div className="text-[11px] text-red-400 mt-1">{err}</div>}
</div>
);
}

View File

@@ -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 <picture>
* 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 (
<div className="space-y-3">
{segments.map((seg, i) => {
if (seg.type === "image") {
const avif = deriveAvifFromWebp(seg.url);
return (
<figure key={i} className="my-2">
<picture>
{avif && <source srcSet={avif} type="image/avif" />}
<source srcSet={seg.url} type="image/webp" />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={seg.url}
alt={seg.alt}
loading="lazy"
decoding="async"
className="max-w-full h-auto rounded-md border border-[#252525] bg-[#0d0d0d]"
/>
</picture>
{seg.alt && (
<figcaption className="text-[11px] text-[#666] mt-1 italic">{seg.alt}</figcaption>
)}
</figure>
);
}
// For text segments, drop into LinkifyPlainText (company links)
return seg.text.trim()
? <LinkifyPlainText key={i} body={seg.text} />
: null;
})}
</div>
);
}