- Standardize "NAB Show" / "2026 NAB Show" across badges, filter chips, and headings; never bare "NAB" in user-facing copy. - /manufacturers/[slug]: normalize show name at render, strip randomstring suffix on booth, hide booth unless the show is within 90 days, filter single-letter junk out of category display. - News filter "NAB Show" chip loose-matches any nab-tagged article so legacy "NAB 2026" tags still surface. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
699 lines
36 KiB
TypeScript
699 lines
36 KiB
TypeScript
"use client";
|
|
import React, { useState, useEffect, useRef } from "react";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import AppImage from "@/components/ui/AppImage";
|
|
import {
|
|
LinkedInIcon,
|
|
InstagramIcon,
|
|
FacebookIcon,
|
|
RssIcon,
|
|
MenuIcon,
|
|
CloseIcon,
|
|
SearchIcon,
|
|
} from "@/components/ui/Icons";
|
|
import { createClient } from "@/lib/supabase/client";
|
|
import NotificationCenter from "@/components/NotificationCenter";
|
|
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 {
|
|
label: string;
|
|
href: string;
|
|
dropdown?: { label: string; href: string }[];
|
|
}
|
|
|
|
const navLinks: NavItem[] = [
|
|
{ label: "HOME", href: "/" },
|
|
{ label: "SHOW COVERAGE", href: "/show-coverage" },
|
|
{ label: "DIRECTORY", href: "/manufacturers" },
|
|
{ label: "FORUM", href: "/forum" },
|
|
];
|
|
|
|
export default function Header() {
|
|
const router = useRouter();
|
|
const [scrolled, setScrolled] = useState(false);
|
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [searchFocused, setSearchFocused] = useState(false);
|
|
const [suggestions, setSuggestions] = useState<
|
|
Array<{
|
|
slug: string;
|
|
title: string;
|
|
category?: string;
|
|
date?: string | null;
|
|
author_name?: string | null;
|
|
snippet?: { text: string; matchStart: number; matchEnd: number } | null;
|
|
matched_in?: "title" | "excerpt" | "content" | "author" | null;
|
|
href: string;
|
|
}>
|
|
>([]);
|
|
const [suggestedCompanies, setSuggestedCompanies] = useState<
|
|
Array<{
|
|
slug: string;
|
|
name: string;
|
|
logoUrl: string | null;
|
|
exhibitsNab: boolean;
|
|
exhibitsIbc: boolean;
|
|
isSponsor: boolean;
|
|
href: string;
|
|
}>
|
|
>([]);
|
|
const [suggestOpen, setSuggestOpen] = useState(false);
|
|
|
|
const submitSearch = () => {
|
|
const q = searchQuery.trim();
|
|
if (!q) return;
|
|
setMobileOpen(false);
|
|
setSuggestOpen(false);
|
|
router.push(`/search?q=${encodeURIComponent(q)}`);
|
|
};
|
|
|
|
// Autocomplete: debounced fetch to /api/search/suggest while the user types.
|
|
useEffect(() => {
|
|
const q = searchQuery.trim();
|
|
if (q.length < 2) {
|
|
setSuggestions([]);
|
|
return;
|
|
}
|
|
const handle = setTimeout(() => {
|
|
fetch(`/api/search/suggest?q=${encodeURIComponent(q)}&limit=8`, { cache: "no-store" })
|
|
.then((r) => r.ok ? r.json() : { items: [], companies: [] })
|
|
.then((d) => {
|
|
setSuggestions(d.items || []);
|
|
setSuggestedCompanies(d.companies || []);
|
|
setSuggestOpen(true);
|
|
})
|
|
.catch(() => { setSuggestions([]); setSuggestedCompanies([]); });
|
|
}, 150);
|
|
return () => clearTimeout(handle);
|
|
}, [searchQuery]);
|
|
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
|
const [currentUserEmail, setCurrentUserEmail] = useState<string | null>(null);
|
|
const [isAdmin, setIsAdmin] = useState(false);
|
|
const [subEmail, setSubEmail] = useState("");
|
|
const [subStatus, setSubStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
|
|
const [subMessage, setSubMessage] = useState("");
|
|
const searchRef = useRef<HTMLInputElement>(null);
|
|
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
|
const hamburgerRef = useRef<HTMLButtonElement>(null);
|
|
const navLinksRef = useRef<(HTMLAnchorElement | null)[]>([]);
|
|
|
|
useEffect(() => {
|
|
const handler = () => setScrolled(window.scrollY > 60);
|
|
window.addEventListener("scroll", handler, { passive: true });
|
|
return () => window.removeEventListener("scroll", handler);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const supabase = createClient();
|
|
const ADMIN_ROLES = new Set([
|
|
"administrator", "admin", "super_admin", "editor", "support",
|
|
]);
|
|
|
|
const refreshAdmin = async (userId: string | null) => {
|
|
if (!userId) { setIsAdmin(false); return; }
|
|
const { data } = await supabase
|
|
.from("user_profiles")
|
|
.select("role")
|
|
.eq("id", userId)
|
|
.maybeSingle();
|
|
setIsAdmin(!!data?.role && ADMIN_ROLES.has(data.role));
|
|
};
|
|
|
|
supabase.auth.getUser().then(({ data: { user } }) => {
|
|
setCurrentUserId(user?.id || null);
|
|
setCurrentUserEmail(user?.email || null);
|
|
refreshAdmin(user?.id || null);
|
|
});
|
|
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
|
|
setCurrentUserId(session?.user?.id || null);
|
|
setCurrentUserEmail(session?.user?.email || null);
|
|
refreshAdmin(session?.user?.id || null);
|
|
});
|
|
return () => subscription.unsubscribe();
|
|
}, []);
|
|
|
|
// Global keyboard shortcuts
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
const tag = document.activeElement?.tagName;
|
|
const isInput = tag === "INPUT" || tag === "TEXTAREA";
|
|
|
|
// "/" to focus search
|
|
if (e.key === "/" && !isInput) {
|
|
e.preventDefault();
|
|
searchRef.current?.focus();
|
|
}
|
|
|
|
// Cmd/Ctrl+Shift+A — open AI Console
|
|
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === "A") {
|
|
e.preventDefault();
|
|
window.location.href = "/dashboard/ai-console";
|
|
}
|
|
|
|
// Escape: close mobile menu or clear search
|
|
if (e.key === "Escape") {
|
|
if (mobileOpen) {
|
|
setMobileOpen(false);
|
|
hamburgerRef.current?.focus();
|
|
} else if (searchFocused) {
|
|
setSearchQuery("");
|
|
searchRef.current?.blur();
|
|
}
|
|
}
|
|
};
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [searchFocused, mobileOpen]);
|
|
|
|
// Arrow key navigation for desktop nav links
|
|
const handleNavKeyDown = (e: React.KeyboardEvent, index: number, refs: React.MutableRefObject<(HTMLAnchorElement | null)[]>) => {
|
|
const items = refs.current.filter(Boolean);
|
|
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
const next = items[(index + 1) % items.length];
|
|
next?.focus();
|
|
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
const prev = items[(index - 1 + items.length) % items.length];
|
|
prev?.focus();
|
|
} else if (e.key === "Home") {
|
|
e.preventDefault();
|
|
items[0]?.focus();
|
|
} else if (e.key === "End") {
|
|
e.preventDefault();
|
|
items[items.length - 1]?.focus();
|
|
}
|
|
};
|
|
|
|
// Trap focus inside mobile menu when open
|
|
useEffect(() => {
|
|
if (!mobileOpen) return;
|
|
const menu = mobileMenuRef.current;
|
|
if (!menu) return;
|
|
const focusable = menu.querySelectorAll<HTMLElement>(
|
|
'a[href], button, input, [tabindex]:not([tabIndex="-1"])'
|
|
);
|
|
const first = focusable[0];
|
|
const last = focusable[focusable.length - 1];
|
|
first?.focus();
|
|
|
|
const trap = (e: KeyboardEvent) => {
|
|
if (e.key !== "Tab") return;
|
|
if (e.shiftKey) {
|
|
if (document.activeElement === first) {
|
|
e.preventDefault();
|
|
last?.focus();
|
|
}
|
|
} else {
|
|
if (document.activeElement === last) {
|
|
e.preventDefault();
|
|
first?.focus();
|
|
}
|
|
}
|
|
};
|
|
menu.addEventListener("keydown", trap);
|
|
return () => menu.removeEventListener("keydown", trap);
|
|
}, [mobileOpen]);
|
|
|
|
const handleSubscribe = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!subEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(subEmail)) {
|
|
setSubStatus("error");
|
|
setSubMessage("Enter a valid email");
|
|
return;
|
|
}
|
|
setSubStatus("loading");
|
|
try {
|
|
const res = await fetch("/api/newsletter/subscribe", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email: subEmail, topics: [] }),
|
|
});
|
|
if (res.ok) {
|
|
setSubStatus("success");
|
|
setSubMessage("You're subscribed!");
|
|
setSubEmail("");
|
|
setTimeout(() => setSubStatus("idle"), 4000);
|
|
} else {
|
|
const data = await res.json();
|
|
setSubStatus("error");
|
|
setSubMessage(data?.error || "Failed. Try again.");
|
|
setTimeout(() => setSubStatus("idle"), 3000);
|
|
}
|
|
} catch {
|
|
setSubStatus("error");
|
|
setSubMessage("Network error. Try again.");
|
|
setTimeout(() => setSubStatus("idle"), 3000);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="relative">
|
|
{/* MAIN NAV */}
|
|
<div className={`sticky top-0 z-50 transition-shadow duration-200 ${scrolled ? "shadow-nav" : ""}`}>
|
|
<nav className="bg-[#111111] border-b border-[#252525]" role="navigation" aria-label="Main navigation">
|
|
<div className="max-w-container mx-auto px-4">
|
|
<div className="flex items-center justify-between h-[96px] gap-4">
|
|
{/* Logo */}
|
|
<Link href="/home-page" className="flex-shrink-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6] focus-visible:ring-offset-1 focus-visible:ring-offset-[#111]">
|
|
<AppImage
|
|
src="/assets/images/logo.png"
|
|
alt="Broadcast Beat logo"
|
|
width={200}
|
|
height={26}
|
|
priority={true}
|
|
className="h-7 w-auto object-contain"
|
|
/>
|
|
</Link>
|
|
|
|
{/* Header 728x90 banner — centered between logo and right side */}
|
|
{ADS_728X90.length > 0 && (
|
|
<div className="hidden md:flex flex-1 justify-center min-w-0 overflow-hidden">
|
|
<AdImage ad={shuffle(ADS_728X90)[0]} priority page="header" />
|
|
</div>
|
|
)}
|
|
|
|
<div className="hidden lg:flex items-center gap-3">
|
|
<Link
|
|
href="/reading-list"
|
|
aria-label="Reading List"
|
|
className="flex items-center text-[#888] hover:text-[#3b82f6] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] focus-visible:ring-offset-1 focus-visible:ring-offset-[#111]">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
|
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
|
</svg>
|
|
</Link>
|
|
<NotificationCenter />
|
|
</div>
|
|
|
|
{/* Search moved to the Browse bar below the leaderboard */}
|
|
|
|
{/* Mobile Hamburger */}
|
|
<button
|
|
ref={hamburgerRef}
|
|
className="lg:hidden p-2 text-[#cccccc] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6]"
|
|
onClick={() => setMobileOpen(!mobileOpen)}
|
|
aria-label={mobileOpen ? "Close menu" : "Open menu"}
|
|
aria-expanded={mobileOpen}
|
|
aria-controls="mobile-menu">
|
|
{mobileOpen ? <CloseIcon size={22} /> : <MenuIcon size={22} />}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Mobile Menu */}
|
|
{mobileOpen && (
|
|
<div
|
|
id="mobile-menu"
|
|
ref={mobileMenuRef}
|
|
className="lg:hidden mobile-menu-open border-t border-[#252525] pb-4 bg-[#111111]"
|
|
role="dialog"
|
|
aria-label="Mobile navigation menu">
|
|
<div className="flex flex-col gap-0 pt-2">
|
|
{navLinks?.map((link, i) => (
|
|
<Link
|
|
key={link?.label}
|
|
href={link?.href}
|
|
ref={(el) => { navLinksRef.current[i] = el; }}
|
|
className="px-2 py-2.5 text-sm font-body font-bold uppercase tracking-wide text-[#cccccc] hover:text-accent hover:bg-[#1a1a1a] border-b border-[#222] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[#3b82f6]"
|
|
onClick={() => setMobileOpen(false)}>
|
|
{link?.label}
|
|
</Link>
|
|
))}
|
|
{/* Reading List mobile link */}
|
|
<Link
|
|
href="/reading-list"
|
|
className="px-2 py-2.5 text-sm font-body font-bold uppercase tracking-wide text-[#cccccc] hover:text-accent hover:bg-[#1a1a1a] border-b border-[#222] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[#3b82f6] flex items-center gap-2"
|
|
onClick={() => setMobileOpen(false)}>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
|
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
|
</svg>
|
|
Reading List
|
|
</Link>
|
|
{/* Sign In / Register removed — see desktop bar comment. */}
|
|
{currentUserId && (
|
|
<Link
|
|
href="/account"
|
|
className="px-2 py-2.5 text-sm font-body font-bold uppercase tracking-wide text-[#cccccc] hover:text-[#3b82f6] hover:bg-[#1a1a1a] border-b border-[#222] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[#3b82f6] flex items-center gap-2"
|
|
onClick={() => setMobileOpen(false)}>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /><circle cx="12" cy="7" r="4" />
|
|
</svg>
|
|
My Profile
|
|
</Link>
|
|
)}
|
|
{/* WP Import mobile link */}
|
|
{isAdmin && (
|
|
<Link
|
|
href="/admin/import"
|
|
className="px-2 py-2.5 text-sm font-body font-bold uppercase tracking-wide text-[#cccccc] hover:text-[#3b82f6] hover:bg-[#1a1a1a] border-b border-[#222] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[#3b82f6] flex items-center gap-2"
|
|
onClick={() => setMobileOpen(false)}>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" />
|
|
</svg>
|
|
WP Import
|
|
</Link>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col gap-0 border-t border-[#252525] mt-1">
|
|
{navLinks
|
|
.filter((n) => n.dropdown && n.dropdown.length > 0)
|
|
.map((section) => (
|
|
<div key={section.label} className="border-b border-[#222]">
|
|
<div className="px-2 py-2 text-[10px] font-body font-bold text-[#3b82f6] uppercase tracking-widest bg-[#0d0d0d]">
|
|
{section.label}
|
|
</div>
|
|
{section.dropdown!.map((link) => (
|
|
<Link
|
|
key={link.href + link.label}
|
|
href={link.href}
|
|
className="px-4 py-2 text-xs font-body text-[#888] hover:text-accent hover:bg-[#1a1a1a] block transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[#3b82f6]"
|
|
onClick={() => setMobileOpen(false)}>
|
|
{link.label}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="mt-3 px-2">
|
|
<div className="relative">
|
|
<input
|
|
type="search"
|
|
placeholder="Search articles..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e?.target?.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
submitSearch();
|
|
}
|
|
}}
|
|
className="search-input search-input-enhanced w-full pr-8"
|
|
aria-label="Search articles mobile"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={submitSearch}
|
|
aria-label="Submit search"
|
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[#666] hover:text-[#3b82f6] transition-colors cursor-pointer"
|
|
>
|
|
<SearchIcon size={13} strokeWidth={1.75} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</nav>
|
|
</div>
|
|
|
|
{/* SECONDARY BAR — was the TOP BAR above the main nav until Ryan asked
|
|
it move under the main nav on 2026-05-27. Lives outside the sticky
|
|
wrapper so it scrolls off, with a hairline separator below to keep
|
|
it visually distinct from the browse bar. */}
|
|
<div className="max-w-container mx-auto px-4">
|
|
<div className="bg-[#1a2535] border-b border-[#2a3a50] h-8 flex items-center justify-between px-4">
|
|
<p className="text-[#999999] text-xs font-body hidden sm:block">
|
|
News & Intelligence for Broadcast, Post-Production & Streaming Technology
|
|
</p>
|
|
<div className="flex items-center gap-3 ml-auto">
|
|
<a href="https://linkedin.com/company/broadcastbeat" target="_blank" rel="noopener noreferrer" aria-label="Broadcast Beat on LinkedIn"
|
|
className="text-[#999] hover:text-[#3b82f6] focus:text-[#3b82f6] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors social-icon">
|
|
<LinkedInIcon size={14} />
|
|
</a>
|
|
<a href="https://instagram.com/broadcastbeat" target="_blank" rel="noopener noreferrer" aria-label="Broadcast Beat on Instagram"
|
|
className="text-[#999] hover:text-[#3b82f6] focus:text-[#3b82f6] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors social-icon">
|
|
<InstagramIcon size={14} />
|
|
</a>
|
|
<a href="https://facebook.com/broadcastbeat" target="_blank" rel="noopener noreferrer" aria-label="Broadcast Beat on Facebook"
|
|
className="text-[#999] hover:text-[#3b82f6] focus:text-[#3b82f6] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors social-icon">
|
|
<FacebookIcon size={14} />
|
|
</a>
|
|
<a href="/rss" aria-label="Broadcast Beat RSS Feed" className="text-[#999] hover:text-[#3b82f6] focus:text-[#3b82f6] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors social-icon">
|
|
<RssIcon size={14} />
|
|
</a>
|
|
<div className="w-px h-3.5 bg-[#333] mx-1" />
|
|
{/* Language Switcher — Section 18B */}
|
|
<LanguageSwitcher compact />
|
|
<div className="w-px h-3.5 bg-[#333] mx-1" />
|
|
{currentUserId ? (
|
|
<>
|
|
<Link href="/account" className="text-[#999] hover:text-[#3b82f6] focus:text-[#3b82f6] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors text-xs font-body font-bold uppercase tracking-wider">
|
|
My Profile
|
|
</Link>
|
|
<button
|
|
type="button"
|
|
onClick={async () => {
|
|
const supabase = createClient();
|
|
await supabase.auth.signOut();
|
|
window.location.href = '/forum';
|
|
}}
|
|
className="text-[#999] hover:text-[#3b82f6] focus:text-[#3b82f6] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors text-xs font-body font-bold uppercase tracking-wider"
|
|
>
|
|
Log Out
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Link href="/forum/login" className="text-[#999] hover:text-[#3b82f6] focus:text-[#3b82f6] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors text-xs font-body font-bold uppercase tracking-wider">
|
|
Sign In
|
|
</Link>
|
|
<Link href="/forum/register" className="text-[#3b82f6] hover:text-blue-300 focus:text-blue-300 focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors text-xs font-body font-bold uppercase tracking-wider">
|
|
Join
|
|
</Link>
|
|
</>
|
|
)}
|
|
{isAdmin && (
|
|
<>
|
|
<Link href="/admin/import" className="text-[#999] hover:text-[#3b82f6] focus:text-[#3b82f6] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors text-xs font-body font-bold uppercase tracking-wider">
|
|
WP Import
|
|
</Link>
|
|
<Link href="/admin/articles" className="text-[#999] hover:text-[#3b82f6] focus:text-[#3b82f6] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors text-xs font-body font-bold uppercase tracking-wider">
|
|
Articles
|
|
</Link>
|
|
<Link href="/dashboard/editorial" className="text-[#999] hover:text-[#3b82f6] focus:text-[#3b82f6] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors text-xs font-body font-bold uppercase tracking-wider">
|
|
Editorial
|
|
</Link>
|
|
<Link href="/admin/newsletter" className="text-[#999] hover:text-[#3b82f6] focus:text-[#3b82f6] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors text-xs font-body font-bold uppercase tracking-wider">
|
|
Newsletter
|
|
</Link>
|
|
|
|
{/* TOOLS dropdown — admin tools consolidated */}
|
|
<div className="relative group/tools">
|
|
<button
|
|
type="button"
|
|
aria-haspopup="true"
|
|
className="text-[#999] hover:text-[#3b82f6] focus:text-[#3b82f6] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors text-xs font-body font-bold uppercase tracking-wider inline-flex items-center gap-1">
|
|
Tools
|
|
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
|
|
<polyline points="6 9 12 15 18 9" />
|
|
</svg>
|
|
</button>
|
|
<div
|
|
role="menu"
|
|
aria-label="Admin tools"
|
|
className="absolute right-0 top-full min-w-[200px] bg-[#0d0d0d] border border-[#252525] shadow-xl rounded-sm py-1 opacity-0 invisible translate-y-1 group-hover/tools:opacity-100 group-hover/tools:visible group-hover/tools:translate-y-0 group-focus-within/tools:opacity-100 group-focus-within/tools:visible group-focus-within/tools:translate-y-0 transition-all duration-150 z-50">
|
|
<Link href="/admin/users" role="menuitem" className="block px-4 py-2 text-xs font-body text-[#bbb] hover:bg-[#1a1a1a] hover:text-[#3b82f6] focus:outline-none focus-visible:bg-[#1a1a1a] focus-visible:text-[#3b82f6] uppercase tracking-wider transition-colors">Users</Link>
|
|
<Link href="/admin/analytics" role="menuitem" className="block px-4 py-2 text-xs font-body text-[#bbb] hover:bg-[#1a1a1a] hover:text-[#3b82f6] focus:outline-none focus-visible:bg-[#1a1a1a] focus-visible:text-[#3b82f6] uppercase tracking-wider transition-colors">Analytics</Link>
|
|
<Link href="/admin/audit-log" role="menuitem" className="block px-4 py-2 text-xs font-body text-[#bbb] hover:bg-[#1a1a1a] hover:text-[#3b82f6] focus:outline-none focus-visible:bg-[#1a1a1a] focus-visible:text-[#3b82f6] uppercase tracking-wider transition-colors">Audit Log</Link>
|
|
<Link href="/admin/notifications" role="menuitem" className="block px-4 py-2 text-xs font-body text-[#bbb] hover:bg-[#1a1a1a] hover:text-[#3b82f6] focus:outline-none focus-visible:bg-[#1a1a1a] focus-visible:text-[#3b82f6] uppercase tracking-wider transition-colors">Notifications</Link>
|
|
<Link href="/admin/forum-seed" role="menuitem" className="block px-4 py-2 text-xs font-body text-[#bbb] hover:bg-[#1a1a1a] hover:text-[#3b82f6] focus:outline-none focus-visible:bg-[#1a1a1a] focus-visible:text-[#3b82f6] uppercase tracking-wider transition-colors">Forum Seed</Link>
|
|
<Link href="/admin/company-tracker" role="menuitem" className="block px-4 py-2 text-xs font-body text-[#bbb] hover:bg-[#1a1a1a] hover:text-[#3b82f6] focus:outline-none focus-visible:bg-[#1a1a1a] focus-visible:text-[#3b82f6] uppercase tracking-wider transition-colors">Company Tracker</Link>
|
|
<Link href="/dashboard/show-calendar" role="menuitem" className="block px-4 py-2 text-xs font-body text-[#bbb] hover:bg-[#1a1a1a] hover:text-[#3b82f6] focus:outline-none focus-visible:bg-[#1a1a1a] focus-visible:text-[#3b82f6] uppercase tracking-wider transition-colors">Show Calendar</Link>
|
|
<Link href="/dashboard/ai-console" role="menuitem" className="block px-4 py-2 text-xs font-body text-[#3b82f6] hover:bg-[#1a1a1a] hover:text-blue-300 focus:outline-none focus-visible:bg-[#1a1a1a] focus-visible:text-blue-300 uppercase tracking-wider transition-colors">AI Console</Link>
|
|
<Link href="/admin/banned-terms" role="menuitem" className="block px-4 py-2 text-xs font-body text-[#bbb] hover:bg-[#1a1a1a] hover:text-[#3b82f6] focus:outline-none focus-visible:bg-[#1a1a1a] focus-visible:text-[#3b82f6] uppercase tracking-wider transition-colors">Banned Terms</Link>
|
|
</div>
|
|
</div>
|
|
<Link href="/admin" className="text-[#3b82f6] hover:text-blue-300 focus:text-blue-300 focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors text-xs font-body font-bold uppercase tracking-wider">
|
|
Dashboard
|
|
</Link>
|
|
</>
|
|
)}
|
|
{/* Inline Email Subscription Form */}
|
|
{subStatus === "success" ? (
|
|
<span className="text-[#3b82f6] text-xs font-body font-bold whitespace-nowrap">{subMessage}</span>
|
|
) : (
|
|
<form onSubmit={handleSubscribe} className="flex items-center gap-1" aria-label="Newsletter subscription">
|
|
<input
|
|
type="email"
|
|
value={subEmail}
|
|
onChange={(e) => setSubEmail(e.target.value)}
|
|
placeholder="your@email.com"
|
|
aria-label="Email address for newsletter"
|
|
disabled={subStatus === "loading"}
|
|
className="h-5 px-2 text-[11px] bg-[#0d1520] border border-[#2a3a50] text-[#ccc] placeholder-[#555] rounded focus:outline-none focus:border-[#3b82f6] focus-visible:ring-1 focus-visible:ring-[#3b82f6] w-32 disabled:opacity-50 transition-colors"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={subStatus === "loading"}
|
|
className="h-5 px-2.5 text-[11px] font-body font-bold uppercase tracking-wider bg-[#3b82f6] hover:bg-[#2563eb] text-white rounded disabled:opacity-50 transition-colors whitespace-nowrap focus:outline-none focus-visible:ring-1 focus-visible:ring-white focus-visible:ring-offset-1 focus-visible:ring-offset-[#3b82f6]"
|
|
>
|
|
{subStatus === "loading" ? "..." : "Subscribe"}
|
|
</button>
|
|
{subStatus === "error" && (
|
|
<span className="text-red-400 text-[10px] whitespace-nowrap">{subMessage}</span>
|
|
)}
|
|
</form>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{/* Hairline accent separator — mirrors the divider next to "Staff Editorial" */}
|
|
<div className="h-px bg-[#2a2a2a]" />
|
|
</div>
|
|
|
|
<DualSpeedTicker />
|
|
|
|
{/* BROWSE BAR — search left, primary nav right */}
|
|
<div className="max-w-container mx-auto px-4 hidden md:block">
|
|
<div className="bb-browse-bar" role="navigation" aria-label="Browse sections">
|
|
<div className="bb-browse-row">
|
|
{/* Search (left) — with autocomplete dropdown */}
|
|
<div className="bb-browse-search-wrap">
|
|
<div className="bb-browse-search">
|
|
<SearchIcon size={14} strokeWidth={1.75} className="bb-browse-search-icon" aria-hidden="true" />
|
|
<input
|
|
ref={searchRef}
|
|
type="search"
|
|
placeholder="Search all content sitewide"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e?.target?.value)}
|
|
onFocus={() => { setSearchFocused(true); if (suggestions.length > 0) setSuggestOpen(true); }}
|
|
onBlur={() => { setSearchFocused(false); setTimeout(() => setSuggestOpen(false), 150); }}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
submitSearch();
|
|
}
|
|
if (e.key === "Escape") {
|
|
setSuggestOpen(false);
|
|
}
|
|
}}
|
|
aria-label="Search articles"
|
|
aria-autocomplete="list"
|
|
aria-expanded={suggestOpen}
|
|
aria-controls="bb-search-suggest"
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
type="button"
|
|
onClick={() => { setSearchQuery(""); setSuggestions([]); searchRef.current?.focus(); }}
|
|
aria-label="Clear search"
|
|
className="bb-browse-search-clear">
|
|
<CloseIcon size={11} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{suggestOpen && (suggestions.length > 0 || suggestedCompanies.length > 0) && (
|
|
<ul
|
|
id="bb-search-suggest"
|
|
role="listbox"
|
|
className="bb-browse-search-suggest">
|
|
{suggestedCompanies.length > 0 && (
|
|
<>
|
|
<li className="px-3 pt-2 pb-1 text-[10px] uppercase tracking-widest text-[#666] font-bold border-b border-[#1a1a1a]">
|
|
Companies
|
|
</li>
|
|
{suggestedCompanies.map((c) => (
|
|
<li key={`co-${c.slug}`} role="option">
|
|
<Link
|
|
href={c.href}
|
|
data-company-slug={c.slug}
|
|
className="bb-browse-search-suggest-item flex items-center gap-2.5"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => { setSuggestOpen(false); setSearchQuery(""); }}>
|
|
{c.logoUrl ? (
|
|
/* eslint-disable-next-line @next/next/no-img-element */
|
|
<img src={c.logoUrl} alt="" className="w-7 h-7 rounded bg-white p-0.5 object-contain flex-shrink-0" loading="lazy" />
|
|
) : (
|
|
<div className="w-7 h-7 rounded bg-[#222] flex items-center justify-center text-[#666] font-bold text-xs flex-shrink-0">
|
|
{(c.name || "?").trim().charAt(0).toUpperCase()}
|
|
</div>
|
|
)}
|
|
<span className="flex-1 min-w-0 truncate font-semibold text-[#e0e0e0]">{c.name}</span>
|
|
{c.isSponsor && (
|
|
<span className="text-[9px] font-bold uppercase tracking-wider bg-emerald-500/15 text-emerald-300 px-1.5 py-0.5 rounded">Sponsor</span>
|
|
)}
|
|
{c.exhibitsNab && !c.isSponsor && (
|
|
<span className="text-[9px] font-bold uppercase tracking-wider bg-amber-500/15 text-amber-300 px-1.5 py-0.5 rounded">2026 NAB Show</span>
|
|
)}
|
|
</Link>
|
|
</li>
|
|
))}
|
|
{suggestions.length > 0 && (
|
|
<li className="px-3 pt-2 pb-1 text-[10px] uppercase tracking-widest text-[#666] font-bold border-y border-[#1a1a1a]">
|
|
Stories
|
|
</li>
|
|
)}
|
|
</>
|
|
)}
|
|
{suggestions.map((s) => {
|
|
const snippet = s.snippet;
|
|
const beforeMatch = snippet?.text.slice(0, snippet.matchStart) || "";
|
|
const matchText = snippet?.text.slice(snippet.matchStart, snippet.matchEnd) || "";
|
|
const afterMatch = snippet?.text.slice(snippet.matchEnd) || "";
|
|
return (
|
|
<li key={s.slug} role="option">
|
|
<Link
|
|
href={s.href}
|
|
className="bb-browse-search-suggest-item"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => { setSuggestOpen(false); setSearchQuery(""); }}>
|
|
<div className="bb-browse-search-suggest-row1">
|
|
<span className="bb-browse-search-suggest-title">{s.title}</span>
|
|
{s.category && <span className="bb-browse-search-suggest-cat">{s.category}</span>}
|
|
</div>
|
|
{snippet && s.matched_in !== "title" && (
|
|
<div className="bb-browse-search-suggest-snippet">
|
|
{beforeMatch}
|
|
<mark className="bb-browse-search-suggest-mark">{matchText}</mark>
|
|
{afterMatch}
|
|
</div>
|
|
)}
|
|
{(s.date || s.author_name) && (
|
|
<div className="bb-browse-search-suggest-meta">
|
|
{s.author_name && <span>{s.author_name}</span>}
|
|
{s.author_name && s.date && <span className="bb-dot">·</span>}
|
|
{s.date && <time>{s.date}</time>}
|
|
{s.matched_in === "author" && <span className="bb-browse-search-suggest-mark-tag">match in byline</span>}
|
|
{s.matched_in === "content" && <span className="bb-browse-search-suggest-mark-tag">match in article body</span>}
|
|
</div>
|
|
)}
|
|
</Link>
|
|
</li>
|
|
);
|
|
})}
|
|
<li role="option" className="bb-browse-search-suggest-all">
|
|
<button
|
|
type="button"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={submitSearch}>
|
|
See all results for "{searchQuery}" →
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
{/* Menu (right) */}
|
|
<nav className="bb-browse-menu" aria-label="Main sections">
|
|
{navLinks.map((link) => (
|
|
<Link
|
|
key={link.label}
|
|
href={link.href}
|
|
className="bb-browse-item">
|
|
{link.label}
|
|
</Link>
|
|
))}
|
|
<AboutDropdown />
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |