Files
memegoat/frontend/src/components/app-sidebar.tsx
Mathis HERRIOT 9eb5a60fb2 feat: add unread messages badge and live updates in sidebar
- Display unread message count badge in the sidebar.
- Integrate `useSocket` for real-time updates on unread messages.
- Reset unread message count when navigating to the messages page.
- Increment badge count on receiving `new_message` WebSocket events.
2026-01-29 15:56:16 +01:00

394 lines
11 KiB
TypeScript

"use client";
import {
ChevronRight,
Clock,
Heart,
HelpCircle,
History,
Home,
LayoutGrid,
LogIn,
LogOut,
MessageCircle,
PlusCircle,
Settings,
ShieldCheck,
TrendingUp,
User as UserIcon,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { useTheme } from "next-themes";
import * as React from "react";
import { ModeToggle } from "@/components/mode-toggle";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarRail,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { useAuth } from "@/providers/auth-provider";
import { useSocket } from "@/providers/socket-provider";
import { CategoryService } from "@/services/category.service";
import { MessageService } from "@/services/message.service";
import type { Category } from "@/types/content";
const mainNav = [
{
title: "Accueil",
url: "/",
icon: Home,
},
{
title: "Tendances",
url: "/trends",
icon: TrendingUp,
},
{
title: "Nouveautés",
url: "/recent",
icon: Clock,
},
];
export function AppSidebar() {
const pathname = usePathname();
const searchParams = useSearchParams();
const { user, logout, isAuthenticated } = useAuth();
const { socket } = useSocket();
const { resolvedTheme } = useTheme();
const [categories, setCategories] = React.useState<Category[]>([]);
const [mounted, setMounted] = React.useState(false);
const [unreadMessages, setUnreadMessages] = React.useState(0);
React.useEffect(() => {
setMounted(true);
CategoryService.getAll().then(setCategories).catch(console.error);
}, []);
// Gérer le compteur de messages non-lus
React.useEffect(() => {
if (isAuthenticated) {
MessageService.getUnreadCount().then(setUnreadMessages).catch(console.error);
}
}, [isAuthenticated]);
React.useEffect(() => {
if (socket && isAuthenticated) {
socket.on("new_message", () => {
// Incrémenter si on n'est pas sur la page messages
if (pathname !== "/messages") {
setUnreadMessages((prev) => prev + 1);
}
});
return () => {
socket.off("new_message");
};
}
}, [socket, isAuthenticated, pathname]);
// Remettre à zéro si on arrive sur la page messages
React.useEffect(() => {
if (pathname === "/messages") {
setUnreadMessages(0);
}
}, [pathname]);
const logoSrc = React.useMemo(() => {
if (!mounted) return "/memegoat-color.svg";
return resolvedTheme === "dark"
? "/memegoat-white.svg"
: "/memegoat-black.svg";
}, [resolvedTheme, mounted]);
return (
<Sidebar collapsible="icon">
<SidebarHeader className="flex flex-row items-center justify-between py-4 group-data-[collapsible=icon]:justify-center">
<Link
href="/"
className="flex items-center gap-2 font-bold text-xl overflow-hidden"
>
<div className="p-1 rounded shrink-0">
<Image
src={logoSrc}
alt="MemeGoat Logo"
width={32}
height={32}
className="w-8 h-8"
/>
</div>
<span className="group-data-[collapsible=icon]:hidden whitespace-nowrap">
MemeGoat
</span>
</Link>
<SidebarTrigger className="hidden md:flex group-data-[collapsible=icon]:hidden" />
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
{mainNav.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
isActive={pathname === item.url}
tooltip={item.title}
>
<Link href={item.url}>
<item.icon />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Explorer</SidebarGroupLabel>
<SidebarMenu>
<Collapsible asChild className="group/collapsible">
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip="Catégories">
<LayoutGrid />
<span>Catégories</span>
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{categories.map((category) => (
<SidebarMenuSubItem key={category.id}>
<SidebarMenuSubButton
asChild
isActive={pathname === `/category/${category.slug}`}
>
<Link href={`/category/${category.slug}`}>
<span>{category.name}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
</SidebarMenu>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Communauté</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Publier">
<Link href="/upload">
<PlusCircle />
<span>Publier</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
{isAuthenticated && (
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={pathname === "/messages"}
tooltip="Messages"
>
<Link href="/messages">
<MessageCircle />
<span>Messages</span>
</Link>
</SidebarMenuButton>
{unreadMessages > 0 && (
<SidebarMenuBadge className="bg-red-500 text-white border-none h-5 min-w-5 flex items-center justify-center p-1 text-[10px]">
{unreadMessages > 9 ? "9+" : unreadMessages}
</SidebarMenuBadge>
)}
</SidebarMenuItem>
)}
</SidebarMenu>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Ma Bibliothèque</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={
pathname === "/profile" && searchParams.get("tab") === "favorites"
}
tooltip="Mes Favoris"
>
<Link href="/profile?tab=favorites">
<Heart />
<span>Mes Favoris</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={
pathname === "/profile" && searchParams.get("tab") === "memes"
}
tooltip="Mes Mèmes"
>
<Link href="/profile?tab=memes">
<History />
<span>Mes Mèmes</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
{isAuthenticated && user?.role === "admin" && (
<SidebarGroup>
<SidebarGroupLabel>Administration</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={pathname.startsWith("/admin")}
tooltip="Dashboard Admin"
>
<Link href="/admin">
<ShieldCheck />
<span>Admin</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
)}
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
{isAuthenticated && user ? (
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatarUrl} alt={user.username} />
<AvatarFallback className="rounded-lg">
{user.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
<span className="truncate font-semibold">
{user.displayName || user.username}
</span>
<span className="truncate text-xs">{user.role}</span>
</div>
<ChevronRight className="ml-auto size-4 group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side="right"
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatarUrl} alt={user.username} />
<AvatarFallback className="rounded-lg">
{user.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{user.displayName || user.username}
</span>
<span className="truncate text-xs">{user.role}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/profile" className="flex items-center gap-2">
<UserIcon className="size-4" />
<span>Profil</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/settings" className="flex items-center gap-2">
<Settings className="size-4" />
<span>Paramètres</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => logout()}>
<LogOut className="size-4 mr-2" />
<span>Déconnexion</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
) : (
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Se connecter">
<Link href="/login">
<LogIn className="size-4" />
<span>Se connecter</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)}
<SidebarMenuItem>
<div className="flex items-center justify-between px-2 py-2">
<span className="text-xs font-medium text-muted-foreground group-data-[collapsible=icon]:hidden">
Thème
</span>
<ModeToggle />
</div>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Aide">
<Link href="/help">
<HelpCircle />
<span>Aide</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}