diff --git a/src/app/api/ticker/beat-news/route.ts b/src/app/api/ticker/beat-news/route.ts
new file mode 100644
index 0000000..3b1aafb
--- /dev/null
+++ b/src/app/api/ticker/beat-news/route.ts
@@ -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',
+ },
+ }
+ );
+}
diff --git a/src/app/api/ticker/forum-posts/route.ts b/src/app/api/ticker/forum-posts/route.ts
new file mode 100644
index 0000000..929b0a4
--- /dev/null
+++ b/src/app/api/ticker/forum-posts/route.ts
@@ -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',
+ },
+ }
+ );
+}
diff --git a/src/app/home-page/page.tsx b/src/app/home-page/page.tsx
index a56ad63..70920a7 100644
--- a/src/app/home-page/page.tsx
+++ b/src/app/home-page/page.tsx
@@ -130,7 +130,7 @@ function FeedSkeleton() {
export default function HomePage() {
return (
-
+
{/* Sticky top bar + nav */}
diff --git a/src/components/DualSpeedTicker.tsx b/src/components/DualSpeedTicker.tsx
new file mode 100644
index 0000000..b80ef4a
--- /dev/null
+++ b/src/components/DualSpeedTicker.tsx
@@ -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
{
+ 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 (
+
+
+ {label}
+
+
+
+ {looped.map((item, idx) => (
+
+ {item.category && (
+ {item.category}
+ )}
+ {item.title}
+ {item.author_name && (
+ · {item.author_name}
+ )}
+ •
+
+ ))}
+
+
+
+ );
+}
+
+export default function DualSpeedTicker() {
+ const [beat, setBeat] = useState([]);
+ const [forum, setForum] = useState([]);
+
+ 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 (
+
+ );
+}
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index 089708d..898af60 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -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() {
+
>
);
}
\ No newline at end of file
diff --git a/src/styles/tailwind.css b/src/styles/tailwind.css
index d5d00b2..3bcad95 100644
--- a/src/styles/tailwind.css
+++ b/src/styles/tailwind.css
@@ -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 . 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;
+}