wire real ad rotation: Blackmagic 300x600 fixed + rotating 300x250s
Replace the placeholder Unsplash banner array with the live AdSanity inventory served from /legacy/ads/ (same 27 active creatives currently on broadcastbeat.com). New: - src/lib/ads.ts: ad inventory (1 fixed Blackmagic 300x600, 22 rotating 300x250s, 4 leaderboard 728x90s) + Fisher-Yates shuffle + pickAds() helper that draws N unique ads excluding any caller-supplied src set. - src/components/AdImage.tsx: renders one ad as <a target=_blank rel= sponsored noopener noreferrer><AppImage>. - src/components/SidebarAdStack.tsx: Blackmagic 300x600 fixed on top, then count rotating 300x250s. Memoizes the rotation per render so a refresh re-shuffles. Accepts excludeSrcs to coordinate with other slots on the same page. - src/components/ArticleBody.tsx: splits article HTML at </p> boundaries, splices a 300x250 ad after every Nth paragraph (default 4). Same exclude-set pattern. Wiring: - ArticleFeed.tsx: remove the inline sidebarAds array (was 5 Unsplash placeholders + 1 LiveU 300x250). Sidebar now <SidebarAdStack count=5/>. - NewsArticleDetailClient.tsx: sidebar 300x600 + 300x250 AdSlot placeholders replaced with <SidebarAdStack count=4/>. Article body innerHTML replaced with <ArticleBody html=... everyN=4/> so 300x250 ads slot in between paragraphs. Same banners as the live broadcastbeat.com (sourced from the existing WP AdSanity install we cached locally during Phase 2). Each <a> opens in a new tab and uses rel='sponsored noopener noreferrer'.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import React, { useState, useRef, useId, useEffect, useCallback } from "react";
|
||||
import AppImage from "@/components/ui/AppImage";
|
||||
import SidebarAdStack from "@/components/SidebarAdStack";
|
||||
import { PersonIcon, ChevronLeftIcon, ChevronRightIcon, SearchIcon, CloseIcon } from "@/components/ui/Icons";
|
||||
import Link from "next/link";
|
||||
|
||||
@@ -202,14 +203,6 @@ const staticArticles: ArticleItem[] = [
|
||||
}
|
||||
];
|
||||
|
||||
const sidebarAds = [
|
||||
{ src: "https://images.unsplash.com/photo-1611532736597-de2d4265fba3?w=300&h=600&fit=crop", alt: "Blackmagic Design advertisement 300x600", label: "Blackmagic", size: "300x600" },
|
||||
{ src: "https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=300&h=250&fit=crop", alt: "Zixi broadcast technology advertisement 300x250", label: "Zixi", size: "300x250" },
|
||||
{ src: "https://www.broadcastbeat.com/wp-content/uploads/728-x-90-pxEN.gif", alt: "LiveU live production solutions advertisement 300x250", label: "LiveU", size: "300x250" },
|
||||
{ src: "https://images.unsplash.com/photo-1563986768609-322da13575f3?w=300&h=250&fit=crop", alt: "TelyCam broadcast camera advertisement 300x250", label: "TelyCam", size: "300x250" },
|
||||
{ src: "https://images.unsplash.com/photo-1574717024653-61fd2cf4d44d?w=300&h=250&fit=crop", alt: "AJA Video Systems advertisement 300x250", label: "AJA", size: "300x250" },
|
||||
{ src: "https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=300&h=250&fit=crop", alt: "Studio Suite production management advertisement 300x250", label: "Studio Suite", size: "300x250" }
|
||||
];
|
||||
|
||||
const ALL_CATEGORIES = ["All", "BROADCAST", "FEATURED", "POST PRODUCTION", "ANIMATION"];
|
||||
const SOURCE_FILTERS = ["All", "Live", "Imported"] as const;
|
||||
@@ -907,49 +900,10 @@ export default function ArticleFeed() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
{/* Sidebar — Blackmagic 300x600 fixed at top + 5 rotating 300x250s */}
|
||||
<aside className="lg:col-span-4" aria-label="Advertisements">
|
||||
<div className="lg:sticky lg:top-24">
|
||||
<div className="grid grid-cols-2 gap-3 lg:hidden">
|
||||
{sidebarAds?.filter(ad => ad?.size !== "300x600")?.map((ad, i) => (
|
||||
<a key={i} href="#" aria-label={`${ad?.label} advertisement`} className="block focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6]">
|
||||
<div className="ad-placeholder overflow-hidden h-[120px] w-full relative">
|
||||
<AppImage
|
||||
src={ad?.src}
|
||||
alt={ad?.alt}
|
||||
fill
|
||||
className="w-full h-full object-cover"
|
||||
sizes="(max-width: 1024px) 50vw"
|
||||
/>
|
||||
<div className="absolute bottom-1 right-1 bg-black/40 text-white/60 font-body text-[9px] px-1 py-0.5">
|
||||
AD · {ad?.label}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:flex lg:flex-col lg:space-y-4">
|
||||
{sidebarAds?.map((ad, i) => (
|
||||
<a key={i} href="#" aria-label={`${ad?.label} advertisement`} className="block focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6]">
|
||||
<div
|
||||
className={`ad-placeholder overflow-hidden ${ad?.size === "300x600" ? "h-[600px]" : "h-[250px]"}`}
|
||||
style={{ width: "100%", maxWidth: 300 }}>
|
||||
<AppImage
|
||||
src={ad?.src}
|
||||
alt={ad?.alt}
|
||||
width={300}
|
||||
height={ad?.size === "300x600" ? 600 : 250}
|
||||
className="w-full h-full object-cover"
|
||||
sizes="300px"
|
||||
/>
|
||||
<div className="absolute bottom-1 right-1 bg-black/40 text-white/60 font-body text-[9px] px-1 py-0.5">
|
||||
AD · {ad?.label}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<SidebarAdStack count={5} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,8 @@ import AppImage from "@/components/ui/AppImage";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import AdSlot from "@/components/AdSlot";
|
||||
import SidebarAdStack from "@/components/SidebarAdStack";
|
||||
import ArticleBody from "@/components/ArticleBody";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import type { Article } from "@/lib/articles/types";
|
||||
|
||||
@@ -377,8 +379,10 @@ export default function NewsArticleDetailClient({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Article Body */}
|
||||
<div
|
||||
{/* Article Body — paragraphs interleaved with rotating 300×250 ads every 4 paragraphs */}
|
||||
<ArticleBody
|
||||
html={article.content}
|
||||
everyN={4}
|
||||
className="prose prose-invert max-w-none mb-8 font-body text-[#aaa] leading-relaxed
|
||||
[&_h2]:font-heading [&_h2]:text-[#e0e0e0] [&_h2]:text-xl [&_h2]:font-bold [&_h2]:mt-8 [&_h2]:mb-4 [&_h2]:border-b [&_h2]:border-[#222] [&_h2]:pb-2
|
||||
[&_p]:mb-4 [&_p]:leading-relaxed
|
||||
@@ -386,7 +390,6 @@ export default function NewsArticleDetailClient({
|
||||
[&_li]:text-[#aaa]
|
||||
[&_strong]:text-[#e0e0e0] [&_strong]:font-bold
|
||||
[&_a]:text-[#3b82f6] [&_a]:hover:underline"
|
||||
dangerouslySetInnerHTML={{ __html: article.content }}
|
||||
/>
|
||||
|
||||
{/* Tags */}
|
||||
@@ -450,8 +453,8 @@ export default function NewsArticleDetailClient({
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className="lg:col-span-4 space-y-6">
|
||||
{/* Sidebar fixed half-page */}
|
||||
<AdSlot zone="article-sidebar-top" size="300x600" />
|
||||
{/* Blackmagic 300x600 fixed at top + rotating 300x250 banners */}
|
||||
<SidebarAdStack count={4} />
|
||||
|
||||
{/* Related Articles Sidebar */}
|
||||
<div className="bg-[#111] border border-[#222] p-5">
|
||||
@@ -500,7 +503,6 @@ export default function NewsArticleDetailClient({
|
||||
</div>
|
||||
|
||||
{/* Ad placeholder */}
|
||||
<AdSlot zone="article-sidebar-mid" size="300x250" />
|
||||
</aside>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
39
src/components/AdImage.tsx
Normal file
39
src/components/AdImage.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import AppImage from "@/components/ui/AppImage";
|
||||
import type { Ad } from "@/lib/ads";
|
||||
|
||||
const SIZE_DIM: Record<Ad["size"], { w: number; h: number }> = {
|
||||
"300x250": { w: 300, h: 250 },
|
||||
"300x600": { w: 300, h: 600 },
|
||||
"728x90": { w: 728, h: 90 },
|
||||
};
|
||||
|
||||
export default function AdImage({
|
||||
ad,
|
||||
className = "",
|
||||
priority = false,
|
||||
}: {
|
||||
ad: Ad;
|
||||
className?: string;
|
||||
priority?: boolean;
|
||||
}) {
|
||||
const { w, h } = SIZE_DIM[ad.size];
|
||||
return (
|
||||
<a
|
||||
href={ad.click_url}
|
||||
target="_blank"
|
||||
rel="sponsored noopener noreferrer"
|
||||
aria-label={`Advertisement — ${ad.label}`}
|
||||
className={`block ad-creative ${className}`}
|
||||
data-ad-size={ad.size}
|
||||
data-ad-label={ad.label}>
|
||||
<AppImage
|
||||
src={ad.src}
|
||||
alt={ad.alt}
|
||||
width={w}
|
||||
height={h}
|
||||
className="max-w-full h-auto"
|
||||
priority={priority}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
68
src/components/ArticleBody.tsx
Normal file
68
src/components/ArticleBody.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
import { useMemo } from "react";
|
||||
import AdImage from "./AdImage";
|
||||
import { pickAds } from "@/lib/ads";
|
||||
|
||||
/**
|
||||
* Article body renderer: splits the supplied HTML at paragraph boundaries
|
||||
* and splices a 300×250 ad after every Nth paragraph (default 4).
|
||||
*
|
||||
* `excludeSrcs` lets the caller forbid creatives already used in the
|
||||
* sidebar so the same page never shows a banner twice.
|
||||
*/
|
||||
export default function ArticleBody({
|
||||
html,
|
||||
everyN = 4,
|
||||
excludeSrcs = [],
|
||||
className = "",
|
||||
}: {
|
||||
html: string;
|
||||
everyN?: number;
|
||||
excludeSrcs?: string[];
|
||||
className?: string;
|
||||
}) {
|
||||
const { chunks, ads } = useMemo(() => {
|
||||
// Split keeping the closing </p> tags so each chunk is one paragraph block.
|
||||
const parts = (html || "").split(/(<\/p>)/i);
|
||||
const grouped: string[] = [];
|
||||
let buf = "";
|
||||
for (const p of parts) {
|
||||
buf += p;
|
||||
if (p.toLowerCase() === "</p>") {
|
||||
grouped.push(buf);
|
||||
buf = "";
|
||||
}
|
||||
}
|
||||
if (buf.trim()) grouped.push(buf);
|
||||
|
||||
const slotsNeeded = Math.max(0, Math.floor((grouped.length - 1) / everyN));
|
||||
const ads = pickAds("300x250", slotsNeeded, new Set(excludeSrcs));
|
||||
return { chunks: grouped, ads };
|
||||
}, [html, everyN, excludeSrcs.join("|")]);
|
||||
|
||||
const out: React.ReactNode[] = [];
|
||||
let adCursor = 0;
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
out.push(
|
||||
<div
|
||||
key={`p-${i}`}
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: chunks[i] }}
|
||||
/>,
|
||||
);
|
||||
// Insert ad after this chunk if it's at an everyN boundary and not the last
|
||||
if ((i + 1) % everyN === 0 && i < chunks.length - 1 && adCursor < ads.length) {
|
||||
const ad = ads[adCursor++];
|
||||
out.push(
|
||||
<div
|
||||
key={`ad-${i}`}
|
||||
className="my-6 flex justify-center"
|
||||
data-ad-zone="article-inline">
|
||||
<AdImage ad={ad} />
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <div className={`article-body ${className}`}>{out}</div>;
|
||||
}
|
||||
36
src/components/SidebarAdStack.tsx
Normal file
36
src/components/SidebarAdStack.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
import { useMemo } from "react";
|
||||
import AdImage from "./AdImage";
|
||||
import { FIXED_BLACKMAGIC_300X600, pickAds, type Ad } from "@/lib/ads";
|
||||
|
||||
/**
|
||||
* Sidebar ad stack — fixed Blackmagic 300×600 on top, then `count` rotating
|
||||
* 300×250 banners.
|
||||
*
|
||||
* Pass `excludeSrcs` (Set or array) when other slots on the same page already
|
||||
* use specific 300×250 creatives, to avoid duplicates within a page render.
|
||||
*/
|
||||
export default function SidebarAdStack({
|
||||
count = 5,
|
||||
excludeSrcs = [],
|
||||
className = "",
|
||||
}: {
|
||||
count?: number;
|
||||
excludeSrcs?: string[];
|
||||
className?: string;
|
||||
}) {
|
||||
const rotating = useMemo<Ad[]>(
|
||||
() => pickAds("300x250", count, new Set(excludeSrcs)),
|
||||
// exclude list is page-render scoped; recompute when it changes
|
||||
[count, excludeSrcs.join("|")],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
<AdImage ad={FIXED_BLACKMAGIC_300X600} priority />
|
||||
{rotating.map((ad) => (
|
||||
<AdImage key={ad.src} ad={ad} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
src/lib/ads.ts
Normal file
82
src/lib/ads.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// Live ad creatives — same set served by the existing broadcastbeat.com
|
||||
// AdSanity install. Sourced from /legacy/ads/ on the persistent storage
|
||||
// volume; click-through URLs preserved from the original WP postmeta.
|
||||
|
||||
export interface Ad {
|
||||
src: string;
|
||||
alt: string;
|
||||
label: string;
|
||||
size: "300x250" | "300x600" | "728x90";
|
||||
click_url: string;
|
||||
}
|
||||
|
||||
/** Fixed sidebar half-page that always renders at the top across the site. */
|
||||
export const FIXED_BLACKMAGIC_300X600: Ad = {
|
||||
src: "/legacy/ads/ATEM-4ME-Constellation-4K-Plus-v1g-300x600-EN_1804ac7b.jpg",
|
||||
alt: "Blackmagic Design ATEM 4 M/E Constellation 4K Plus",
|
||||
label: "Blackmagic",
|
||||
size: "300x600",
|
||||
click_url: "http://bmd.link/mRNsKu",
|
||||
};
|
||||
|
||||
/** Rotating 300×250 inventory — sidebar mid + inline article slots. */
|
||||
export const ADS_300X250: Ad[] = [
|
||||
{ src: "/legacy/ads/Magewell_Director_One_Pro-Convert-IP-to-HDMI_Sports_InfoComm_9170d5cd.gif", alt: "Magewell Director One Pro", label: "Magewell", size: "300x250", click_url: "https://www.magewell.com" },
|
||||
{ src: "/legacy/ads/PAYG-300x250-1_31957672.gif", alt: "LiveU pay-as-you-go", label: "LiveU", size: "300x250", click_url: "https://bit.ly/4ghXVeq" },
|
||||
{ src: "/legacy/ads/300x250-banner-Our-Story-film_resize_c30978de.jpg", alt: "Lectrosonics — Our Story", label: "Lectrosonics", size: "300x250", click_url: "https://bit.ly/3PWTsSV" },
|
||||
{ src: "/legacy/ads/Zixi_Ads_300x250_93cf5742.png", alt: "Zixi", label: "Zixi", size: "300x250", click_url: "https://zixi.com/" },
|
||||
{ src: "/legacy/ads/Studio-Hero-for-Broadcast-Beat_e1aaa706.png", alt: "Studio Hero", label: "Studio Hero", size: "300x250", click_url: "http://www.TheStudioHero.com" },
|
||||
{ src: "/legacy/ads/aja_2025_colorbox_og_colorbox_300x250_en-3_041eaa50.gif", alt: "AJA ColorBox", label: "AJA", size: "300x250", click_url: "https://www.aja.com/solutions/color" },
|
||||
{ src: "/legacy/ads/1467-NAB-300x250-R2V1-1_364334f9.png", alt: "Dell at NAB", label: "Dell", size: "300x250", click_url: "https://www.dell.com/en-us/dt/events/media/index.htm" },
|
||||
{ src: "/legacy/ads/Broadcasters-report-v2-300x250-px_9b98a4c3.jpg", alt: "JW Player Broadcasters Report", label: "JW Player", size: "300x250", click_url: "https://info.jwplayer.com/l/927863/2022-10-24/j4g5v" },
|
||||
{ src: "/legacy/ads/HPA-ad-Oct-22_44905037.png", alt: "Puget Systems HPA", label: "Puget", size: "300x250", click_url: "http://www.pugetsystems.com" },
|
||||
{ src: "/legacy/ads/Broadcast-Beat-April-Banner-V1_160b8d08.jpg", alt: "ROE Visual", label: "ROE", size: "300x250", click_url: "https://www.roevisual.com/" },
|
||||
{ src: "/legacy/ads/webinar-ad_19d5c0c1.png", alt: "Viz Flowics 2023 Webinar", label: "Viz Flowics", size: "300x250", click_url: "https://broadcastbeat.webinargeek.com/viz-flowics-2023" },
|
||||
{ src: "/legacy/ads/BB_NAB1_022924-15_f127d83a.gif", alt: "Boxx at NAB", label: "Boxx", size: "300x250", click_url: "https://boxx.com/nab2024" },
|
||||
{ src: "/legacy/ads/October-November-ad-Broadcast-Beat-TAG-Monitoring-Layers-blo_9d37b415.jpg", alt: "TAG Video Systems", label: "TAG", size: "300x250", click_url: "https://tagvs.com/blog/future-proofing-broadcast-operations-monitoring/" },
|
||||
{ src: "/legacy/ads/Broadcast-Beat-Banner-March-2024-300x250-1_6dda0fb9.jpg", alt: "Camplex SMPTE", label: "Camplex", size: "300x250", click_url: "https://www.camplex.com/products/category/smpte" },
|
||||
{ src: "/legacy/ads/GIF3_07_10fps_03_71177a21.gif", alt: "Boxx animated", label: "Boxx", size: "300x250", click_url: "https://boxx.com/nab2024" },
|
||||
{ src: "/legacy/ads/Telycam_BroadcastBeat_300x250_MixOne-ExploreXE_with_NAB2026_2f30157f.gif", alt: "Telycam Mix One ExploreXE NAB 2026", label: "Telycam", size: "300x250", click_url: "https://telycam.com/" },
|
||||
{ src: "/legacy/ads/300x250_Matrox_Monarch_EDGE_Series_Flex_Your_Workflow_0924_caf31161.jpg", alt: "Matrox Monarch EDGE", label: "Matrox", size: "300x250", click_url: "https://go.matrox.com/transport-contact-us.html" },
|
||||
{ src: "/legacy/ads/quantum-webinar_8db637f8.jpg", alt: "Quantum Webinar", label: "Quantum", size: "300x250", click_url: "https://broadcastbeat.easywebinar.live/event-registration" },
|
||||
{ src: "/legacy/ads/openGear-guide-2026-300x250-1_a5c4b515.png", alt: "openGear Guide 2026", label: "openGear", size: "300x250", click_url: "https://www.opengear.tv/ultimate-application-guide/" },
|
||||
{ src: "/legacy/ads/250417-300-x-250-ad_4e4ea2cf.png", alt: "OpenDrives at NAB 2025", label: "OpenDrives", size: "300x250", click_url: "https://opendrives.ac-page.com/nabshow2025" },
|
||||
{ src: "/legacy/ads/image001-1-1_d6f7d0df.png", alt: "Look Solutions Tiny S", label: "Look Solutions", size: "300x250", click_url: "https://looksolutionsusa.com/product/tiny-s/" },
|
||||
{ src: "/legacy/ads/300x250-Bolero-Mini_4c8e5038.gif", alt: "Riedel Bolero Mini", label: "Riedel", size: "300x250", click_url: "https://www.riedel.net/en/" },
|
||||
];
|
||||
|
||||
/** Leaderboards — used by AdSlot zones article-top / article-bottom / homepage-top. */
|
||||
export const ADS_728X90: Ad[] = [
|
||||
{ src: "/legacy/ads/Broadcast-Beat-Ad-March-2023_2f1904b5.png", alt: "Tower Products", label: "Tower", size: "728x90", click_url: "https://bit.ly/3rd4oBS" },
|
||||
{ src: "/legacy/ads/Matrox_728x90_ConvertIP_Series_0722_7b13be6a.jpg", alt: "Matrox ConvertIP", label: "Matrox", size: "728x90", click_url: "https://info.matrox.com/video/ad/infrastructure/broadcast/convertip-series" },
|
||||
{ src: "/legacy/ads/11503_URXP41D_Audio_728X90_OnlineBanner_V1_StaticBackup_40K_f92207ce.jpg", alt: "Sony Pro UWP-D27", label: "Sony", size: "728x90", click_url: "https://pro.sony/ue_US/products/broadcastaudiouwpdseriesmicpackages/uwp-d27" },
|
||||
{ src: "/legacy/ads/728-x-90-pxEN_3ce6f379.gif", alt: "LiveU 728x90", label: "LiveU", size: "728x90", click_url: "https://bit.ly/4ghXVeq" },
|
||||
];
|
||||
|
||||
/** Fisher-Yates shuffle, returns a new array. */
|
||||
export function shuffle<T>(arr: T[]): T[] {
|
||||
const out = arr.slice();
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[out[i], out[j]] = [out[j], out[i]];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw N unique ads of the requested size for a single page render.
|
||||
* Pass `exclude` (set of `src` strings already used elsewhere on the same page)
|
||||
* to guarantee no duplicates across slots.
|
||||
*/
|
||||
export function pickAds(
|
||||
size: Ad["size"],
|
||||
n: number,
|
||||
exclude: Set<string> = new Set(),
|
||||
): Ad[] {
|
||||
const pool =
|
||||
size === "300x250" ? ADS_300X250
|
||||
: size === "728x90" ? ADS_728X90
|
||||
: []; // 300x600 is the fixed Blackmagic; not pooled
|
||||
const filtered = pool.filter((a) => !exclude.has(a.src));
|
||||
return shuffle(filtered).slice(0, Math.min(n, filtered.length));
|
||||
}
|
||||
Reference in New Issue
Block a user