diff --git a/src/app/home-page/components/ArticleFeed.tsx b/src/app/home-page/components/ArticleFeed.tsx index d8d734d..0ace312 100644 --- a/src/app/home-page/components/ArticleFeed.tsx +++ b/src/app/home-page/components/ArticleFeed.tsx @@ -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() { )} - {/* Sidebar */} + {/* Sidebar — Blackmagic 300x600 fixed at top + 5 rotating 300x250s */} diff --git a/src/app/news/[slug]/NewsArticleDetailClient.tsx b/src/app/news/[slug]/NewsArticleDetailClient.tsx index a24b670..04fb9ea 100644 --- a/src/app/news/[slug]/NewsArticleDetailClient.tsx +++ b/src/app/news/[slug]/NewsArticleDetailClient.tsx @@ -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({ /> - {/* Article Body */} -
{/* Tags */} @@ -450,8 +453,8 @@ export default function NewsArticleDetailClient({ {/* Sidebar */}
diff --git a/src/components/AdImage.tsx b/src/components/AdImage.tsx new file mode 100644 index 0000000..cd010d0 --- /dev/null +++ b/src/components/AdImage.tsx @@ -0,0 +1,39 @@ +import AppImage from "@/components/ui/AppImage"; +import type { Ad } from "@/lib/ads"; + +const SIZE_DIM: Record = { + "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 ( + + + + ); +} diff --git a/src/components/ArticleBody.tsx b/src/components/ArticleBody.tsx new file mode 100644 index 0000000..2e6ff73 --- /dev/null +++ b/src/components/ArticleBody.tsx @@ -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

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() === "

") { + 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( +
, + ); + // 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( +
+ +
, + ); + } + } + + return
{out}
; +} diff --git a/src/components/SidebarAdStack.tsx b/src/components/SidebarAdStack.tsx new file mode 100644 index 0000000..d51d462 --- /dev/null +++ b/src/components/SidebarAdStack.tsx @@ -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( + () => pickAds("300x250", count, new Set(excludeSrcs)), + // exclude list is page-render scoped; recompute when it changes + [count, excludeSrcs.join("|")], + ); + + return ( +
+ + {rotating.map((ad) => ( + + ))} +
+ ); +} diff --git a/src/lib/ads.ts b/src/lib/ads.ts new file mode 100644 index 0000000..9d3e95a --- /dev/null +++ b/src/lib/ads.ts @@ -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(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 = 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)); +}