- New AvBeatLogo inline-SVG component (rounded-square emblem with forward-leaning AV monogram + horizontal beam detail) at src/components/AvBeatLogo.tsx, three variants (full/wordmark/icon). - Header.tsx now uses the responsive AV BEAT logo lockup in the top-left, links to /, full lockup on desktop (emblem+wordmark+tagline), emblem+wordmark on tablet, emblem-only on mobile. - Removed DualSpeedTicker (the 'RECENT FORUM POSTS' ticker strip); the news ticker / glow chrome does not match the new aesthetic and the forum row was the explicit removal target. - Tailwind theme + globals.css now expose the AV BEAT 2026 palette as semantic tokens: --brand-blue (#1D4ED8), --brand-blue-bright (#38BDF8), --brand-navy (#0F172A), --brand-bg (#F8FAFC), --brand-surface, --brand-border, --brand-text, --brand-text-muted. Legacy --color-* aliases re-point to the new tokens so any inline-styled component re-themes for free. - Site-wide hex sweep migrates 2,769 hardcoded color literals across 144 files from the old dark-broadcast palette to the new tokens (orange -> blue, dark-brown -> white surface / navy text, cream -> navy). - Layout body class flipped from 'bb-neon' to 'bg-brand-bg text-brand-text' so the dark glow chrome no longer leaks through the new light theme.
233 lines
8.7 KiB
TypeScript
233 lines
8.7 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useTransition } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import Link from "next/link";
|
|
|
|
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;
|
|
}
|
|
|
|
interface StatBucket {
|
|
impressions: number;
|
|
clicks: number;
|
|
}
|
|
interface BannerStats {
|
|
d1: StatBucket;
|
|
d7: StatBucket;
|
|
lifetime: StatBucket;
|
|
}
|
|
|
|
function fmtDate(iso: string | null): string {
|
|
if (!iso) return "";
|
|
return iso.slice(0, 10);
|
|
}
|
|
|
|
function pct(n: number, d: number): string {
|
|
if (!d) return "—";
|
|
return ((n / d) * 100).toFixed(2) + "%";
|
|
}
|
|
|
|
function fmtNum(n: number): string {
|
|
return n.toLocaleString();
|
|
}
|
|
|
|
function StatBlock({ label, b }: { label: string; b: StatBucket }) {
|
|
return (
|
|
<div className="rounded border border-[#1f2937] bg-[#070a10] px-3 py-2 text-xs">
|
|
<div className="uppercase tracking-wider text-[#6b7280]">{label}</div>
|
|
<div className="mt-1 text-[#e5e7eb]">
|
|
<span className="font-semibold">{fmtNum(b.impressions)}</span>
|
|
<span className="text-[#6b7280]"> imp</span>
|
|
<span className="mx-1 text-[#374151]">·</span>
|
|
<span className="font-semibold">{fmtNum(b.clicks)}</span>
|
|
<span className="text-[#6b7280]"> clk</span>
|
|
<span className="mx-1 text-[#374151]">·</span>
|
|
<span className="text-[#1D4ED8]">{pct(b.clicks, b.impressions)}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function BannerRows({
|
|
banners,
|
|
stats,
|
|
}: {
|
|
banners: BannerRow[];
|
|
stats: Record<string, BannerStats>;
|
|
}) {
|
|
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());
|
|
}
|
|
|
|
const empty: BannerStats = {
|
|
d1: { impressions: 0, clicks: 0 },
|
|
d7: { impressions: 0, clicks: 0 },
|
|
lifetime: { impressions: 0, clicks: 0 },
|
|
};
|
|
|
|
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] || {}) };
|
|
const s = stats[b.id] || empty;
|
|
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-[#1D4ED8] 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-[#1D4ED8] 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-[#1D4ED8] 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-[#1D4ED8] 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-[#1D4ED8] focus:outline-none"
|
|
>
|
|
<option value="active">active</option>
|
|
<option value="scheduled">scheduled</option>
|
|
<option value="paused">paused</option>
|
|
<option value="expired">expired</option>
|
|
<option value="inactive">inactive</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-4 grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
<StatBlock label="Last 24h" b={s.d1} />
|
|
<StatBlock label="Last 7 days" b={s.d7} />
|
|
<StatBlock label="Lifetime" b={s.lifetime} />
|
|
</div>
|
|
|
|
<div className="mt-3 flex items-center justify-between gap-3">
|
|
<div className="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-[#1E3A8A] 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>
|
|
<Link
|
|
href={`/admin/banners/${b.id}/analytics`}
|
|
className="text-sm text-[#1D4ED8] hover:underline"
|
|
>
|
|
View detailed analytics →
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|