ticker: replace LiveWire with dual-row neon ticker (news + events)
Row 1 (Live Wire, scrolls →): most recent imported + AI-rewritten articles, neon-green label, monospaced [HH:MM] [SOURCE_CODE] prefix. Row 2 (On Calendar, scrolls ←, slower): upcoming events from bb.events (confirmed/tentative, start_date >= today), neon-cyan label, [Mon DD] [SHORT_NAME] prefix. Defensive: each query is in its own try/catch so a partial failure renders an empty row rather than crashing the page — that was the silent build crash that took down the prior DoubleTicker incarnation. If BOTH queries return zero rows we render nothing instead of two blank bars. CSS tokens added to redesign-tokens.css: - bb-marquee--reverse + bb-marquee--slow modifiers - .bb-neon-bar / .bb-neon-label / .bb-neon-time / .bb-neon-code (scoped class names so this is the ONLY place on the site that uses the neon palette — rest of the site keeps dark-navy + blue) User cancelled the wholesale neon redesign; only the dual-ticker keeps the neon green/cyan styling.
This commit is contained in:
@@ -4,7 +4,7 @@ import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import FeaturedBentoFromDb from '@/components/FeaturedBentoFromDb';
|
||||
import FeaturedCarouselServer from '@/components/FeaturedCarouselServer';
|
||||
import LiveWireTicker from '@/components/LiveWireTicker';
|
||||
import DoubleTicker from '@/components/DoubleTicker';
|
||||
import NewsTicker from "./components/NewsTicker";
|
||||
import FeaturedBento from "./components/FeaturedBento";
|
||||
import SpotlightCarousel from "./components/SpotlightCarousel";
|
||||
@@ -150,7 +150,7 @@ export default function HomePage() {
|
||||
{/* Scrolling news ticker */}
|
||||
<div className="section-enter section-enter-1">
|
||||
<Suspense fallback={<TickerSkeleton />}>
|
||||
<LiveWireTicker />
|
||||
<DoubleTicker />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
|
||||
205
src/components/DoubleTicker.tsx
Normal file
205
src/components/DoubleTicker.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import Link from "next/link";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface NewsItem {
|
||||
slug: string;
|
||||
title: string;
|
||||
published_at: string;
|
||||
source_code: string;
|
||||
}
|
||||
|
||||
interface EventItem {
|
||||
slug: string | null;
|
||||
short_name: string;
|
||||
name: string;
|
||||
start_date: string;
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
}
|
||||
|
||||
const SOURCE_CODES: { match: RegExp; code: string }[] = [
|
||||
{ match: /blackmagic|davinci/i, code: "BMD" },
|
||||
{ match: /magewell/i, code: "MGW" },
|
||||
{ match: /aja/i, code: "AJA" },
|
||||
{ match: /sony/i, code: "SONY" },
|
||||
{ match: /matrox/i, code: "MTRX" },
|
||||
{ match: /telestream/i, code: "TLST" },
|
||||
{ match: /grass valley/i, code: "GV" },
|
||||
{ match: /lawo/i, code: "LAWO" },
|
||||
{ match: /calrec/i, code: "CLRC" },
|
||||
{ match: /zixi/i, code: "ZIXI" },
|
||||
{ match: /liveu/i, code: "LVU" },
|
||||
{ match: /netflix/i, code: "NFLX" },
|
||||
{ match: /youtube/i, code: "YT" },
|
||||
{ match: /amazon|aws/i, code: "AWS" },
|
||||
];
|
||||
|
||||
function codeFor(title: string): string {
|
||||
for (const { match, code } of SOURCE_CODES) if (match.test(title)) return code;
|
||||
return "WIRE";
|
||||
}
|
||||
|
||||
function hhmm(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const h = String(d.getUTCHours()).padStart(2, "0");
|
||||
const m = String(d.getUTCMinutes()).padStart(2, "0");
|
||||
return `${h}:${m}`;
|
||||
}
|
||||
|
||||
function fmtEventDate(iso: string): string {
|
||||
// Show "MMM DD" (no year) so each row stays compact.
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
} catch {
|
||||
return iso.slice(5, 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Server-side fetches return arrays — but a network/RLS error can throw.
|
||||
// Each is wrapped so a partial failure renders an empty row rather than
|
||||
// crashing the whole component (the silent build crash that took down
|
||||
// the prior incarnation of this component was caused by an uncaught
|
||||
// throw inside an async server component).
|
||||
async function loadNews(sb: any): Promise<NewsItem[]> {
|
||||
try {
|
||||
const [wp, ai] = await Promise.all([
|
||||
sb
|
||||
.from("wp_imported_posts")
|
||||
.select("wp_slug, title, wp_published_at")
|
||||
.eq("status", "published")
|
||||
.order("wp_published_at", { ascending: false })
|
||||
.limit(12),
|
||||
sb
|
||||
.from("ai_rewritten_articles")
|
||||
.select("slug, title, published_at")
|
||||
.eq("status", "published")
|
||||
.order("published_at", { ascending: false, nullsFirst: false })
|
||||
.limit(6),
|
||||
]);
|
||||
const items: NewsItem[] = [];
|
||||
for (const r of (wp.data || []) as any[]) {
|
||||
items.push({
|
||||
slug: r.wp_slug,
|
||||
title: r.title,
|
||||
published_at: r.wp_published_at,
|
||||
source_code: codeFor(r.title || ""),
|
||||
});
|
||||
}
|
||||
for (const r of (ai.data || []) as any[]) {
|
||||
items.push({
|
||||
slug: r.slug,
|
||||
title: r.title,
|
||||
published_at: r.published_at,
|
||||
source_code: codeFor(r.title || ""),
|
||||
});
|
||||
}
|
||||
items.sort(
|
||||
(a, b) => new Date(b.published_at).getTime() - new Date(a.published_at).getTime()
|
||||
);
|
||||
return items.slice(0, 14);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEvents(sb: any): Promise<EventItem[]> {
|
||||
try {
|
||||
const { data } = await sb
|
||||
.from("events")
|
||||
.select("slug, short_name, name, start_date, city, country")
|
||||
.in("status", ["confirmed", "tentative"])
|
||||
.gte("start_date", new Date().toISOString().slice(0, 10))
|
||||
.order("start_date", { ascending: true })
|
||||
.limit(12);
|
||||
return ((data || []) as any[]).map((r) => ({
|
||||
slug: r.slug || null,
|
||||
short_name: r.short_name || r.name,
|
||||
name: r.name,
|
||||
start_date: r.start_date,
|
||||
city: r.city,
|
||||
country: r.country,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function DoubleTicker() {
|
||||
const sb = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
db: { schema: (process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "bb") as "public" },
|
||||
auth: { persistSession: false },
|
||||
}
|
||||
);
|
||||
|
||||
const [news, events] = await Promise.all([loadNews(sb), loadEvents(sb)]);
|
||||
|
||||
// If both queries failed, render nothing — better than a half-blank bar.
|
||||
if (news.length === 0 && events.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div role="region" aria-label="Live wire and upcoming events">
|
||||
{/* Row 1 — Live wire (news, right-to-left, fast). */}
|
||||
{news.length > 0 && (
|
||||
<div className="bb-neon-bar w-full overflow-hidden">
|
||||
<div className="max-w-container mx-auto px-4 h-[32px] flex items-center gap-3 text-[12px]">
|
||||
<span className="bb-neon-label">Live Wire</span>
|
||||
<div className="overflow-hidden flex-1">
|
||||
<div className="bb-marquee">
|
||||
{[...news, ...news].map((it, i) => (
|
||||
<Link
|
||||
key={`n-${it.slug}-${i}`}
|
||||
href={`/news/${it.slug}`}
|
||||
className="inline-flex items-center gap-2 px-4 whitespace-nowrap bb-neon-text"
|
||||
>
|
||||
<span className="bb-neon-time">[{hhmm(it.published_at)}]</span>
|
||||
<span className="bb-neon-code">[{it.source_code}]</span>
|
||||
<span>{it.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 2 — Industry calendar (events, left-to-right, slower). */}
|
||||
{events.length > 0 && (
|
||||
<div className="bb-neon-bar w-full overflow-hidden">
|
||||
<div className="max-w-container mx-auto px-4 h-[32px] flex items-center gap-3 text-[12px]">
|
||||
<span className="bb-neon-label bb-neon-label--cyan">On Calendar</span>
|
||||
<div className="overflow-hidden flex-1">
|
||||
<div className="bb-marquee bb-marquee--reverse bb-marquee--slow">
|
||||
{[...events, ...events].map((ev, i) => {
|
||||
const where = [ev.city, ev.country].filter(Boolean).join(", ");
|
||||
const inner = (
|
||||
<span className="inline-flex items-center gap-2 px-4 whitespace-nowrap bb-neon-text">
|
||||
<span className="bb-neon-time">[{fmtEventDate(ev.start_date)}]</span>
|
||||
<span className="bb-neon-code bb-neon-code--cyan">[{ev.short_name}]</span>
|
||||
<span>{ev.name}{where ? ` — ${where}` : ""}</span>
|
||||
</span>
|
||||
);
|
||||
return ev.slug ? (
|
||||
<Link key={`e-${ev.short_name}-${i}`} href={`/events/${ev.slug}`}>
|
||||
{inner}
|
||||
</Link>
|
||||
) : (
|
||||
<span key={`e-${ev.short_name}-${i}`}>{inner}</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -74,9 +74,49 @@
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
@keyframes bb-marquee-reverse {
|
||||
from { transform: translateX(-50%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
.bb-marquee {
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
animation: bb-marquee 90s linear infinite;
|
||||
}
|
||||
.bb-marquee:hover { animation-play-state: paused; }
|
||||
.bb-marquee--reverse {
|
||||
animation-name: bb-marquee-reverse;
|
||||
}
|
||||
.bb-marquee--slow {
|
||||
animation-duration: 140s;
|
||||
}
|
||||
|
||||
/* Neon dual-ticker tokens — used ONLY inside DoubleTicker. */
|
||||
.bb-neon-bar {
|
||||
background: linear-gradient(180deg, rgba(0,0,0,0.55), rgba(5,10,8,0.55));
|
||||
border-top: 1px solid rgba(0,255,159,0.18);
|
||||
border-bottom: 1px solid rgba(0,255,159,0.18);
|
||||
}
|
||||
.bb-neon-bar + .bb-neon-bar {
|
||||
border-top: 1px solid rgba(0,212,255,0.10); /* faint divider between the two */
|
||||
}
|
||||
.bb-neon-label {
|
||||
background: #00ff9f;
|
||||
color: #050a08;
|
||||
font-weight: 700;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.15em;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
text-shadow: none;
|
||||
}
|
||||
.bb-neon-label--cyan {
|
||||
background: #00d4ff;
|
||||
}
|
||||
.bb-neon-time { color: #707070; font-family: 'IBM Plex Mono', ui-monospace, monospace; }
|
||||
.bb-neon-code { color: #00ff9f; font-family: 'IBM Plex Mono', ui-monospace, monospace; }
|
||||
.bb-neon-code--cyan { color: #00d4ff; }
|
||||
.bb-neon-text { color: #d4d4d4; }
|
||||
.bb-neon-text:hover { color: #00ff9f; text-shadow: 0 0 8px rgba(0,255,159,0.4); }
|
||||
|
||||
Reference in New Issue
Block a user