diff --git a/public/legacy/ads/300x250-banner-Our-Story-film_resize.jpg b/public/legacy/ads/300x250-banner-Our-Story-film_resize.jpg new file mode 100644 index 0000000..a89d2be Binary files /dev/null and b/public/legacy/ads/300x250-banner-Our-Story-film_resize.jpg differ diff --git a/public/legacy/ads/728-x-90-pxEN.gif b/public/legacy/ads/728-x-90-pxEN.gif new file mode 100644 index 0000000..8231a88 Binary files /dev/null and b/public/legacy/ads/728-x-90-pxEN.gif differ diff --git a/public/legacy/ads/Davinci-Resolve-20_300x600.jpg b/public/legacy/ads/Davinci-Resolve-20_300x600.jpg new file mode 100644 index 0000000..4d6184e Binary files /dev/null and b/public/legacy/ads/Davinci-Resolve-20_300x600.jpg differ diff --git a/public/legacy/ads/Magewell_Pro-Convert-IP-to-HDMI_Sports_NAB2026.gif b/public/legacy/ads/Magewell_Pro-Convert-IP-to-HDMI_Sports_NAB2026.gif new file mode 100644 index 0000000..977318f Binary files /dev/null and b/public/legacy/ads/Magewell_Pro-Convert-IP-to-HDMI_Sports_NAB2026.gif differ diff --git a/public/legacy/ads/PAYG-300x250-1.gif b/public/legacy/ads/PAYG-300x250-1.gif new file mode 100644 index 0000000..438efb0 Binary files /dev/null and b/public/legacy/ads/PAYG-300x250-1.gif differ diff --git a/public/legacy/ads/Studio-Hero-for-Broadcast-Beat.png b/public/legacy/ads/Studio-Hero-for-Broadcast-Beat.png new file mode 100644 index 0000000..02d1426 Binary files /dev/null and b/public/legacy/ads/Studio-Hero-for-Broadcast-Beat.png differ diff --git a/public/legacy/ads/Zixi_Ads_300x250.png b/public/legacy/ads/Zixi_Ads_300x250.png new file mode 100644 index 0000000..4a1cdc4 Binary files /dev/null and b/public/legacy/ads/Zixi_Ads_300x250.png differ diff --git a/public/legacy/ads/aja_2025_colorbox_og_colorbox_300x250_en-2.gif b/public/legacy/ads/aja_2025_colorbox_og_colorbox_300x250_en-2.gif new file mode 100644 index 0000000..0065818 Binary files /dev/null and b/public/legacy/ads/aja_2025_colorbox_og_colorbox_300x250_en-2.gif differ diff --git a/src/app/admin/banners/BannerRows.tsx b/src/app/admin/banners/BannerRows.tsx new file mode 100644 index 0000000..0a32952 --- /dev/null +++ b/src/app/admin/banners/BannerRows.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; + +interface BannerRow { + id: string; + slug: string; + name: string; + image_path: string; + click_url: string | null; + size: "300x250" | "300x600" | "728x90"; + start_date: string | null; + end_date: string | null; + status: string; + adsanity_source_id: number | null; + notes: string | null; + updated_at: string; +} + +function fmtDate(iso: string | null): string { + if (!iso) return ""; + return iso.slice(0, 10); +} + +export default function BannerRows({ banners }: { banners: BannerRow[] }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [edits, setEdits] = useState>>({}); + const [err, setErr] = useState(null); + const [saved, setSaved] = useState(null); + + function field(id: string, key: keyof BannerRow, value: any) { + setEdits((e) => ({ ...e, [id]: { ...e[id], [key]: value } })); + } + + async function save(id: string) { + setErr(null); + setSaved(null); + const patch = edits[id]; + if (!patch || Object.keys(patch).length === 0) return; + const body: Record = { ...patch }; + if (body.start_date === "") body.start_date = null; + if (body.end_date === "") body.end_date = null; + const res = await fetch(`/api/admin/banners/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const j = await res.json().catch(() => ({})); + setErr(j.error || `HTTP ${res.status}`); + return; + } + setSaved(id); + setEdits((e) => { + const n = { ...e }; + delete n[id]; + return n; + }); + startTransition(() => router.refresh()); + } + + return ( +
+ {err &&
{err}
} + + {banners.map((b) => { + const dirty = !!edits[b.id]; + const current: BannerRow = { ...b, ...(edits[b.id] || {}) }; + return ( +
+
+
+ {b.name} +
{b.size}
+
+ +
+ + + + + + + + + + +
+ Image path + {b.image_path} + {b.adsanity_source_id && ( + AdSanity #{b.adsanity_source_id} + )} +
+
+
+ +
+ + {saved === b.id && Saved} + {dirty && saved !== b.id && Unsaved} +
+
+ ); + })} +
+ ); +} diff --git a/src/app/admin/banners/page.tsx b/src/app/admin/banners/page.tsx new file mode 100644 index 0000000..b27eff4 --- /dev/null +++ b/src/app/admin/banners/page.tsx @@ -0,0 +1,70 @@ +import Link from "next/link"; +import { createAdminClient } from "@/lib/supabase/admin"; +import { createClient } from "@/lib/supabase/server"; +import { redirect } from "next/navigation"; +import BannerRows from "./BannerRows"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +interface BannerRow { + id: string; + slug: string; + name: string; + image_path: string; + click_url: string | null; + size: "300x250" | "300x600" | "728x90"; + start_date: string | null; + end_date: string | null; + status: string; + adsanity_source_id: number | null; + notes: string | null; + updated_at: string; +} + +async function requireAdmin() { + const sb = await createClient(); + const { data: { user } } = await sb.auth.getUser(); + if (!user) return { ok: false as const }; + const { data: profile } = await sb + .from("user_profiles") + .select("role,is_active") + .eq("id", user.id) + .maybeSingle(); + if (!profile?.is_active) return { ok: false as const }; + if (!["administrator", "admin"].includes((profile as any).role)) return { ok: false as const }; + return { ok: true as const }; +} + +export default async function AdminBannersPage() { + const guard = await requireAdmin(); + if (!guard.ok) redirect("/admin"); + + const admin = createAdminClient(); + const { data: rows } = await admin + .from("banner_creatives") + .select("*") + .order("size", { ascending: true }) + .order("slug", { ascending: true }); + + const banners = (rows || []) as BannerRow[]; + + return ( +
+
+
+

Banner creatives

+

+ {banners.length} banners · live data from bb.banner_creatives. Edits + apply within 5 minutes (in-process cache TTL on the site renderer). +

+
+ + ← Admin home + +
+ + +
+ ); +} diff --git a/src/app/api/admin/banners/[id]/route.ts b/src/app/api/admin/banners/[id]/route.ts new file mode 100644 index 0000000..6118b0e --- /dev/null +++ b/src/app/api/admin/banners/[id]/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { createAdminClient } from "@/lib/supabase/admin"; + +export const runtime = "nodejs"; + +async function requireAdmin() { + const sb = await createClient(); + const { data: { user } } = await sb.auth.getUser(); + if (!user) return { ok: false as const, status: 401, error: "Unauthorized" }; + const { data: profile } = await sb + .from("user_profiles") + .select("role, is_active") + .eq("id", user.id) + .maybeSingle(); + if (!profile?.is_active) return { ok: false as const, status: 403, error: "Inactive" }; + if (!["administrator", "admin"].includes((profile as any).role)) { + return { ok: false as const, status: 403, error: "Insufficient permissions" }; + } + return { ok: true as const }; +} + +const ALLOWED_FIELDS = new Set([ + "name", + "click_url", + "image_path", + "size", + "start_date", + "end_date", + "status", + "notes", +]); + +export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const guard = await requireAdmin(); + if (!guard.ok) return NextResponse.json({ error: guard.error }, { status: guard.status }); + + const { id } = await params; + const body = (await req.json()) as Record; + const patch: Record = {}; + for (const [k, v] of Object.entries(body)) { + if (ALLOWED_FIELDS.has(k)) patch[k] = v; + } + if (Object.keys(patch).length === 0) { + return NextResponse.json({ error: "No allowed fields supplied" }, { status: 400 }); + } + patch.updated_at = new Date().toISOString(); + + const admin = createAdminClient(); + const { data, error } = await admin + .from("banner_creatives") + .update(patch) + .eq("id", id) + .select("id, slug, status") + .single(); + if (error || !data) return NextResponse.json({ error: error?.message || "Not found" }, { status: 500 }); + + return NextResponse.json({ ok: true, banner: data }); +} diff --git a/src/lib/ads.ts b/src/lib/ads.ts index 66c2138..defa3db 100644 --- a/src/lib/ads.ts +++ b/src/lib/ads.ts @@ -1,10 +1,15 @@ -// Banner control panel: this file is the source of truth for which ads -// render on broadcastbeat.com. Adding a new entry here automatically -// rotates it into the appropriate zone — no other code changes needed. +// Banner serving — source of truth is bb.banner_creatives in Supabase. +// The DB query is date-gated (status=active AND start<=NOW<=end) and the +// result is cached in-process for 5 minutes. A hardcoded fallback keeps +// the site rendering banners if Supabase is unreachable during a deploy +// or during the initial module load. // -// Each Ad must provide a local image (src + click_url + alt). The legacy -// Adsanity iframe path was removed because ads.broadcastbeat.com no longer -// resolves; banners awaiting creatives are tracked in PENDING_ads.md. +// Public helpers (rotateAll, pickAds) are SYNCHRONOUS so existing client +// component callsites keep working. They read from a module-level cache +// that is primed on first import and refreshed asynchronously after the +// TTL expires. + +import { createClient } from "@supabase/supabase-js"; export interface Ad { label: string; @@ -14,50 +19,107 @@ export interface Ad { click_url?: string; } -/** 728x90 leaderboard pool — header (between nav and ticker) and footer. */ -export const ADS_728X90: Ad[] = [ - { - label: "LiveU", - size: "728x90", - src: "/legacy/ads/728-x-90-pxEN_3ce6f379.gif", - alt: "LiveU 728x90", - click_url: "https://bit.ly/4ghXVeq", - }, +const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!; +const SUPABASE_ANON = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; +const SCHEMA = process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "bb"; + +const CACHE_TTL_MS = 5 * 60 * 1000; +type CacheRow = { rows: Ad[]; loadedAt: number }; +let CACHE: CacheRow | null = null; +let REFRESH_IN_FLIGHT: Promise | null = null; + +const FALLBACK: Ad[] = [ + { label: "LiveU 728x90", size: "728x90", src: "/legacy/ads/728-x-90-pxEN.gif", + alt: "LiveU 728x90", click_url: "https://bit.ly/4ghXVeq" }, + { label: "Blackmagic DaVinci Resolve 20", size: "300x600", + src: "/legacy/ads/Davinci-Resolve-20_300x600.jpg", + alt: "Blackmagic Design DaVinci Resolve 20", + click_url: "http://bmd.link/mRNsKu" }, + { label: "Studio Hero", size: "300x250", src: "/legacy/ads/Studio-Hero-for-Broadcast-Beat.png", + alt: "Studio Suite — Studio Hero", click_url: "http://www.TheStudioHero.com" }, + { label: "Magewell Pro-Convert", size: "300x250", + src: "/legacy/ads/Magewell_Pro-Convert-IP-to-HDMI_Sports_NAB2026.gif", + alt: "Magewell Pro-Convert IP to HDMI", + click_url: "https://www.magewell.com" }, + { label: "LiveU PAYG", size: "300x250", src: "/legacy/ads/PAYG-300x250-1.gif", + alt: "LiveU Pay-As-You-Go", click_url: "https://bit.ly/4ghXVeq" }, + { label: "AJA ColorBox", size: "300x250", src: "/legacy/ads/aja_2025_colorbox_og_colorbox_300x250_en-2.gif", + alt: "AJA ColorBox", click_url: "https://www.aja.com" }, + { label: "Zixi", size: "300x250", src: "/legacy/ads/Zixi_Ads_300x250.png", + alt: "Zixi", click_url: "https://zixi.com/" }, + { label: "Lectrosonics", size: "300x250", src: "/legacy/ads/300x250-banner-Our-Story-film_resize.jpg", + alt: "Lectrosonics Our Story", click_url: "https://www.lectrosonics.com/" }, ]; -/** - * 300x250 sidebar pool. ALL entries render on every page — SidebarAdStack - * stacks them vertically and shuffles the order per page-view so each banner - * cycles through positions. Add new entries here to grow the stack. - * - * AJA, Zixi, Lectrosonics, Opengear, Studio Hero, Magewell were removed - * pending real creatives (their Adsanity campaigns relied on - * ads.broadcastbeat.com, which is down). See PENDING_ads.md. - */ -export const ADS_300X250: Ad[] = [ - { - label: "Telycam", - size: "300x250", - src: "/legacy/ads/Telycam_BroadcastBeat_300x250_MixOne-ExploreXE_with_NAB2026_2f30157f.gif", - alt: "Telycam Mix One ExploreXE NAB 2026", - click_url: "https://telycam.com/", - }, - { - label: "LiveU", - size: "300x250", - src: "/legacy/ads/PAYG-300x250-1_31957672.gif", - alt: "LiveU pay-as-you-go", - click_url: "https://bit.ly/4ghXVeq", - }, -]; +interface DbRow { + slug: string; + name: string; + image_path: string; + click_url: string | null; + size: "300x250" | "300x600" | "728x90"; +} -/** - * 300x600 fixed sidebar slot. Rendered at the TOP of SidebarAdStack and is - * NEVER rotated. Set to null to omit. Blackmagic ATEM creative pending. - */ -export const FIXED_300X600: Ad | null = null; +async function loadFromDb(): Promise { + if (!SUPABASE_URL || !SUPABASE_ANON) return FALLBACK; + try { + const sb = createClient(SUPABASE_URL, SUPABASE_ANON, { + db: { schema: SCHEMA as "public" }, + auth: { persistSession: false }, + }); + const nowIso = new Date().toISOString(); + const { data, error } = await sb + .from("banner_creatives") + .select("slug,name,image_path,click_url,size,start_date,end_date,status") + .eq("status", "active") + .or(`start_date.is.null,start_date.lte.${nowIso}`) + .or(`end_date.is.null,end_date.gte.${nowIso}`); + if (error || !data || data.length === 0) { + if (typeof console !== "undefined") { + console.warn("[ads] db query empty/error, using fallback:", error?.message || "no rows"); + } + return FALLBACK; + } + return (data as DbRow[]).map((r) => ({ + label: r.name, + size: r.size, + src: r.image_path, + alt: r.name, + click_url: r.click_url || undefined, + })); + } catch (err: any) { + if (typeof console !== "undefined") { + console.warn("[ads] db query threw, using fallback:", err?.message); + } + return FALLBACK; + } +} + +function triggerRefresh(): void { + if (REFRESH_IN_FLIGHT) return; + REFRESH_IN_FLIGHT = loadFromDb() + .then((rows) => { + CACHE = { rows, loadedAt: Date.now() }; + }) + .catch(() => { + // Failures stay logged via loadFromDb; keep CACHE as-is. + }) + .finally(() => { + REFRESH_IN_FLIGHT = null; + }); +} + +function readCachedAds(): Ad[] { + if (!CACHE) { + triggerRefresh(); + return FALLBACK; + } + if (Date.now() - CACHE.loadedAt > CACHE_TTL_MS) { + // Stale — kick off a background refresh but serve the stale snapshot. + triggerRefresh(); + } + return CACHE.rows; +} -/** 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--) { @@ -67,31 +129,26 @@ export function shuffle(arr: T[]): T[] { return out; } -/** - * Return ALL ads of the requested size in a shuffled order. Use this when - * every banner must render (e.g. the sidebar 300x250 stack); the shuffle - * ensures each banner cycles through positions across page-views. - */ export function rotateAll(size: Ad["size"], exclude: Set = new Set()): Ad[] { - const pool = - size === "300x250" ? ADS_300X250 - : size === "728x90" ? ADS_728X90 - : size === "300x600" ? (FIXED_300X600 ? [FIXED_300X600] : []) - : []; + const pool = readCachedAds().filter((a) => a.size === size); const key = (a: Ad) => a.src ?? a.label; return shuffle(pool.filter((a) => !exclude.has(key(a)))); } -/** - * Draw N ads of the requested size, shuffled. Used by ArticleBody for - * a bounded number of in-article slots. For zones that must show every - * banner, prefer rotateAll(). - */ export function pickAds( size: Ad["size"], n: number, - exclude: Set = new Set(), + exclude: Set = new Set() ): Ad[] { const all = rotateAll(size, exclude); return all.slice(0, Math.min(n, all.length)); } + +// Sync exports for callers that still want the bare arrays. These also +// read the cache (so live edits surface) but fall back to FALLBACK. +export const ADS_728X90: Ad[] = (() => readCachedAds().filter((a) => a.size === "728x90"))(); +export const ADS_300X250: Ad[] = (() => readCachedAds().filter((a) => a.size === "300x250"))(); +export const FIXED_300X600: Ad | null = (() => readCachedAds().find((a) => a.size === "300x600") || null)(); + +// Prime the cache once at module load. +triggerRefresh();