Initial release v1.0.0

This commit is contained in:
Kevin O'Neill
2025-12-25 18:58:06 -06:00
commit 021aec7a63
439 changed files with 116588 additions and 0 deletions
@@ -0,0 +1,273 @@
"use client";
import { useState } from "react";
import { useNotifications } from "@/hooks/useNotifications";
import { useActiveDownloads } from "@/hooks/useNotifications";
import { NotificationsTab } from "@/components/activity/NotificationsTab";
import { ActiveDownloadsTab } from "@/components/activity/ActiveDownloadsTab";
import { HistoryTab } from "@/components/activity/HistoryTab";
import {
Bell,
Download,
History,
ChevronLeft,
ChevronRight,
X,
} from "lucide-react";
import { cn } from "@/utils/cn";
import { useIsMobile, useIsTablet } from "@/hooks/useMediaQuery";
type ActivityTab = "notifications" | "active" | "history";
const TABS: { id: ActivityTab; label: string; icon: React.ElementType }[] = [
{ id: "notifications", label: "Notifications", icon: Bell },
{ id: "active", label: "Active", icon: Download },
{ id: "history", label: "History", icon: History },
];
interface ActivityPanelProps {
isOpen: boolean;
onToggle: () => void;
activeTab?: ActivityTab;
onTabChange?: (tab: ActivityTab) => void;
}
export function ActivityPanel({
isOpen,
onToggle,
activeTab,
onTabChange,
}: ActivityPanelProps) {
const [internalActiveTab, setInternalActiveTab] =
useState<ActivityTab>("notifications");
const resolvedActiveTab = activeTab ?? internalActiveTab;
const setResolvedActiveTab = onTabChange ?? setInternalActiveTab;
const { unreadCount } = useNotifications();
const { downloads: activeDownloads } = useActiveDownloads();
const isMobile = useIsMobile();
const isTablet = useIsTablet();
const isMobileOrTablet = isMobile || isTablet;
// Badge counts
const notificationBadge = unreadCount > 0 ? unreadCount : null;
const activeBadge =
activeDownloads.length > 0 ? activeDownloads.length : null;
const hasActivity = unreadCount > 0 || activeDownloads.length > 0;
// Mobile/Tablet: Full-screen overlay
if (isMobileOrTablet) {
if (!isOpen) return null;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/60 z-[100]"
onClick={onToggle}
/>
{/* Panel - slides in from right */}
<div
className="fixed inset-y-0 right-0 w-full max-w-md bg-[#0a0a0a] z-[101] flex flex-col"
style={{ paddingTop: "env(safe-area-inset-top)" }}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-4 border-b border-white/10">
<h2 className="text-lg font-semibold text-white">
Activity
</h2>
<button
onClick={onToggle}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
title="Close"
>
<X className="w-5 h-5 text-white/60" />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-white/10">
{TABS.map((tab) => {
const Icon = tab.icon;
const badge =
tab.id === "notifications"
? notificationBadge
: tab.id === "active"
? activeBadge
: null;
return (
<button
key={tab.id}
onClick={() => setResolvedActiveTab(tab.id)}
className={cn(
"flex-1 flex items-center justify-center gap-2 py-3 text-sm font-medium transition-colors relative",
resolvedActiveTab === tab.id
? "text-white border-b-2 border-[#f5c518]"
: "text-white/50 hover:text-white/70"
)}
>
<Icon className="w-4 h-4" />
<span>{tab.label}</span>
{badge && (
<span
className={cn(
"min-w-[18px] h-[18px] px-1 rounded-full text-xs font-bold flex items-center justify-center ml-1",
tab.id === "active"
? "bg-blue-500 text-white"
: "bg-[#f5c518] text-black"
)}
>
{badge > 99 ? "99+" : badge}
</span>
)}
</button>
);
})}
</div>
{/* Tab Content */}
<div className="flex-1 overflow-hidden">
{resolvedActiveTab === "notifications" && (
<NotificationsTab />
)}
{resolvedActiveTab === "active" && (
<ActiveDownloadsTab />
)}
{resolvedActiveTab === "history" && <HistoryTab />}
</div>
</div>
</>
);
}
// Desktop: Side panel
return (
<div
className={cn(
"shrink-0 h-full bg-[#0d0d0d] rounded-tl-lg rounded-bl-lg border-l border-white/5 flex flex-col z-10 transition-all duration-300 ease-out overflow-hidden relative",
isOpen ? "w-[400px]" : "w-12"
)}
>
{/* Collapsed state overlay */}
{!isOpen && (
<div
onClick={onToggle}
className="absolute inset-0 flex items-center justify-center cursor-pointer hover:bg-[#141414] transition-colors"
title="Open activity panel"
>
<ChevronLeft className="w-5 h-5 text-white/40" />
{/* Activity badge */}
{hasActivity && (
<span className="absolute top-4 right-3 w-2.5 h-2.5 rounded-full bg-[#ecb200]" />
)}
</div>
)}
{/* Expanded content - only visible when open */}
<div
className={cn(
"flex flex-col h-full transition-opacity duration-200",
isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
)}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-white/10">
<h2 className="text-base font-semibold text-white whitespace-nowrap">
Activity
</h2>
<button
onClick={onToggle}
className="p-1.5 hover:bg-white/10 rounded transition-colors"
title="Close panel"
>
<ChevronRight className="w-5 h-5 text-white/60" />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-white/10">
{TABS.map((tab) => {
const Icon = tab.icon;
const badge =
tab.id === "notifications"
? notificationBadge
: tab.id === "active"
? activeBadge
: null;
return (
<button
key={tab.id}
onClick={() => setResolvedActiveTab(tab.id)}
className={cn(
"flex-1 flex items-center justify-center gap-2 py-3 text-sm font-medium transition-colors relative whitespace-nowrap",
resolvedActiveTab === tab.id
? "text-white border-b-2 border-[#ecb200]"
: "text-white/50 hover:text-white/70"
)}
>
<Icon className="w-4 h-4" />
<span>{tab.label}</span>
{badge && (
<span
className={cn(
"absolute -top-0.5 right-1/4 min-w-[18px] h-[18px] px-1 rounded-full text-xs font-bold flex items-center justify-center",
tab.id === "active"
? "bg-blue-500 text-white"
: "bg-[#ecb200] text-black"
)}
>
{badge > 99 ? "99+" : badge}
</span>
)}
</button>
);
})}
</div>
{/* Tab Content */}
<div className="flex-1 overflow-hidden">
{resolvedActiveTab === "notifications" && (
<NotificationsTab />
)}
{resolvedActiveTab === "active" && <ActiveDownloadsTab />}
{resolvedActiveTab === "history" && <HistoryTab />}
</div>
</div>
</div>
);
}
// Toggle button for TopBar
export function ActivityPanelToggle() {
const { unreadCount } = useNotifications();
const { downloads: activeDownloads } = useActiveDownloads();
const isMobile = useIsMobile();
const isTablet = useIsTablet();
if (isMobile || isTablet) {
return null;
}
const hasActivity = unreadCount > 0 || activeDownloads.length > 0;
return (
<button
onClick={() =>
window.dispatchEvent(new CustomEvent("toggle-activity-panel"))
}
className={cn(
"relative p-2 rounded-full transition-all",
"text-white/60 hover:text-white"
)}
title="Toggle activity panel"
>
<Bell className="w-5 h-5" />
{hasActivity && (
<span className="absolute top-1.5 right-2 w-1 h-1 rounded-full bg-[#ecb200]" />
)}
</button>
);
}
@@ -0,0 +1,175 @@
"use client";
import { useAuth } from "@/lib/auth-context";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
import { Sidebar } from "./Sidebar";
import { TopBar } from "./TopBar";
import { TVLayout } from "./TVLayout";
import { BottomNavigation } from "./BottomNavigation";
import { UniversalPlayer } from "../player/UniversalPlayer";
import { MediaControlsHandler } from "../player/MediaControlsHandler";
import { PlayerModeWrapper } from "../player/PlayerModeWrapper";
import { ActivityPanel } from "./ActivityPanel";
import { GalaxyBackground } from "../ui/GalaxyBackground";
import { GradientSpinner } from "../ui/GradientSpinner";
import { PWAInstallPrompt } from "../PWAInstallPrompt";
import { ReactNode } from "react";
import { useIsMobile, useIsTablet } from "@/hooks/useMediaQuery";
import { useIsTV } from "@/lib/tv-utils";
import { useActivityPanel } from "@/hooks/useActivityPanel";
const publicPaths = ["/login", "/register", "/onboarding", "/sync"];
export function AuthenticatedLayout({ children }: { children: ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
const pathname = usePathname();
const isMobile = useIsMobile();
const isTablet = useIsTablet();
const isTV = useIsTV();
const isMobileOrTablet = isMobile || isTablet;
const activityPanel = useActivityPanel();
// Listen for activity panel events (toggle/open/close/tab)
useEffect(() => {
const handleToggle = () => activityPanel.toggle();
const handleOpen = () => activityPanel.open();
const handleClose = () => activityPanel.close();
const handleSetTab = (
e: CustomEvent<{ tab: "notifications" | "active" | "history" }>
) => {
activityPanel.setActiveTab(e.detail.tab);
};
window.addEventListener("toggle-activity-panel", handleToggle);
window.addEventListener("open-activity-panel", handleOpen);
window.addEventListener("close-activity-panel", handleClose);
window.addEventListener(
"set-activity-panel-tab",
handleSetTab as EventListener
);
return () => {
window.removeEventListener("toggle-activity-panel", handleToggle);
window.removeEventListener("open-activity-panel", handleOpen);
window.removeEventListener("close-activity-panel", handleClose);
window.removeEventListener(
"set-activity-panel-tab",
handleSetTab as EventListener
);
};
}, [activityPanel]);
const isPublicPage = publicPaths.includes(pathname);
// Show loading state only on protected pages
if (!isPublicPage && isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-black">
<div className="flex flex-col items-center gap-4">
<GradientSpinner size="lg" />
<p className="text-white/60 text-sm">Loading...</p>
</div>
</div>
);
}
// On public pages (login/register), don't show sidebar/player/topbar
if (isPublicPage) {
return <>{children}</>;
}
// On protected pages, show appropriate layout based on device
if (isAuthenticated) {
// Android TV Layout - Optimized for 10-foot UI
if (isTV) {
return (
<PlayerModeWrapper>
<MediaControlsHandler />
<TVLayout>{children}</TVLayout>
</PlayerModeWrapper>
);
}
// Mobile/Tablet Layout
if (isMobileOrTablet) {
return (
<PlayerModeWrapper>
<div className="h-screen bg-black overflow-hidden flex flex-col">
<MediaControlsHandler />
<TopBar />
{/* Sidebar - renders MobileSidebar for hamburger menu */}
<Sidebar />
{/* Activity Panel - for mobile notifications (rendered as overlay) */}
<ActivityPanel
isOpen={activityPanel.isOpen}
onToggle={activityPanel.toggle}
activeTab={activityPanel.activeTab}
onTabChange={activityPanel.setActiveTab}
/>
{/* Main content area with rounded corners */}
<main
className="flex-1 bg-gradient-to-b from-[#1a1a1a] via-black to-black mx-2 mb-2 rounded-lg overflow-y-auto relative"
style={{
marginTop: "52px",
marginBottom:
"calc(56px + env(safe-area-inset-bottom, 0px) + 8px)",
}}
>
<GalaxyBackground />
{/* Padding at bottom for mini player floating above */}
<div className="pb-24">{children}</div>
</main>
{/* Mini Player - fixed, positioned above bottom nav */}
<UniversalPlayer />
{/* Bottom Navigation - fixed at bottom */}
<BottomNavigation />
<PWAInstallPrompt />
</div>
</PlayerModeWrapper>
);
}
// Desktop Layout
return (
<PlayerModeWrapper>
<div
className="h-screen bg-black overflow-hidden flex flex-col"
style={{ paddingTop: "64px" }}
>
<MediaControlsHandler />
<TopBar />
<div className="flex-1 flex gap-2 p-2 pt-0 overflow-hidden">
<Sidebar />
<main className="flex-1 bg-gradient-to-b from-[#1a1a1a] via-black to-black rounded-lg overflow-y-auto relative">
<GalaxyBackground />
{children}
</main>
<ActivityPanel
isOpen={activityPanel.isOpen}
onToggle={activityPanel.toggle}
activeTab={activityPanel.activeTab}
onTabChange={activityPanel.setActiveTab}
/>
</div>
<UniversalPlayer />
<PWAInstallPrompt />
</div>
</PlayerModeWrapper>
);
}
// If not authenticated on a protected page, auth context will redirect
return (
<div className="min-h-screen flex items-center justify-center bg-black">
<div className="flex flex-col items-center gap-4">
<GradientSpinner size="lg" />
<p className="text-white/60 text-sm">Redirecting...</p>
</div>
</div>
);
}
@@ -0,0 +1,89 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Library, BookOpen, Mic, ListMusic } from "lucide-react";
import { cn } from "@/utils/cn";
import { useIsMobile, useIsTablet } from "@/hooks/useMediaQuery";
const navigationItems = [
{
name: "Library",
href: "/library",
icon: Library,
matchPattern: "/library"
},
{
name: "Audiobooks",
href: "/audiobooks",
icon: BookOpen,
matchPattern: "/audiobooks"
},
{
name: "Podcasts",
href: "/podcasts",
icon: Mic,
matchPattern: "/podcasts"
},
{
name: "Playlists",
href: "/playlists",
icon: ListMusic,
matchPattern: "/playlist" // Matches both /playlists and /playlist/[id]
},
];
export function BottomNavigation() {
const pathname = usePathname();
const isMobile = useIsMobile();
const isTablet = useIsTablet();
const isMobileOrTablet = isMobile || isTablet;
// Only render on mobile/tablet
if (!isMobileOrTablet) return null;
return (
<nav
className="fixed bottom-0 left-0 right-0 z-40 bg-black border-t border-white/10"
style={{
paddingBottom: 'env(safe-area-inset-bottom, 0px)'
}}
>
<div className="flex items-center justify-around h-14">
{navigationItems.map((item) => {
const isActive = pathname.startsWith(item.matchPattern);
const Icon = item.icon;
return (
<Link
key={item.name}
href={item.href}
className={cn(
"flex flex-col items-center justify-center flex-1 h-full py-2 transition-colors",
isActive
? "text-white"
: "text-gray-500 active:text-gray-300"
)}
>
<Icon
className={cn(
"w-5 h-5 mb-1",
isActive && "text-white"
)}
strokeWidth={isActive ? 2.5 : 2}
/>
<span
className={cn(
"text-[10px] tracking-wide",
isActive ? "font-semibold" : "font-medium"
)}
>
{item.name}
</span>
</Link>
);
})}
</div>
</nav>
);
}
@@ -0,0 +1,222 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import {
Settings,
RefreshCw,
LogOut,
Compass,
X,
Radio,
Calendar,
} from "lucide-react";
import { cn } from "@/utils/cn";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth-context";
import { useToast } from "@/lib/toast-context";
import Image from "next/image";
interface MobileSidebarProps {
isOpen: boolean;
onClose: () => void;
}
export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
const pathname = usePathname();
const { logout } = useAuth();
const { toast } = useToast();
const [isSyncing, setIsSyncing] = useState(false);
// Close on route change
useEffect(() => {
onClose();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
// Handle library sync
const handleSync = async () => {
if (isSyncing) return;
try {
setIsSyncing(true);
await api.scanLibrary();
window.dispatchEvent(new CustomEvent("notifications-changed"));
onClose();
} catch (error) {
console.error("Failed to sync library:", error);
toast.error("Failed to start scan. Please try again.");
} finally {
setTimeout(() => setIsSyncing(false), 2000);
}
};
// Handle logout
const handleLogout = async () => {
try {
await logout();
toast.success("Logged out successfully");
onClose();
} catch (error) {
console.error("Logout error:", error);
toast.error("Failed to logout");
}
};
if (!isOpen) return null;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/60 z-50 transition-opacity"
onClick={onClose}
/>
{/* Sidebar Drawer */}
<div
className="fixed inset-y-0 left-0 w-[280px] bg-[#0a0a0a] z-50 flex flex-col overflow-hidden transform transition-transform border-r border-white/[0.06]"
style={{
paddingTop: "env(safe-area-inset-top)",
}}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-white/[0.06]">
<Link
href="/"
className="flex items-center gap-3"
onClick={onClose}
>
<Image
src="/assets/images/LIDIFY.webp"
alt="Lidify"
width={32}
height={32}
className="flex-shrink-0"
/>
<span className="text-lg font-bold text-white tracking-tight">
Lidify
</span>
</Link>
<button
onClick={onClose}
className="w-9 h-9 flex items-center justify-center text-gray-500 hover:text-white transition-colors rounded-full hover:bg-white/10"
aria-label="Close menu"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Menu Content */}
<nav className="flex-1 overflow-y-auto py-4">
{/* Quick Links Section */}
<div className="px-3 mb-6">
<div className="text-[10px] font-semibold text-gray-600 uppercase tracking-widest px-3 mb-2">
Quick Links
</div>
<Link
href="/discover"
className={cn(
"flex items-center gap-3 px-3 py-3 rounded-lg transition-colors",
pathname === "/discover"
? "bg-white/10 text-white"
: "text-gray-400 hover:text-white hover:bg-white/5"
)}
>
<Compass className="w-5 h-5" />
<span className="text-[15px] font-medium">
Discover
</span>
</Link>
<Link
href="/radio"
className={cn(
"flex items-center gap-3 px-3 py-3 rounded-lg transition-colors",
pathname === "/radio"
? "bg-white/10 text-white"
: "text-gray-400 hover:text-white hover:bg-white/5"
)}
>
<Radio className="w-5 h-5" />
<span className="text-[15px] font-medium">
Radio
</span>
</Link>
<Link
href="/releases"
className={cn(
"flex items-center gap-3 px-3 py-3 rounded-lg transition-colors",
pathname === "/releases"
? "bg-white/10 text-white"
: "text-gray-400 hover:text-white hover:bg-white/5"
)}
>
<Calendar className="w-5 h-5" />
<span className="text-[15px] font-medium">
Releases
</span>
</Link>
</div>
{/* Actions Section */}
<div className="px-3">
<div className="text-[10px] font-semibold text-gray-600 uppercase tracking-widest px-3 mb-2">
Actions
</div>
<button
onClick={handleSync}
disabled={isSyncing}
className={cn(
"w-full flex items-center gap-3 px-3 py-3 rounded-lg transition-colors text-left",
isSyncing
? "text-green-400"
: "text-gray-400 hover:text-white hover:bg-white/5"
)}
>
<RefreshCw
className={cn(
"w-5 h-5",
isSyncing && "animate-spin"
)}
/>
<span className="text-[15px] font-medium">
{isSyncing ? "Syncing..." : "Sync Library"}
</span>
</button>
<Link
href="/settings"
className={cn(
"flex items-center gap-3 px-3 py-3 rounded-lg transition-colors",
pathname === "/settings"
? "bg-white/10 text-white"
: "text-gray-400 hover:text-white hover:bg-white/5"
)}
>
<Settings className="w-5 h-5" />
<span className="text-[15px] font-medium">
Settings
</span>
</Link>
</div>
</nav>
{/* Footer - Logout */}
<div className="border-t border-white/[0.06] p-3">
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-3 py-3 rounded-lg text-red-400 hover:text-red-300 hover:bg-red-500/10 transition-colors"
>
<LogOut className="w-5 h-5" />
<span className="text-[15px] font-medium">Logout</span>
</button>
</div>
</div>
</>
);
}
+444
View File
@@ -0,0 +1,444 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState, useEffect, useRef } from "react";
import { Plus, Settings, RefreshCw } from "lucide-react";
import { cn } from "@/utils/cn";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth-context";
import { useAudio } from "@/lib/audio-context";
import { useIsMobile, useIsTablet } from "@/hooks/useMediaQuery";
import { useToast } from "@/lib/toast-context";
import Image from "next/image";
import { MobileSidebar } from "./MobileSidebar";
const navigation = [
{ name: "Library", href: "/library" },
{ name: "Radio", href: "/radio" },
{ name: "Discovery", href: "/discover" },
{ name: "Audiobooks", href: "/audiobooks" },
{ name: "Podcasts", href: "/podcasts" },
{ name: "Browse", href: "/browse/playlists", badge: "Beta" },
] as const;
interface Playlist {
id: string;
name: string;
trackCount: number;
isHidden?: boolean;
isOwner?: boolean;
user?: { username: string };
}
export function Sidebar() {
const pathname = usePathname();
const { isAuthenticated } = useAuth();
const { toast } = useToast();
const { currentTrack, currentAudiobook, currentPodcast, playbackType } =
useAudio();
const isMobile = useIsMobile();
const isTablet = useIsTablet();
const isMobileOrTablet = isMobile || isTablet;
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [playlists, setPlaylists] = useState<Playlist[]>([]);
const [isLoadingPlaylists, setIsLoadingPlaylists] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const hasLoadedPlaylists = useRef(false);
// Handle library sync - no toast, notification bar handles feedback
const handleSync = async () => {
if (isSyncing) return;
try {
setIsSyncing(true);
await api.scanLibrary();
// No toast - notification will appear in the activity panel
window.dispatchEvent(new CustomEvent("notifications-changed"));
} catch (error) {
console.error("Failed to trigger library scan:", error);
toast.error("Failed to start scan. Please try again.");
} finally {
// Keep syncing for a bit to show the animation
setTimeout(() => setIsSyncing(false), 2000);
}
};
// Load playlists only once
useEffect(() => {
let loadingTimeout: NodeJS.Timeout | null = null;
const loadPlaylists = async () => {
if (!isAuthenticated || hasLoadedPlaylists.current) return;
// Delay showing loading state to avoid flicker
loadingTimeout = setTimeout(() => setIsLoadingPlaylists(true), 200);
hasLoadedPlaylists.current = true;
try {
const data = await api.getPlaylists();
setPlaylists(data);
} catch (error) {
console.error("Failed to load playlists:", error);
hasLoadedPlaylists.current = false; // Allow retry on error
} finally {
if (loadingTimeout) clearTimeout(loadingTimeout);
setIsLoadingPlaylists(false);
}
};
loadPlaylists();
// Listen for playlist events to refresh playlists
const handlePlaylistEvent = async () => {
console.log(
"[Sidebar] Playlist event received, refreshing playlists..."
);
if (!isAuthenticated) return;
try {
const data = await api.getPlaylists();
console.log(
"[Sidebar] Playlists refreshed:",
data.length,
"playlists"
);
setPlaylists(data);
} catch (error) {
console.error("Failed to reload playlists:", error);
}
};
window.addEventListener("playlist-created", handlePlaylistEvent);
window.addEventListener("playlist-updated", handlePlaylistEvent);
window.addEventListener("playlist-deleted", handlePlaylistEvent);
return () => {
if (loadingTimeout) {
clearTimeout(loadingTimeout);
}
window.removeEventListener("playlist-created", handlePlaylistEvent);
window.removeEventListener("playlist-updated", handlePlaylistEvent);
window.removeEventListener("playlist-deleted", handlePlaylistEvent);
};
}, [isAuthenticated]);
// Close mobile menu when route changes
useEffect(() => {
setIsMobileMenuOpen(false);
}, [pathname]);
// Close mobile menu on escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsMobileMenuOpen(false);
};
if (isMobileMenuOpen) {
document.addEventListener("keydown", handleEscape);
document.body.style.overflow = "hidden";
}
return () => {
document.removeEventListener("keydown", handleEscape);
document.body.style.overflow = "unset";
};
}, [isMobileMenuOpen]);
// Listen for toggle event from TopBar
useEffect(() => {
const handleToggle = () => setIsMobileMenuOpen(true);
window.addEventListener("toggle-mobile-menu", handleToggle);
return () =>
window.removeEventListener("toggle-mobile-menu", handleToggle);
}, []);
// Don't show sidebar on login/register pages
// (Check after all hooks to comply with Rules of Hooks)
if (pathname === "/login" || pathname === "/register") {
return null;
}
// Render sidebar content inline to prevent component recreation
const sidebarContent = (
<>
{/* Mobile Only - Logo and App Info */}
{isMobileOrTablet && (
<div className="px-6 pt-8 pb-6 border-b border-white/[0.08]">
{/* Logo and Title */}
<div className="flex items-center gap-4 mb-5">
<Image
src="/assets/images/LIDIFY.webp"
alt="Lidify"
width={48}
height={48}
className="flex-shrink-0"
/>
<div className="flex-1 min-w-0">
<h2 className="text-2xl font-black text-white tracking-tight">
Lidify
</h2>
{!currentTrack &&
!currentAudiobook &&
!currentPodcast ? (
<p className="text-sm text-gray-400 font-medium">
Stream Your Way
</p>
) : (
<div className="text-xs text-gray-400 truncate">
<span className="text-gray-500">
Listening to:{" "}
</span>
<span className="text-white font-medium">
{playbackType === "track" &&
currentTrack
? `${currentTrack.artist?.name} - ${currentTrack.album?.title}`
: playbackType === "audiobook" &&
currentAudiobook
? currentAudiobook.title
: playbackType === "podcast" &&
currentPodcast
? currentPodcast.podcastTitle
: ""}
</span>
</div>
)}
</div>
</div>
{/* Quick Actions - Settings and Sync */}
<div className="flex items-center gap-2">
<button
onClick={handleSync}
disabled={isSyncing}
className={cn(
"w-10 h-10 flex items-center justify-center rounded-full transition-all duration-300",
isSyncing
? "bg-[#1DB954] text-black"
: "bg-white/10 text-white hover:bg-white/15 active:scale-95"
)}
title={isSyncing ? "Syncing..." : "Sync Library"}
>
<RefreshCw
className={cn(
"w-4 h-4 transition-transform",
isSyncing && "animate-spin"
)}
/>
</button>
<Link
href="/settings"
className={cn(
"w-10 h-10 flex items-center justify-center rounded-full transition-all",
pathname === "/settings"
? "bg-white text-black"
: "bg-white/10 text-gray-400 hover:text-white hover:bg-white/15 active:scale-95"
)}
title="Settings"
>
<Settings className="w-4 h-4" />
</Link>
</div>
</div>
)}
{/* Navigation */}
<nav
className={cn(
"pt-6 space-y-1",
isMobileOrTablet ? "px-6" : "px-3"
)}
>
{navigation.map((item) => {
const isActive = pathname === item.href;
const badge = "badge" in item ? item.badge : null;
return (
<Link
key={item.name}
href={item.href}
prefetch={false}
className={cn(
"block rounded-lg transition-all duration-200 group relative overflow-hidden",
isMobileOrTablet ? "px-4 py-3.5" : "px-4 py-3",
isActive
? "bg-white/10 text-white"
: "text-gray-400 hover:text-white hover:bg-white/5 active:bg-white/[0.07]"
)}
>
<div className="relative z-10 flex items-center gap-2">
<span
className={cn(
"font-semibold transition-all duration-200",
isMobileOrTablet
? "text-base"
: "text-sm",
isActive && "text-white"
)}
>
{item.name}
</span>
{badge && (
<span className="px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide rounded bg-[#ecb200]/20 text-[#ecb200] border border-[#ecb200]/30">
{badge}
</span>
)}
</div>
</Link>
);
})}
</nav>
{/* Playlists Section */}
<div className="flex-1 overflow-hidden flex flex-col mt-8">
<div
className={cn(
"mb-4 flex items-center justify-between group",
isMobileOrTablet ? "px-6" : "px-4"
)}
>
<Link
href="/playlists"
prefetch={false}
className="relative group/link"
>
<span className="text-[10px] font-black text-gray-500 group-hover/link:text-transparent group-hover/link:bg-clip-text group-hover/link:bg-gradient-to-r group-hover/link:from-purple-400 group-hover/link:to-pink-400 transition-all duration-300 uppercase tracking-[0.15em]">
Your playlists
</span>
<div className="absolute -bottom-0.5 left-0 right-0 h-px bg-gradient-to-r from-purple-500/0 via-purple-500/50 to-purple-500/0 opacity-0 group-hover/link:opacity-100 transition-opacity duration-300" />
</Link>
<Link
href="/playlists"
prefetch={false}
className="w-7 h-7 flex items-center justify-center rounded-md bg-white/5 text-gray-400 hover:text-white hover:bg-gradient-to-br hover:from-purple-500 hover:to-pink-500 hover:scale-110 transition-all duration-300 shadow-lg shadow-transparent hover:shadow-purple-500/30 border border-white/5 hover:border-transparent"
title="Create Playlist"
>
<Plus className="w-4 h-4" />
</Link>
</div>
<div
className={cn(
"flex-1 overflow-y-auto space-y-1 scrollbar-thin scrollbar-thumb-[#1c1c1c] scrollbar-track-transparent",
isMobileOrTablet ? "px-6" : "px-3"
)}
>
{isLoadingPlaylists ? (
// Loading skeleton with shimmer
<>
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="px-3 py-2.5 rounded-lg relative overflow-hidden bg-white/[0.02] border-l-2 border-transparent"
>
<div
className="absolute inset-0 bg-gradient-to-r from-transparent via-purple-500/10 to-transparent"
style={{
animation: "shimmer 2s infinite",
}}
/>
<div className="h-4 bg-white/5 rounded w-3/4 mb-2 relative"></div>
<div className="h-3 bg-white/5 rounded w-1/2 relative"></div>
</div>
))}
</>
) : playlists.filter((p) => !p.isHidden).length > 0 ? (
playlists
.filter((p) => !p.isHidden) // Filter out hidden playlists
.map((playlist) => {
const isActive =
pathname === `/playlist/${playlist.id}`;
const isShared = playlist.isOwner === false;
return (
<Link
key={playlist.id}
href={`/playlist/${playlist.id}`}
prefetch={false}
className={cn(
"block px-3 py-2.5 rounded-lg transition-all duration-300 group relative overflow-hidden",
isActive
? "bg-gradient-to-r from-purple-500/10 to-transparent text-white border-l-2 border-purple-500 shadow-md shadow-purple-500/5"
: "text-gray-400 hover:text-white hover:bg-white/[0.05] border-l-2 border-transparent hover:border-l-2 hover:border-purple-500/30"
)}
>
{/* Hover shimmer effect */}
{!isActive && (
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-purple-500/5 to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-700" />
)}
<div className="flex items-center gap-1.5">
<div
className={cn(
"text-sm font-medium truncate relative z-10 transition-all duration-200 flex-1",
isActive
? "font-semibold"
: "group-hover:translate-x-0.5"
)}
>
{playlist.name}
</div>
{isShared && (
<span
className="shrink-0 w-1.5 h-1.5 rounded-full bg-purple-500"
title={`Shared by ${
playlist.user
?.username ||
"someone"
}`}
/>
)}
</div>
<div
className={cn(
"text-xs truncate relative z-10 mt-0.5 transition-colors duration-200",
isActive
? "text-gray-400"
: "text-gray-500 group-hover:text-gray-400"
)}
>
{isShared
? `by ${
playlist.user?.username ||
"Shared"
}`
: "Playlist"}{" "}
{playlist.trackCount} track
{playlist.trackCount !== 1
? "s"
: ""}
</div>
</Link>
);
})
) : (
<div className="px-4 py-8 text-center">
<div className="text-sm text-gray-500 mb-2">
No playlists yet
</div>
<div className="text-xs text-gray-600">
Create your first playlist to get started
</div>
</div>
)}
</div>
</div>
</>
);
return (
<>
{/* Mobile Sidebar */}
{isMobileOrTablet && (
<MobileSidebar
isOpen={isMobileMenuOpen}
onClose={() => setIsMobileMenuOpen(false)}
/>
)}
{/* Desktop Sidebar */}
{!isMobileOrTablet && (
<aside className="w-72 bg-[#0f0f0f] rounded-lg flex flex-col overflow-hidden relative z-10 border border-white/[0.03]">
{sidebarContent}
</aside>
)}
</>
);
}
+317
View File
@@ -0,0 +1,317 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState, useEffect, useRef, useCallback } from "react";
import Image from "next/image";
import { cn } from "@/utils/cn";
import { useAudio } from "@/lib/audio-context";
import { api } from "@/lib/api";
import { DPAD_KEYS } from "@/lib/tv-utils";
import { useTVNavigation } from "@/hooks/useTVNavigation";
import { RefreshCw, SkipBack, SkipForward, Shuffle, Repeat } from "lucide-react";
const tvNavigation = [
{ name: "Home", href: "/" },
{ name: "Search", href: "/search" },
{ name: "Library", href: "/library" },
{ name: "Audiobooks", href: "/audiobooks" },
{ name: "Podcasts", href: "/podcasts" },
{ name: "Discovery", href: "/discover" },
{ name: "Playlists", href: "/playlists" },
];
export function TVLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
// Start with nav focused and first tab selected for immediate D-pad usability
const [focusedTabIndex, setFocusedTabIndex] = useState(0);
const [isNavFocused, setIsNavFocused] = useState(true);
const [isSyncing, setIsSyncing] = useState(false);
const navRef = useRef<HTMLDivElement>(null);
// TV content navigation hook
const {
containerRef: contentRef,
focusFirstCard,
handleKeyDown: handleContentKeyDown,
isContentFocused,
} = useTVNavigation({
onBack: () => {
setIsNavFocused(true);
const currentIndex = tvNavigation.findIndex(n => n.href === pathname);
setFocusedTabIndex(currentIndex >= 0 ? currentIndex : 0);
},
});
const {
currentTrack,
currentAudiobook,
currentPodcast,
playbackType,
isPlaying,
pause,
resume,
currentTime,
duration,
next,
previous,
isShuffle,
toggleShuffle,
repeatMode,
toggleRepeat,
seek,
} = useAudio();
// Add tv-mode class to body on mount
useEffect(() => {
document.documentElement.classList.add('tv-mode');
document.body.classList.add('tv-mode');
return () => {
document.documentElement.classList.remove('tv-mode');
document.body.classList.remove('tv-mode');
};
}, []);
const hasMedia = !!(currentTrack || currentAudiobook || currentPodcast);
let title = "";
let artist = "";
let coverUrl: string | null = null;
if (playbackType === "track" && currentTrack) {
title = currentTrack.title;
artist = currentTrack.artist?.name || "";
coverUrl = currentTrack.album?.coverArt
? api.getCoverArtUrl(currentTrack.album.coverArt, 96)
: null;
} else if (playbackType === "audiobook" && currentAudiobook) {
title = currentAudiobook.title;
artist = currentAudiobook.author || "";
coverUrl = currentAudiobook.coverUrl
? api.getCoverArtUrl(currentAudiobook.coverUrl, 96)
: null;
} else if (playbackType === "podcast" && currentPodcast) {
title = currentPodcast.title;
artist = currentPodcast.podcastTitle || "";
coverUrl = currentPodcast.coverUrl
? api.getCoverArtUrl(currentPodcast.coverUrl, 96)
: null;
}
// Format time helper
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
// Sync library
const handleSync = async () => {
setIsSyncing(true);
try {
await api.scanLibrary();
} catch (error) {
console.error("Sync failed:", error);
} finally {
setIsSyncing(false);
}
};
const handleKeyDown = useCallback((e: KeyboardEvent) => {
// Media keys work globally regardless of focus state
if (hasMedia) {
switch (e.key) {
case DPAD_KEYS.PLAY_PAUSE:
case "MediaPlayPause":
case " ": // Space bar as play/pause
// Only use space when not in an input field
if (e.key === " ") {
const target = e.target as HTMLElement;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") {
return;
}
}
e.preventDefault();
isPlaying ? pause() : resume();
return;
case "MediaTrackNext":
e.preventDefault();
next();
return;
case "MediaTrackPrevious":
e.preventDefault();
previous();
return;
case DPAD_KEYS.FAST_FORWARD:
case "MediaFastForward":
e.preventDefault();
seek(Math.min(currentTime + 10, duration));
return;
case DPAD_KEYS.REWIND:
case "MediaRewind":
e.preventDefault();
seek(Math.max(currentTime - 10, 0));
return;
}
}
if (isNavFocused) {
if (e.key === DPAD_KEYS.LEFT) {
e.preventDefault();
setFocusedTabIndex(prev => Math.max(0, prev - 1));
} else if (e.key === DPAD_KEYS.RIGHT) {
e.preventDefault();
setFocusedTabIndex(prev => Math.min(tvNavigation.length - 1, prev + 1));
} else if (e.key === DPAD_KEYS.DOWN) {
e.preventDefault();
setIsNavFocused(false);
// Use the navigation hook to focus first card
focusFirstCard();
} else if (e.key === DPAD_KEYS.CENTER) {
e.preventDefault();
router.push(tvNavigation[focusedTabIndex].href);
}
} else {
// Delegate to content navigation hook
handleContentKeyDown(e);
}
}, [isNavFocused, focusedTabIndex, router, hasMedia, isPlaying, pause, resume, next, previous, seek, currentTime, duration, focusFirstCard, handleContentKeyDown]);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
// Focus correct nav tab when isNavFocused changes or focusedTabIndex changes
useEffect(() => {
if (isNavFocused && navRef.current) {
const tabs = navRef.current.querySelectorAll<HTMLAnchorElement>('[data-tv-tab]');
tabs[focusedTabIndex]?.focus();
}
}, [focusedTabIndex, isNavFocused]);
// On initial mount and pathname change, set the correct focused tab
useEffect(() => {
const currentIndex = tvNavigation.findIndex(n =>
n.href === pathname || (n.href !== "/" && pathname.startsWith(n.href))
);
if (currentIndex >= 0) {
setFocusedTabIndex(currentIndex);
}
}, [pathname]);
return (
<>
{/* Nav */}
<header className="tv-nav">
<Link href="/" className="tv-logo">
<Image src="/assets/images/LIDIFY.webp" alt="Lidify" width={24} height={24} />
<span>Lidify</span>
</Link>
<nav ref={navRef} className="tv-nav-links">
{tvNavigation.map((item, index) => {
const isActive = pathname === item.href ||
(item.href !== "/" && pathname.startsWith(item.href));
const isFocused = isNavFocused && focusedTabIndex === index;
return (
<Link
key={item.name}
href={item.href}
data-tv-tab
className={cn("tv-nav-link", isActive && "active", isFocused && "focused")}
onFocus={() => {
setIsNavFocused(true);
setFocusedTabIndex(index);
}}
>
{item.name}
</Link>
);
})}
</nav>
{/* Spacer */}
<div className="flex-1" />
{/* Sync button */}
<button
onClick={handleSync}
disabled={isSyncing}
className="tv-sync-btn"
title="Sync Library"
>
<RefreshCw className={cn("w-4 h-4", isSyncing && "animate-spin")} />
</button>
</header>
{/* Now Playing Bar - below nav */}
{hasMedia && (
<div className="tv-now-playing-bar">
{coverUrl && (
<Image src={coverUrl} alt={title} width={48} height={48} className="tv-np-cover" />
)}
<div className="tv-np-info">
<div className="tv-np-title">{title}</div>
<div className="tv-np-artist">{artist}</div>
</div>
{/* Time counter */}
<div className="tv-np-time">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
{/* Shuffle */}
<button
onClick={toggleShuffle}
className={cn("tv-np-ctrl", isShuffle && "active")}
title="Shuffle"
>
<Shuffle className="w-4 h-4" />
</button>
{/* Previous */}
<button onClick={previous} className="tv-np-ctrl" title="Previous">
<SkipBack className="w-4 h-4" />
</button>
{/* Play/Pause */}
<button onClick={() => isPlaying ? pause() : resume()} className="tv-np-btn">
{isPlaying ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="4" width="4" height="16" />
<rect x="14" y="4" width="4" height="16" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<polygon points="5,3 19,12 5,21" />
</svg>
)}
</button>
{/* Next */}
<button onClick={next} className="tv-np-ctrl" title="Next">
<SkipForward className="w-4 h-4" />
</button>
{/* Repeat */}
<button
onClick={toggleRepeat}
className={cn("tv-np-ctrl", repeatMode !== "off" && "active")}
title={repeatMode === "one" ? "Repeat One" : repeatMode === "all" ? "Repeat All" : "Repeat Off"}
>
<Repeat className="w-4 h-4" />
{repeatMode === "one" && <span className="tv-np-repeat-one">1</span>}
</button>
</div>
)}
{/* Content */}
<main ref={contentRef} className="tv-content">
{children}
</main>
</>
);
}
+335
View File
@@ -0,0 +1,335 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState, useEffect, useRef } from "react";
import { Home, Search, Settings, RefreshCw, Power, Menu, Bell } from "lucide-react";
import { ActivityPanelToggle } from "./ActivityPanel";
import { cn } from "@/utils/cn";
import { api } from "@/lib/api";
import { useToast } from "@/lib/toast-context";
import { useJobStatus } from "@/hooks/useJobStatus";
import { useDownloadContext } from "@/lib/download-context";
import { useIsMobile, useIsTablet } from "@/hooks/useMediaQuery";
import { useAuth } from "@/lib/auth-context";
import { useQueryClient } from "@tanstack/react-query";
import Image from "next/image";
export function TopBar() {
const pathname = usePathname();
const router = useRouter();
const { logout } = useAuth();
const isMobile = useIsMobile();
const isTablet = useIsTablet();
const isMobileOrTablet = isMobile || isTablet;
const [searchQuery, setSearchQuery] = useState("");
const [scanJobId, setScanJobId] = useState<string | null>(null);
const [lastScanTime, setLastScanTime] = useState<number>(0);
const { toast } = useToast();
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const queryClient = useQueryClient();
const { jobStatus, isPolling } = useJobStatus(scanJobId, "scan", {
onComplete: () => {
// Refresh Activity Panel and enrichment progress after scan
queryClient.invalidateQueries({ queryKey: ["notifications"] });
queryClient.invalidateQueries({ queryKey: ["enrichment-progress"] });
setScanJobId(null);
},
onError: () => {
// Scan errors will show in the activity panel via notifications
setScanJobId(null);
},
});
// Track download status from context (single source of truth)
const { pendingDownloads, downloadStatus } = useDownloadContext();
const hasActiveDownloads =
downloadStatus.hasActiveDownloads || pendingDownloads.length > 0;
const hasFailedDownloads = downloadStatus.failedDownloads.length > 0;
const handleSync = async () => {
if (isPolling) return;
// Prevent spam clicking - cooldown of 5 seconds (silently ignore)
const now = Date.now();
const timeSinceLastScan = now - lastScanTime;
if (timeSinceLastScan < 5000) {
return;
}
try {
setLastScanTime(now);
const response = await api.scanLibrary();
setScanJobId(response.jobId);
// Refresh notifications to show the scan started notification
queryClient.invalidateQueries({ queryKey: ["notifications"] });
} catch (error) {
console.error("Failed to trigger library scan:", error);
// Scan errors will show in the activity panel via notifications
}
};
const handleLogout = async () => {
try {
await logout();
toast.success("Logged out successfully");
} catch (error) {
console.error("Logout error:", error);
toast.error("Failed to logout");
}
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (searchQuery.trim()) {
router.push(`/search?q=${encodeURIComponent(searchQuery.trim())}`);
}
};
// Auto-search with debounce (500ms after user stops typing)
useEffect(() => {
// Don't auto-search if we're already on the search page with the same query
const params = new URLSearchParams(window.location.search);
const currentQuery = params.get("q");
if (pathname === "/search" && currentQuery === searchQuery.trim()) {
return;
}
// Clear any existing timeout
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
// Don't search if query is empty
if (!searchQuery.trim()) {
return;
}
// Set new timeout to trigger search after 500ms of no typing
searchTimeoutRef.current = setTimeout(() => {
router.push(`/search?q=${encodeURIComponent(searchQuery.trim())}`);
}, 500);
// Cleanup timeout on unmount or when searchQuery changes
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, [searchQuery, router, pathname]);
// Sync search query with URL on page change
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const q = params.get("q");
if (pathname === "/search" && q) {
// Only update if different to avoid loops
if (q !== searchQuery) {
setSearchQuery(q);
}
} else if (pathname !== "/search" && searchQuery) {
// Clear search when leaving search page
setSearchQuery("");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]); // Only re-run when pathname changes
return (
<header
className="fixed top-0 left-0 right-0 bg-black flex items-center px-3 z-50"
style={{ height: isMobileOrTablet ? "52px" : "64px" }}
>
{/* Mobile/Tablet Layout: Hamburger + Home + Search + Bell */}
{isMobileOrTablet ? (
<>
{/* Hamburger menu button */}
<button
onClick={() => {
// Dispatch custom event to toggle mobile menu
window.dispatchEvent(
new CustomEvent("toggle-mobile-menu")
);
}}
className="w-10 h-10 flex items-center justify-center bg-[#0f0f0f] border border-[#262626] rounded-md text-white hover:bg-[#141414] transition-colors mr-2 flex-shrink-0"
aria-label="Open menu"
>
<Menu className="w-5 h-5" />
</button>
{/* Home */}
<Link
href="/"
className={cn(
"w-10 h-10 rounded-full flex items-center justify-center transition-all flex-shrink-0 mr-2",
pathname === "/"
? "bg-white text-black"
: "bg-[#0a0a0a] text-gray-400 hover:bg-[#1a1a1a] hover:text-white"
)}
title="Home"
>
<Home className="w-5 h-5" />
</Link>
{/* Search */}
<form onSubmit={handleSearch} className="flex-1 min-w-0">
<div
className="relative"
data-tv-section="search-input"
>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) =>
setSearchQuery(e.target.value)
}
placeholder="Search..."
autoCapitalize="none"
autoCorrect="off"
tabIndex={0}
className="w-full h-10 pl-10 pr-3 bg-[#1a1a1a] hover:bg-[#242424] border-2 border-transparent focus:border-white/20 rounded-full text-sm text-white placeholder-gray-400 transition-all outline-none"
/>
</div>
</form>
{/* Notification Bell */}
<button
onClick={() => {
window.dispatchEvent(
new CustomEvent("toggle-activity-panel")
);
}}
className="w-10 h-10 flex items-center justify-center text-gray-400 hover:text-white transition-colors ml-2 flex-shrink-0 relative"
aria-label="Notifications"
title="Notifications"
>
<Bell className="w-5 h-5" />
{/* TODO: Add notification badge in Phase 3 */}
</button>
</>
) : (
<>
{/* Desktop Layout */}
{/* Logo - Far Left */}
<div className="w-72 flex items-center px-2">
<Link
href="/"
className="flex items-center gap-2 group"
>
<Image
src="/assets/images/LIDIFY.webp"
alt="Lidify"
width={32}
height={32}
className="group-hover:scale-105 transition-transform"
/>
<span className="text-xl font-semibold text-white">
Lidify
</span>
</Link>
</div>
{/* Center - Home & Search */}
<div className="flex-1 flex items-center justify-center gap-3 max-w-3xl mx-auto">
<Link
href="/"
className={cn(
"w-12 h-12 rounded-full flex items-center justify-center transition-all flex-shrink-0",
pathname === "/"
? "bg-white text-black"
: "bg-[#0a0a0a] text-gray-400 hover:bg-[#1a1a1a] hover:text-white hover:scale-105"
)}
title="Home"
>
<Home className="w-6 h-6" />
</Link>
<form
onSubmit={handleSearch}
className="flex-1 max-w-md"
>
<div
className="relative"
data-tv-section="search-input"
>
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) =>
setSearchQuery(e.target.value)
}
placeholder="What do you want to play?"
autoCapitalize="none"
autoCorrect="off"
tabIndex={0}
className="w-full h-12 pl-12 pr-4 bg-[#1a1a1a] hover:bg-[#242424] border-2 border-transparent focus:border-white/20 rounded-full text-sm text-white placeholder-gray-400 transition-all outline-none"
/>
</div>
</form>
</div>
{/* Right - Sync & Settings */}
<div className="w-72 flex items-center justify-end gap-2 px-2">
<button
onClick={handleSync}
disabled={isPolling}
className={cn(
"flex items-center gap-2 px-3 h-10 rounded-full transition-all text-sm font-medium",
isPolling
? "bg-white/10 text-gray-500 cursor-not-allowed"
: hasActiveDownloads
? " text-green-400 "
: hasFailedDownloads
? "bg-red-500/20 text-red-400 hover:bg-red-500/30"
: "bg-#0a0a0a text-white hover:bg-white/20"
)}
title={
isPolling
? "Library scan in progress..."
: hasActiveDownloads
? `${
downloadStatus.activeDownloads
.length + pendingDownloads.length
} download(s) in progress`
: hasFailedDownloads
? `${downloadStatus.failedDownloads.length} download(s) failed`
: "Sync Library"
}
>
<RefreshCw
className={cn(
"w-4 h-4",
(isPolling || hasActiveDownloads) &&
"animate-spin"
)}
/>
</button>
<ActivityPanelToggle />
<Link
href="/settings"
className={cn(
"w-10 h-10 rounded-full flex items-center justify-center transition-all",
pathname === "/settings"
? "bg-white text-black"
: "text-white/60 hover:text-white"
)}
title="Settings"
>
<Settings className="w-5 h-5" />
</Link>
<button
onClick={handleLogout}
className="w-10 h-10 rounded-full flex items-center justify-center transition-all text-red-400 hover:text-red-300"
title="Logout"
>
<Power className="w-5 h-5" />
</button>
</div>
</>
)}
</header>
);
}