home: replace single LiveWire ticker with stacked dual ticker
Two horizontal lanes stacked on top of each other:
Lane 1 LIVE WIRE red gradient label, pulsing white dot,
latest 14 published articles, 65s loop
Lane 2 RECENT FORUM POSTS green gradient label, pulsing white dot,
latest 14 forum threads, 82s loop
The two lanes use intentionally different animation durations (65s vs
82s) so they never sync up — feels alive like a real wire room. Both
loops use CSS translateX with a duplicated content track for seamless
infinite scroll. Edges fade via mask-image so items dissolve in/out
rather than appearing on a hard line.
Both lanes pause on hover (the whole row) so users can stop motion
to read or click. Each item is a real <Link>; clicking opens the
article or thread page.
Server component — data is fetched once at request time from
bb.wp_imported_posts and bb.forum_threads, cached at the page's
revalidate boundary. No client JS needed.
Old LiveWireTicker.tsx is removed (orphaned, not referenced elsewhere).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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";
|
||||
@@ -147,10 +147,10 @@ export default function HomePage() {
|
||||
<h1>Broadcast Beat — Broadcast Engineering News & Insights</h1>
|
||||
</div>
|
||||
|
||||
{/* Scrolling news ticker */}
|
||||
{/* Scrolling news + forum tickers */}
|
||||
<div className="section-enter section-enter-1">
|
||||
<Suspense fallback={<TickerSkeleton />}>
|
||||
<LiveWireTicker />
|
||||
<DoubleTicker />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
|
||||
93
src/components/DoubleTicker.tsx
Normal file
93
src/components/DoubleTicker.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import Link from "next/link";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface TickerItem {
|
||||
href: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
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 [wireRes, forumRes] = await Promise.all([
|
||||
sb
|
||||
.from("wp_imported_posts")
|
||||
.select("wp_slug, title")
|
||||
.eq("status", "published")
|
||||
.order("wp_published_at", { ascending: false, nullsFirst: false })
|
||||
.limit(14),
|
||||
sb
|
||||
.from("forum_threads")
|
||||
.select("id, title")
|
||||
.order("last_reply_at", { ascending: false, nullsFirst: false })
|
||||
.limit(14),
|
||||
]);
|
||||
|
||||
const wire: TickerItem[] = (wireRes.data || []).map((r: any) => ({
|
||||
href: `/news/${r.wp_slug}`,
|
||||
title: r.title,
|
||||
}));
|
||||
const forum: TickerItem[] = (forumRes.data || []).map((r: any) => ({
|
||||
href: `/forum/thread/${r.id}`,
|
||||
title: r.title,
|
||||
}));
|
||||
|
||||
// Fallback to keep layout sensible if either feed is empty
|
||||
const wireItems = wire.length > 0 ? wire : [{ href: "/news", title: "Latest broadcast industry news loading…" }];
|
||||
const forumItems = forum.length > 0 ? forum : [{ href: "/forum", title: "Join the conversation in the forum" }];
|
||||
|
||||
return (
|
||||
<div className="bb-ticker-stack" aria-label="Site activity tickers">
|
||||
{/* LANE 1 — LIVE WIRE (red) */}
|
||||
<div className="bb-ticker bb-ticker--wire" role="region" aria-label="Live wire — latest articles">
|
||||
<span className="bb-ticker-label bb-ticker-label--red">
|
||||
<span className="bb-ticker-pulse" aria-hidden="true" />
|
||||
LIVE WIRE
|
||||
</span>
|
||||
<div className="bb-ticker-mask">
|
||||
<div className="bb-ticker-track bb-ticker-track--wire">
|
||||
{[...wireItems, ...wireItems].map((item, i) => (
|
||||
<Link
|
||||
key={`wire-${i}`}
|
||||
href={item.href}
|
||||
className="bb-ticker-item bb-ticker-item--wire">
|
||||
<span className="bb-ticker-bullet" aria-hidden="true">›</span>
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LANE 2 — RECENT FORUM POSTS (teal-green) */}
|
||||
<div className="bb-ticker bb-ticker--forum" role="region" aria-label="Recent forum posts">
|
||||
<span className="bb-ticker-label bb-ticker-label--green">
|
||||
<span className="bb-ticker-pulse bb-ticker-pulse--green" aria-hidden="true" />
|
||||
RECENT FORUM POSTS
|
||||
</span>
|
||||
<div className="bb-ticker-mask">
|
||||
<div className="bb-ticker-track bb-ticker-track--forum">
|
||||
{[...forumItems, ...forumItems].map((item, i) => (
|
||||
<Link
|
||||
key={`forum-${i}`}
|
||||
href={item.href}
|
||||
className="bb-ticker-item bb-ticker-item--forum">
|
||||
<span className="bb-ticker-bullet" aria-hidden="true">›</span>
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface Item {
|
||||
slug: string;
|
||||
title: string;
|
||||
published_at: string;
|
||||
source_code: string;
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
export default async function LiveWireTicker() {
|
||||
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 [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: Item[] = [];
|
||||
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()
|
||||
);
|
||||
|
||||
const display = items.slice(0, 14);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full bg-[var(--color-background-secondary,#111)] border-b border-[#252525] overflow-hidden"
|
||||
role="region"
|
||||
aria-label="Live wire ticker"
|
||||
>
|
||||
<div className="max-w-container mx-auto px-4 h-[36px] flex items-center gap-4 text-[12px]">
|
||||
<span className="inline-flex items-center gap-1.5 font-mono uppercase tracking-wider text-[var(--color-text-danger,#ef4444)] shrink-0">
|
||||
<span className="bb-pulse-dot bb-pulse-dot--red" /> Live wire
|
||||
</span>
|
||||
<div className="overflow-hidden flex-1">
|
||||
<div className="bb-marquee">
|
||||
{[...display, ...display].map((it, i) => (
|
||||
<Link
|
||||
key={`${it.slug}-${i}`}
|
||||
href={`/news/${it.slug}`}
|
||||
className="inline-flex items-center gap-2 px-4 text-[#d1d5db] hover:text-[var(--color-text-info,#60a5fa)] whitespace-nowrap"
|
||||
>
|
||||
<span className="font-mono text-[#6b7280]">[{hhmm(it.published_at)}]</span>
|
||||
<span className="font-mono text-[var(--color-text-info,#60a5fa)]">[{it.source_code}]</span>
|
||||
<span>{it.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -807,4 +807,197 @@ h1, h2, h3 {
|
||||
.ProseMirror strong { font-weight: 700; color: #fff; }
|
||||
.ProseMirror em { font-style: italic; }
|
||||
.ProseMirror u { text-decoration: underline; }
|
||||
.ProseMirror s { text-decoration: line-through; }
|
||||
.ProseMirror s { text-decoration: line-through; }
|
||||
|
||||
/* =====================
|
||||
DOUBLE TICKER (LIVE WIRE + RECENT FORUM POSTS)
|
||||
===================== */
|
||||
.bb-ticker-stack {
|
||||
width: 100%;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.bb-ticker {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
height: 34px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bb-ticker--wire {
|
||||
background: linear-gradient(90deg, #0a0a0a 0%, #161616 50%, #0a0a0a 100%);
|
||||
border-bottom: 1px solid #1f1212;
|
||||
}
|
||||
|
||||
.bb-ticker--forum {
|
||||
background: linear-gradient(90deg, #08120e 0%, #0f1d18 50%, #08120e 100%);
|
||||
}
|
||||
|
||||
.bb-ticker-label {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.22em;
|
||||
color: #ffffff;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.bb-ticker-label--red {
|
||||
background: linear-gradient(90deg, #d40000 0%, #a30000 100%);
|
||||
box-shadow: 0 0 18px rgba(212, 0, 0, 0.45), inset 0 -1px 0 rgba(255, 255, 255, 0.05);
|
||||
clip-path: polygon(0 0, 100% 0, calc(100% - 10px) 100%, 0 100%);
|
||||
padding-right: 24px;
|
||||
}
|
||||
|
||||
.bb-ticker-label--green {
|
||||
background: linear-gradient(90deg, #0c8a63 0%, #086149 100%);
|
||||
box-shadow: 0 0 18px rgba(12, 138, 99, 0.40), inset 0 -1px 0 rgba(255, 255, 255, 0.05);
|
||||
clip-path: polygon(0 0, 100% 0, calc(100% - 10px) 100%, 0 100%);
|
||||
padding-right: 24px;
|
||||
}
|
||||
|
||||
.bb-ticker-pulse {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 8px rgba(255, 255, 255, 0.9);
|
||||
animation: bb-ticker-pulse-anim 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.bb-ticker-pulse--green {
|
||||
animation-duration: 1.8s;
|
||||
}
|
||||
|
||||
@keyframes bb-ticker-pulse-anim {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(1.25); }
|
||||
}
|
||||
|
||||
.bb-ticker-mask {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
-webkit-mask-image: linear-gradient(to right, transparent 0, #000 28px, #000 calc(100% - 28px), transparent 100%);
|
||||
mask-image: linear-gradient(to right, transparent 0, #000 28px, #000 calc(100% - 28px), transparent 100%);
|
||||
}
|
||||
|
||||
.bb-ticker-track {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: max-content;
|
||||
white-space: nowrap;
|
||||
animation-name: bb-ticker-scroll;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Different speeds so the two lanes never sync up — feels alive */
|
||||
.bb-ticker-track--wire { animation-duration: 65s; }
|
||||
.bb-ticker-track--forum { animation-duration: 82s; }
|
||||
|
||||
.bb-ticker-track:hover,
|
||||
.bb-ticker-track:focus-within {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
@keyframes bb-ticker-scroll {
|
||||
0% { transform: translate3d(0, 0, 0); }
|
||||
100% { transform: translate3d(-50%, 0, 0); }
|
||||
}
|
||||
|
||||
.bb-ticker-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 22px;
|
||||
height: 34px;
|
||||
text-decoration: none;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.005em;
|
||||
color: #d8d8d8;
|
||||
transition: color 0.18s ease;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.bb-ticker-item--wire:hover { color: #ff6b6b; }
|
||||
.bb-ticker-item--forum:hover { color: #5eead4; }
|
||||
|
||||
.bb-ticker-bullet {
|
||||
flex-shrink: 0;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.bb-ticker-item--wire:hover .bb-ticker-bullet { color: #ff6b6b; }
|
||||
.bb-ticker-item--forum:hover .bb-ticker-bullet { color: #5eead4; }
|
||||
|
||||
/* =====================
|
||||
BROWSE NAV BAR (sits below the 728x90 header banner)
|
||||
===================== */
|
||||
.bb-browse-bar {
|
||||
background: linear-gradient(180deg, #0b0b0b 0%, #0e0e0e 100%);
|
||||
border-top: 1px solid #1a1a1a;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.bb-browse-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.bb-browse-item {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #cccccc;
|
||||
text-decoration: none;
|
||||
border-right: 1px solid #1a1a1a;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.bb-browse-item:first-of-type {
|
||||
border-left: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.bb-browse-item:hover,
|
||||
.bb-browse-item[aria-expanded="true"] {
|
||||
background: #161616;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.bb-browse-item .bb-browse-caret {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
font-size: 8px;
|
||||
opacity: 0.7;
|
||||
transform: translateY(-1px);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.bb-browse-item[aria-expanded="true"] .bb-browse-caret {
|
||||
transform: rotate(180deg) translateY(1px);
|
||||
}
|
||||
Reference in New Issue
Block a user