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  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:
@@ -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 > 20 MB</span>
|
||||
</div>
|
||||
{newError && (
|
||||
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
|
||||
{newError}
|
||||
|
||||
@@ -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 > 20 MB</span>
|
||||
</div>
|
||||
{replyError && (
|
||||
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
|
||||
{replyError}
|
||||
|
||||
91
src/components/forum/ImageAttachButton.tsx
Normal file
91
src/components/forum/ImageAttachButton.tsx
Normal 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 `` 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\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>
|
||||
);
|
||||
}
|
||||
83
src/components/forum/RichBody.tsx
Normal file
83
src/components/forum/RichBody.tsx
Normal 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 `` 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user