Initial release v1.0.0
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user