Files
lidify/frontend/components/layout/Sidebar.tsx
2025-12-25 18:58:06 -06:00

445 lines
21 KiB
TypeScript

"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>
)}
</>
);
}