banners: refresh 8 creatives + bb.banner_creatives + /admin/banners
Backend:
bb.banner_creatives (new) — image_path, click_url, size, start/end_date,
status, adsanity_source_id. Seeded with 8 banners from broadcas_temp
WP-export DB (post_type=ads): LiveU 728x90, Blackmagic DaVinci Resolve
20 (300x600), Studio Hero, Magewell Pro-Convert, LiveU PAYG, AJA
ColorBox, Zixi, Lectrosonics (all 300x250). Dates and click URLs come
from the AdSanity rows; current creatives downloaded from Wayback
snapshot 20260316123737.
src/lib/ads.ts:
- Reads bb.banner_creatives via anon supabase-js client, gated by
status='active' AND start<=NOW()<=end.
- In-process 5-min TTL cache with stale-while-revalidate. Module load
primes the cache; clients see fallback on first hit, live DB on
subsequent.
- Hardcoded FALLBACK list mirrors the seeded rows so the site keeps
rendering banners if Supabase is unreachable during deploy.
- Public helpers (rotateAll, pickAds, ADS_728X90, ADS_300X250,
FIXED_300X600) remain synchronous so existing client component
callsites work unchanged.
/admin/banners:
Admin-only list + inline editor for every banner_creatives row:
name, click URL, start/end date, status (active|scheduled|paused|
expired). PATCH via /api/admin/banners/[id] — service_role bypass for
the write, user_profiles.role admin/administrator for the gate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
167
src/app/admin/banners/BannerRows.tsx
Normal file
167
src/app/admin/banners/BannerRows.tsx
Normal file
@@ -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<Record<string, Partial<BannerRow>>>({});
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState<string | null>(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<string, any> = { ...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 (
|
||||
<div className="space-y-4">
|
||||
{err && <div className="rounded border border-red-400 bg-red-900/30 px-4 py-3 text-sm text-red-200">{err}</div>}
|
||||
|
||||
{banners.map((b) => {
|
||||
const dirty = !!edits[b.id];
|
||||
const current: BannerRow = { ...b, ...(edits[b.id] || {}) };
|
||||
return (
|
||||
<div key={b.id} className="rounded border border-[#1f2937] bg-[#0b0f17] p-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="shrink-0">
|
||||
<img
|
||||
src={b.image_path}
|
||||
alt={b.name}
|
||||
style={{
|
||||
width: b.size === "728x90" ? 320 : b.size === "300x600" ? 150 : 200,
|
||||
height: "auto",
|
||||
}}
|
||||
className="rounded border border-[#1f2937]"
|
||||
/>
|
||||
<div className="mt-1 text-center text-xs text-[#6b7280]">{b.size}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||
<label className="flex flex-col">
|
||||
<span className="text-xs uppercase tracking-wider text-[#9ca3af] mb-1">Name</span>
|
||||
<input
|
||||
value={current.name}
|
||||
onChange={(e) => field(b.id, "name", e.target.value)}
|
||||
className="rounded border border-[#1f2937] bg-[#070a10] p-2 text-[#e5e7eb] focus:border-[#3b82f6] focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col">
|
||||
<span className="text-xs uppercase tracking-wider text-[#9ca3af] mb-1">Click URL</span>
|
||||
<input
|
||||
value={current.click_url || ""}
|
||||
onChange={(e) => field(b.id, "click_url", e.target.value)}
|
||||
placeholder="https://…"
|
||||
className="rounded border border-[#1f2937] bg-[#070a10] p-2 text-[#e5e7eb] focus:border-[#3b82f6] focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col">
|
||||
<span className="text-xs uppercase tracking-wider text-[#9ca3af] mb-1">Start date</span>
|
||||
<input
|
||||
type="date"
|
||||
value={fmtDate(current.start_date)}
|
||||
onChange={(e) => field(b.id, "start_date", e.target.value ? new Date(e.target.value).toISOString() : null)}
|
||||
className="rounded border border-[#1f2937] bg-[#070a10] p-2 text-[#e5e7eb] focus:border-[#3b82f6] focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col">
|
||||
<span className="text-xs uppercase tracking-wider text-[#9ca3af] mb-1">End date</span>
|
||||
<input
|
||||
type="date"
|
||||
value={fmtDate(current.end_date)}
|
||||
onChange={(e) => field(b.id, "end_date", e.target.value ? new Date(e.target.value).toISOString() : null)}
|
||||
className="rounded border border-[#1f2937] bg-[#070a10] p-2 text-[#e5e7eb] focus:border-[#3b82f6] focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col">
|
||||
<span className="text-xs uppercase tracking-wider text-[#9ca3af] mb-1">Status</span>
|
||||
<select
|
||||
value={current.status}
|
||||
onChange={(e) => field(b.id, "status", e.target.value)}
|
||||
className="rounded border border-[#1f2937] bg-[#070a10] p-2 text-[#e5e7eb] focus:border-[#3b82f6] focus:outline-none"
|
||||
>
|
||||
<option value="active">active</option>
|
||||
<option value="scheduled">scheduled</option>
|
||||
<option value="paused">paused</option>
|
||||
<option value="expired">expired</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col text-xs text-[#6b7280]">
|
||||
<span className="uppercase tracking-wider text-[#9ca3af] mb-1">Image path</span>
|
||||
<span className="font-mono break-all">{b.image_path}</span>
|
||||
{b.adsanity_source_id && (
|
||||
<span className="mt-1">AdSanity #{b.adsanity_source_id}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => save(b.id)}
|
||||
disabled={!dirty || pending}
|
||||
className="rounded bg-[#1d4ed8] px-3 py-1.5 text-sm font-medium text-white hover:bg-[#2563eb] disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Save changes
|
||||
</button>
|
||||
{saved === b.id && <span className="text-xs text-emerald-400">Saved</span>}
|
||||
{dirty && saved !== b.id && <span className="text-xs text-amber-400">Unsaved</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
src/app/admin/banners/page.tsx
Normal file
70
src/app/admin/banners/page.tsx
Normal file
@@ -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 (
|
||||
<main className="mx-auto max-w-6xl px-6 py-10 text-[#e5e7eb]">
|
||||
<header className="mb-8 border-b border-[#222] pb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Banner creatives</h1>
|
||||
<p className="text-sm text-[#9ca3af] mt-1">
|
||||
{banners.length} banners · live data from bb.banner_creatives. Edits
|
||||
apply within 5 minutes (in-process cache TTL on the site renderer).
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/admin" className="text-sm text-[#60a5fa] hover:underline">
|
||||
← Admin home
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<BannerRows banners={banners} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user