homepage: neon dual-speed ticker + emerald/cyan palette pass

- New DualSpeedTicker component (CSS keyframes, GPU-accelerated,
  pause on hover, 28px rows, 17s BEAT / 40s FORUM, refresh 5min)
- /api/ticker/beat-news: last 7 days from bb.native_articles
- /api/ticker/forum-posts: latest threads + replies interleaved
- Mounted at the bottom of Header so every page gets it
- Scoped .bb-neon palette on homepage root: section heads +
  text-shadow glow, neon-tinted card borders, emerald→cyan button
  gradient, cyan hover state on links, subscribe boxes neon-bordered
- CSS-only — no layout changes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ryan Salazar
2026-05-21 23:58:12 +00:00
parent 5eaf64925f
commit aee67ef9f6
6 changed files with 490 additions and 1 deletions

View File

@@ -0,0 +1,46 @@
import { NextResponse } from 'next/server';
import { createClient } from '@supabase/supabase-js';
export const runtime = 'nodejs';
export const revalidate = 300; // 5 minutes
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const SUPABASE_ANON = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
export async function GET() {
const sb = createClient(SUPABASE_URL, SUPABASE_ANON, {
db: { schema: 'bb' },
auth: { persistSession: false },
});
const cutoff = new Date(Date.now() - 7 * 86400000).toISOString();
const { data, error } = await sb
.from('native_articles')
.select('id, slug, title, category, published_at')
.eq('status', 'published')
.gte('published_at', cutoff)
.order('published_at', { ascending: false })
.limit(30);
if (error) {
return NextResponse.json({ error: error.message, items: [] }, { status: 500 });
}
const items = (data || []).map((a) => ({
id: a.id,
href: `/articles/${a.slug}`,
title: a.title,
category: a.category || null,
published_at: a.published_at,
}));
return NextResponse.json(
{ items },
{
headers: {
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600',
},
}
);
}

View File

@@ -0,0 +1,90 @@
import { NextResponse } from 'next/server';
import { createClient } from '@supabase/supabase-js';
export const runtime = 'nodejs';
export const revalidate = 300; // 5 minutes
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const SUPABASE_ANON = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
interface TickerItem {
id: string;
href: string;
title: string;
kind: 'thread' | 'reply';
author_name: string | null;
created_at: string;
}
export async function GET() {
const sb = createClient(SUPABASE_URL, SUPABASE_ANON, {
db: { schema: 'bb' },
auth: { persistSession: false },
});
// Newest threads + newest replies, interleaved by timestamp.
const [threadsRes, repliesRes] = await Promise.all([
sb
.from('forum_threads')
.select('id, title, author_name, created_at')
.order('created_at', { ascending: false })
.limit(20),
sb
.from('forum_replies')
.select('id, body, thread_id, author_name, created_at, forum_threads(title)')
.order('created_at', { ascending: false })
.limit(20),
]);
if (threadsRes.error && repliesRes.error) {
return NextResponse.json(
{ error: threadsRes.error?.message || repliesRes.error?.message, items: [] },
{ status: 500 }
);
}
const items: TickerItem[] = [];
for (const t of threadsRes.data || []) {
items.push({
id: t.id,
href: `/forum/thread/${t.id}`,
title: t.title,
kind: 'thread',
author_name: t.author_name,
created_at: t.created_at,
});
}
for (const r of (repliesRes.data || []) as Array<{
id: string;
body: string;
thread_id: string;
author_name: string | null;
created_at: string;
forum_threads?: { title: string } | { title: string }[] | null;
}>) {
const parent =
Array.isArray(r.forum_threads) ? r.forum_threads[0] : r.forum_threads;
const parentTitle = parent?.title || 'forum reply';
items.push({
id: r.id,
href: `/forum/thread/${r.thread_id}`,
title: `Re: ${parentTitle}`,
kind: 'reply',
author_name: r.author_name,
created_at: r.created_at,
});
}
items.sort((a, b) => (a.created_at < b.created_at ? 1 : -1));
return NextResponse.json(
{ items: items.slice(0, 30) },
{
headers: {
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600',
},
}
);
}

View File

@@ -130,7 +130,7 @@ function FeedSkeleton() {
export default function HomePage() { export default function HomePage() {
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background bb-neon">
{/* Sticky top bar + nav */} {/* Sticky top bar + nav */}
<Header /> <Header />

View File

@@ -0,0 +1,228 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
interface TickerItem {
id: string;
href: string;
title: string;
category?: string | null;
author_name?: string | null;
kind?: 'thread' | 'reply';
}
const REFRESH_MS = 5 * 60 * 1000;
async function fetchItems(url: string): Promise<TickerItem[]> {
try {
const res = await fetch(url, { cache: 'no-store' });
if (!res.ok) return [];
const d = await res.json();
return Array.isArray(d.items) ? d.items : [];
} catch {
return [];
}
}
function Row({
label,
labelColor,
glowColor,
durationSec,
items,
}: {
label: string;
labelColor: string;
glowColor: string;
durationSec: number;
items: TickerItem[];
}) {
// Duplicate the items so the strip loops seamlessly. Animation moves
// the inner track from 0 to -50% (the duplicated half), then snaps
// back to 0 — invisible thanks to the duplication.
const safe = items.length > 0 ? items : [{ id: 'empty', href: '#', title: '—' }];
const looped = [...safe, ...safe];
return (
<div
className="bbt-row"
style={{
// height + bg/border baked into class; per-row dynamic values here
['--bbt-duration' as any]: `${durationSec}s`,
['--bbt-label-color' as any]: labelColor,
['--bbt-glow-color' as any]: glowColor,
}}
>
<span className="bbt-label" aria-hidden="true">
{label}
</span>
<div className="bbt-track-wrapper">
<div className="bbt-track">
{looped.map((item, idx) => (
<Link
key={`${item.id}-${idx}`}
href={item.href}
className="bbt-item"
prefetch={false}
>
{item.category && (
<span className="bbt-cat">{item.category}</span>
)}
<span className="bbt-title">{item.title}</span>
{item.author_name && (
<span className="bbt-meta">· {item.author_name}</span>
)}
<span className="bbt-sep" aria-hidden="true"></span>
</Link>
))}
</div>
</div>
</div>
);
}
export default function DualSpeedTicker() {
const [beat, setBeat] = useState<TickerItem[]>([]);
const [forum, setForum] = useState<TickerItem[]>([]);
useEffect(() => {
let cancelled = false;
const tick = async () => {
const [b, f] = await Promise.all([
fetchItems('/api/ticker/beat-news'),
fetchItems('/api/ticker/forum-posts'),
]);
if (!cancelled) {
setBeat(b);
setForum(f);
}
};
tick();
const handle = setInterval(tick, REFRESH_MS);
return () => {
cancelled = true;
clearInterval(handle);
};
}, []);
return (
<aside className="bbt-root" aria-label="News + forum live ticker">
<Row
label="BEAT"
labelColor="#00ff9f"
glowColor="rgba(0,255,159,0.55)"
durationSec={17}
items={beat}
/>
<Row
label="FORUM"
labelColor="#00d4ff"
glowColor="rgba(0,212,255,0.55)"
durationSec={40}
items={forum}
/>
{/* Scoped styles. Plain CSS keyframes = GPU-accelerated, no JS-frame
overhead, no flicker. */}
<style>{`
.bbt-root {
background: #000;
border-top: 1px solid rgba(0,255,159,0.18);
border-bottom: 1px solid rgba(0,212,255,0.18);
font-family: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;
color: #d1d5db;
}
.bbt-row {
position: relative;
display: flex;
align-items: center;
height: 28px;
overflow: hidden;
contain: layout paint;
}
.bbt-row + .bbt-row {
border-top: 1px solid rgba(255,255,255,0.05);
}
.bbt-label {
flex-shrink: 0;
height: 100%;
display: inline-flex;
align-items: center;
padding: 0 12px;
font-size: 10px;
letter-spacing: 0.22em;
font-weight: 700;
color: var(--bbt-label-color);
text-shadow: 0 0 6px var(--bbt-glow-color);
background: linear-gradient(90deg, rgba(0,0,0,1) 0%, rgba(0,0,0,0.85) 100%);
border-right: 1px solid var(--bbt-label-color);
box-shadow: inset 0 0 12px var(--bbt-glow-color);
z-index: 2;
}
.bbt-track-wrapper {
flex: 1;
position: relative;
overflow: hidden;
height: 100%;
}
.bbt-track {
display: inline-flex;
align-items: center;
height: 100%;
white-space: nowrap;
animation: bbt-scroll var(--bbt-duration) linear infinite;
will-change: transform;
}
.bbt-row:hover .bbt-track {
animation-play-state: paused;
}
.bbt-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 14px;
height: 100%;
font-size: 12px;
color: #d1d5db;
text-decoration: none;
transition: color 120ms ease;
}
.bbt-item:hover .bbt-title {
color: var(--bbt-label-color);
text-shadow: 0 0 6px var(--bbt-glow-color);
}
.bbt-cat {
font-size: 9px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--bbt-label-color);
opacity: 0.85;
}
.bbt-title {
font-weight: 500;
}
.bbt-meta {
color: #6b7280;
font-size: 11px;
}
.bbt-sep {
color: var(--bbt-label-color);
opacity: 0.4;
margin-left: 10px;
}
@keyframes bbt-scroll {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
@media (prefers-reduced-motion: reduce) {
.bbt-track { animation-duration: 0s; }
}
@media (max-width: 640px) {
.bbt-label { font-size: 9px; padding: 0 8px; }
.bbt-item { font-size: 11px; padding: 0 10px; }
}
`}</style>
</aside>
);
}

View File

@@ -18,6 +18,7 @@ import LanguageSwitcher from "@/components/LanguageSwitcher";
import AdImage from "@/components/AdImage"; import AdImage from "@/components/AdImage";
import EventsDropdown from "@/components/EventsDropdown"; import EventsDropdown from "@/components/EventsDropdown";
import AboutDropdown from "@/components/AboutDropdown"; import AboutDropdown from "@/components/AboutDropdown";
import DualSpeedTicker from "@/components/DualSpeedTicker";
import { ADS_728X90, shuffle } from "@/lib/ads"; import { ADS_728X90, shuffle } from "@/lib/ads";
interface NavItem { interface NavItem {
@@ -644,6 +645,7 @@ export default function Header() {
</div> </div>
</div> </div>
</div> </div>
<DualSpeedTicker />
</> </>
); );
} }

View File

@@ -1364,3 +1364,126 @@ h1, h2, h3 {
color: #ffffff; color: #ffffff;
font-weight: 600; font-weight: 600;
} }
/* ─── Neon homepage palette ─────────────────────────────────────────
Scoped to the homepage via `.bb-neon` on the root <div>. CSS-only
recolor: maps the existing blue accents (#3b82f6 / #60a5fa) to the
ticker's emerald + cyan, with subtle glows. No layout changes. */
.bb-neon {
--color-accent: #00ff9f;
--color-accent-dark: #00d4ff;
--color-accent-muted: rgba(0, 255, 159, 0.18);
--color-text-info: #00d4ff;
--color-text-info-strong: #00ff9f;
}
/* Section headings — emerald with subtle glow */
.bb-neon h2,
.bb-neon h3.font-heading,
.bb-neon .font-heading.section-title {
color: #00ff9f;
text-shadow: 0 0 6px rgba(0, 255, 159, 0.35);
letter-spacing: -0.01em;
}
/* Existing blue text → cyan */
.bb-neon .text-\[\#3b82f6\] {
color: #00d4ff !important;
text-shadow: 0 0 4px rgba(0, 212, 255, 0.25);
}
.bb-neon .text-\[\#60a5fa\] {
color: #00d4ff !important;
}
.bb-neon a:not(.bbt-item):not(.bbt-label):hover {
color: #00d4ff;
text-shadow: 0 0 4px rgba(0, 212, 255, 0.35);
}
/* Borders previously blue → neon-tinted, subtle */
.bb-neon .border-\[\#3b82f6\],
.bb-neon .border-\[\#3b82f6\\/30\],
.bb-neon .border-\[\#1e3a5f\] {
border-color: rgba(0, 255, 159, 0.35) !important;
}
/* Card border softening to neon */
.bb-neon .border-\[\#252525\],
.bb-neon .border-\[\#2a2a2a\] {
border-color: rgba(0, 255, 159, 0.14) !important;
}
/* Hover state on cards / clickable wrappers — cyan border + faint glow */
.bb-neon .border-\[\#252525\]:hover,
.bb-neon .border-\[\#2a2a2a\]:hover,
.bb-neon a:hover > .border-\[\#252525\],
.bb-neon a:hover > .border-\[\#2a2a2a\] {
border-color: rgba(0, 212, 255, 0.55) !important;
box-shadow: 0 0 14px rgba(0, 212, 255, 0.18);
}
/* Buttons previously blue → emerald→cyan gradient with black text */
.bb-neon .bg-\[\#3b82f6\],
.bb-neon button.bg-\[\#3b82f6\],
.bb-neon a.bg-\[\#3b82f6\] {
background: linear-gradient(90deg, #00ff9f 0%, #00d4ff 100%) !important;
color: #000 !important;
box-shadow: 0 0 14px rgba(0, 212, 255, 0.30);
border: none;
}
.bb-neon .bg-\[\#3b82f6\]:hover,
.bb-neon .hover\:bg-\[\#2563eb\]:hover,
.bb-neon button.bg-\[\#3b82f6\]:hover,
.bb-neon a.bg-\[\#3b82f6\]:hover {
background: linear-gradient(90deg, #14ffb0 0%, #4ee0ff 100%) !important;
box-shadow: 0 0 22px rgba(0, 255, 159, 0.45);
}
/* Category pills / tags / "AI" badges — cyan */
.bb-neon .bg-\[\#3b82f6\]\/20 {
background: rgba(0, 212, 255, 0.12) !important;
border-color: rgba(0, 212, 255, 0.35) !important;
}
/* Subscribe / newsletter accent boxes */
.bb-neon .bg-\[\#1a2535\],
.bb-neon .bg-\[\#0d1f35\] {
background-color: #050a0a !important;
border: 1px solid rgba(0, 255, 159, 0.25);
box-shadow: 0 0 18px rgba(0, 255, 159, 0.15);
}
/* Featured / hero container — neon outline + soft inner glow */
.bb-neon .featured-bento,
.bb-neon [data-featured-bento],
.bb-neon section[aria-label*="Featured"],
.bb-neon section[aria-label*="featured"] {
position: relative;
}
/* Skeletons keep their dim look — don't recolor */
.bb-neon .skeleton {
background: #0d0d0d;
}
/* Divider lines */
.bb-neon hr,
.bb-neon .h-px.bg-\[\#2a2a2a\],
.bb-neon .bg-\[\#222\] {
background-color: rgba(0, 255, 159, 0.15) !important;
}
/* Body / paragraph copy: lean toward spec's #d1d5db where currently muted */
.bb-neon .text-\[\#e8e8e8\],
.bb-neon .text-\[\#e0e0e0\] {
color: #d1d5db !important;
}
/* Focus rings on interactive elements */
.bb-neon .focus\:ring-\[\#3b82f6\]:focus,
.bb-neon .focus-visible\:ring-\[\#3b82f6\]:focus-visible {
--tw-ring-color: rgba(0, 212, 255, 0.6) !important;
}
.bb-neon .focus\:border-\[\#3b82f6\]:focus,
.bb-neon .focus-visible\:border-\[\#3b82f6\]:focus-visible {
border-color: #00d4ff !important;
}