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:
46
src/app/api/ticker/beat-news/route.ts
Normal file
46
src/app/api/ticker/beat-news/route.ts
Normal 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',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
90
src/app/api/ticker/forum-posts/route.ts
Normal file
90
src/app/api/ticker/forum-posts/route.ts
Normal 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',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -130,7 +130,7 @@ function FeedSkeleton() {
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="min-h-screen bg-background bb-neon">
|
||||
{/* Sticky top bar + nav */}
|
||||
<Header />
|
||||
|
||||
|
||||
228
src/components/DualSpeedTicker.tsx
Normal file
228
src/components/DualSpeedTicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||
import AdImage from "@/components/AdImage";
|
||||
import EventsDropdown from "@/components/EventsDropdown";
|
||||
import AboutDropdown from "@/components/AboutDropdown";
|
||||
import DualSpeedTicker from "@/components/DualSpeedTicker";
|
||||
import { ADS_728X90, shuffle } from "@/lib/ads";
|
||||
|
||||
interface NavItem {
|
||||
@@ -644,6 +645,7 @@ export default function Header() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DualSpeedTicker />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1364,3 +1364,126 @@ h1, h2, h3 {
|
||||
color: #ffffff;
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user