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 ( +
+ +
+
+ {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; +}