feat: introduce new app routes with modular structure and enhanced features

Added modular app routes including `login`, `dashboard`, `categories`, `trends`, and `upload`. Introduced reusable components such as `ContentList`, `ContentSkeleton`, and `AppSidebar` for improved UI consistency. Enhanced authentication with `AuthProvider` and implemented lazy loading, dynamic layouts, and infinite scrolling for better performance.
This commit is contained in:
Mathis HERRIOT
2026-01-14 13:52:08 +01:00
parent 0c045e8d3c
commit 0b07320974
41 changed files with 2341 additions and 43 deletions

View File

@@ -0,0 +1,243 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Home,
TrendingUp,
Clock,
LayoutGrid,
PlusCircle,
Settings,
HelpCircle,
ChevronRight,
LogOut,
User as UserIcon,
LogIn,
} from "lucide-react";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@/components/ui/sidebar";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { CategoryService } from "@/services/category.service";
import type { Category } from "@/types/content";
import { useAuth } from "@/providers/auth-provider";
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 { user, logout, isAuthenticated, isLoading } = useAuth();
const [categories, setCategories] = React.useState<Category[]>([]);
React.useEffect(() => {
CategoryService.getAll().then(setCategories).catch(console.error);
}, []);
return (
<Sidebar collapsible="icon">
<SidebarHeader className="flex items-center justify-center py-4">
<Link href="/" className="flex items-center gap-2 font-bold text-xl">
<div className="bg-primary text-primary-foreground p-1 rounded">
🐐
</div>
<span className="group-data-[collapsible=icon]:hidden">MemeGoat</span>
</Link>
</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>
</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.email}</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.email}</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>
<SidebarMenuButton asChild tooltip="Aide">
<Link href="/help">
<HelpCircle />
<span>Aide</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
);
}

View File

@@ -0,0 +1,13 @@
"use client";
import * as React from "react";
import { ContentList } from "@/components/content-list";
import { ContentService } from "@/services/content.service";
export function CategoryContent({ slug }: { slug: string }) {
const fetchFn = React.useCallback((p: { limit: number; offset: number }) =>
ContentService.getExplore({ ...p, category: slug }),
[slug]);
return <ContentList fetchFn={fetchFn} title={`Catégorie : ${slug}`} />;
}

View File

@@ -0,0 +1,131 @@
"use client";
import * as React from "react";
import Image from "next/image";
import Link from "next/link";
import { Heart, MessageSquare, Share2, MoreHorizontal, Eye } from "lucide-react";
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import type { Content } from "@/types/content";
import { useAuth } from "@/providers/auth-provider";
import { FavoriteService } from "@/services/favorite.service";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
interface ContentCardProps {
content: Content;
}
export function ContentCard({ content }: ContentCardProps) {
const { isAuthenticated, user } = useAuth();
const router = useRouter();
const [isLiked, setIsLiked] = React.useState(false);
const [likesCount, setLikesCount] = React.useState(content.favoritesCount);
const handleLike = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!isAuthenticated) {
toast.error("Vous devez être connecté pour liker un mème");
router.push("/login");
return;
}
try {
if (isLiked) {
await FavoriteService.remove(content.id);
setIsLiked(false);
setLikesCount(prev => prev - 1);
} else {
await FavoriteService.add(content.id);
setIsLiked(true);
setLikesCount(prev => prev + 1);
}
} catch (error) {
toast.error("Une erreur est survenue");
}
};
return (
<Card className="overflow-hidden border-none shadow-sm hover:shadow-md transition-shadow">
<CardHeader className="p-4 flex flex-row items-center space-y-0 gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={content.author.avatarUrl} />
<AvatarFallback>{content.author.username[0].toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<Link href={`/user/${content.author.username}`} className="text-sm font-semibold hover:underline">
{content.author.displayName || content.author.username}
</Link>
<span className="text-xs text-muted-foreground">
{new Date(content.createdAt).toLocaleDateString('fr-FR')}
</span>
</div>
<Button variant="ghost" size="icon" className="ml-auto h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="p-0 relative bg-zinc-100 dark:bg-zinc-900 aspect-square flex items-center justify-center">
<Link href={`/meme/${content.slug}`} className="w-full h-full relative">
{content.type === "image" ? (
<Image
src={content.url}
alt={content.title}
fill
className="object-contain"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
) : (
<video
src={content.url}
controls={false}
autoPlay
muted
loop
className="w-full h-full object-contain"
/>
)}
</Link>
</CardContent>
<CardFooter className="p-4 flex flex-col gap-4">
<div className="w-full flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className={`gap-1.5 h-8 ${isLiked ? 'text-red-500 hover:text-red-600' : ''}`}
onClick={handleLike}
>
<Heart className={`h-4 w-4 ${isLiked ? 'fill-current' : ''}`} />
<span className="text-xs">{likesCount}</span>
</Button>
<Button variant="ghost" size="sm" className="gap-1.5 h-8">
<Eye className="h-4 w-4" />
<span className="text-xs">{content.views}</span>
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<Share2 className="h-4 w-4" />
</Button>
</div>
<Button size="sm" variant="secondary" className="text-xs h-8">
Utiliser
</Button>
</div>
<div className="w-full space-y-2">
<h3 className="font-medium text-sm line-clamp-2">{content.title}</h3>
<div className="flex flex-wrap gap-1">
{content.tags.slice(0, 3).map((tag, i) => (
<Badge key={typeof tag === 'string' ? tag : tag.id} variant="secondary" className="text-[10px] py-0 px-1.5">
#{typeof tag === 'string' ? tag : tag.name}
</Badge>
))}
</div>
</div>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import * as React from "react";
import { ContentCard } from "@/components/content-card";
import { ContentService } from "@/services/content.service";
import type { Content, PaginatedResponse } from "@/types/content";
import { Spinner } from "@/components/ui/spinner";
import { useInfiniteScroll } from "@/app/(dashboard)/_hooks/use-infinite-scroll";
interface ContentListProps {
fetchFn: (params: { limit: number; offset: number }) => Promise<PaginatedResponse<Content>>;
title?: string;
}
export function ContentList({ fetchFn, title }: ContentListProps) {
const [contents, setContents] = React.useState<Content[]>([]);
const [loading, setLoading] = React.useState(true);
const [offset, setOffset] = React.useState(0);
const [hasMore, setHasMore] = React.useState(true);
const loadMore = React.useCallback(async () => {
if (!hasMore || loading) return;
setLoading(true);
try {
const response = await fetchFn({
limit: 10,
offset: offset + 10,
});
setContents(prev => [...prev, ...response.data]);
setOffset(prev => prev + 10);
setHasMore(response.data.length === 10);
} catch (error) {
console.error("Failed to load more contents:", error);
} finally {
setLoading(false);
}
}, [offset, hasMore, loading, fetchFn]);
const { loaderRef } = useInfiniteScroll({
hasMore,
loading,
onLoadMore: loadMore,
});
React.useEffect(() => {
const fetchInitial = async () => {
setLoading(true);
try {
const response = await fetchFn({
limit: 10,
offset: 0,
});
setContents(response.data);
setHasMore(response.data.length === 10);
} catch (error) {
console.error("Failed to fetch contents:", error);
} finally {
setLoading(false);
}
};
fetchInitial();
}, [fetchFn]);
return (
<div className="max-w-2xl mx-auto py-8 px-4 space-y-8">
{title && <h1 className="text-2xl font-bold">{title}</h1>}
<div className="flex flex-col gap-6">
{contents.map((content) => (
<ContentCard key={content.id} content={content} />
))}
</div>
<div ref={loaderRef} className="py-8 flex justify-center">
{loading && <Spinner className="h-8 w-8 text-primary" />}
{!hasMore && contents.length > 0 && (
<p className="text-muted-foreground text-sm italic">Vous avez atteint la fin ! 🐐</p>
)}
{!loading && contents.length === 0 && (
<p className="text-muted-foreground text-sm italic">Aucun mème trouvé ici... pour l'instant !</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
export function ContentSkeleton() {
return (
<Card className="overflow-hidden border-none shadow-sm">
<CardHeader className="p-4 flex flex-row items-center space-y-0 gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex flex-col gap-1.5">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-2 w-16" />
</div>
</CardHeader>
<CardContent className="p-0 aspect-square">
<Skeleton className="h-full w-full" />
</CardContent>
<CardFooter className="p-4 flex flex-col gap-4">
<div className="w-full flex justify-between">
<div className="flex gap-2">
<Skeleton className="h-8 w-12 rounded-md" />
<Skeleton className="h-8 w-12 rounded-md" />
</div>
<Skeleton className="h-8 w-20 rounded-md" />
</div>
<div className="w-full space-y-2">
<Skeleton className="h-4 w-full" />
<div className="flex gap-1">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-12" />
</div>
</div>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,35 @@
"use client";
import * as React from "react";
import { ContentList } from "@/components/content-list";
import { ContentService } from "@/services/content.service";
import { useSearchParams } from "next/navigation";
export function HomeContent() {
const searchParams = useSearchParams();
const sort = (searchParams.get("sort") as "trend" | "recent") || "trend";
const category = searchParams.get("category") || undefined;
const tag = searchParams.get("tag") || undefined;
const query = searchParams.get("query") || undefined;
const fetchFn = React.useCallback((params: { limit: number; offset: number }) =>
ContentService.getExplore({
...params,
sort,
category,
tag,
query
}),
[sort, category, tag, query]);
const title = query
? `Résultats pour "${query}"`
: category
? `Catégorie : ${category}`
: sort === "trend"
? "Tendances du moment"
: "Nouveautés";
return <ContentList fetchFn={fetchFn} title={title} />;
}

View File

@@ -0,0 +1,146 @@
"use client";
import * as React from "react";
import { Filter, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { CategoryService } from "@/services/category.service";
import type { Category } from "@/types/content";
export function MobileFilters() {
const router = useRouter();
const searchParams = useSearchParams();
const pathname = usePathname();
const [categories, setCategories] = React.useState<Category[]>([]);
const [query, setQuery] = React.useState(searchParams.get("query") || "");
const [open, setOpen] = React.useState(false);
React.useEffect(() => {
if (open) {
CategoryService.getAll().then(setCategories).catch(console.error);
}
}, [open]);
const updateSearch = React.useCallback((name: string, value: string | null) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(name, value);
} else {
params.delete(name);
}
router.push(`${pathname}?${params.toString()}`);
}, [router, pathname, searchParams]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
updateSearch("query", query);
setOpen(false);
};
const currentSort = searchParams.get("sort") || "trend";
const currentCategory = searchParams.get("category");
return (
<div className="lg:hidden fixed top-4 right-4 z-50">
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button size="icon" className="rounded-full shadow-lg h-12 w-12">
<Filter className="h-6 w-6" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-[300px] sm:w-[400px]">
<SheetHeader>
<SheetTitle>Recherche & Filtres</SheetTitle>
</SheetHeader>
<div className="mt-6 space-y-6">
<form onSubmit={handleSearch} className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Rechercher des mèmes..."
className="pl-8"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</form>
<ScrollArea className="h-[calc(100vh-200px)]">
<div className="space-y-6 pr-4">
<div>
<h3 className="text-sm font-medium mb-3">Trier par</h3>
<div className="flex flex-wrap gap-2">
<Badge
variant={currentSort === "trend" ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("sort", "trend")}
>
Tendances
</Badge>
<Badge
variant={currentSort === "recent" ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("sort", "recent")}
>
Récent
</Badge>
</div>
</div>
<Separator />
<div>
<h3 className="text-sm font-medium mb-3">Catégories</h3>
<div className="flex flex-wrap gap-2">
<Badge
variant={!currentCategory ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("category", null)}
>
Tout
</Badge>
{categories.map((cat) => (
<Badge
key={cat.id}
variant={currentCategory === cat.slug ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("category", cat.slug)}
>
{cat.name}
</Badge>
))}
</div>
</div>
<Separator />
<div>
<h3 className="text-sm font-medium mb-3">Tags populaires</h3>
<div className="flex flex-wrap gap-2">
{["funny", "coding", "cat", "dog", "work", "relatable", "gaming"].map(tag => (
<Badge
key={tag}
variant={searchParams.get("tag") === tag ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("tag", searchParams.get("tag") === tag ? null : tag)}
>
#{tag}
</Badge>
))}
</div>
</div>
</div>
</ScrollArea>
</div>
</SheetContent>
</Sheet>
</div>
);
}

View File

@@ -0,0 +1,132 @@
"use client";
import * as React from "react";
import { Search, Filter } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { CategoryService } from "@/services/category.service";
import type { Category } from "@/types/content";
export function SearchSidebar() {
const router = useRouter();
const searchParams = useSearchParams();
const pathname = usePathname();
const [categories, setCategories] = React.useState<Category[]>([]);
const [query, setQuery] = React.useState(searchParams.get("query") || "");
React.useEffect(() => {
CategoryService.getAll().then(setCategories).catch(console.error);
}, []);
const updateSearch = React.useCallback((name: string, value: string | null) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(name, value);
} else {
params.delete(name);
}
// If we are not on explore/trends/recent, maybe we should redirect to home?
// For now, let's just update the URL.
router.push(`${pathname}?${params.toString()}`);
}, [router, pathname, searchParams]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
updateSearch("query", query);
};
const currentSort = searchParams.get("sort") || "trend";
const currentCategory = searchParams.get("category");
return (
<aside className="hidden lg:flex flex-col w-80 border-l bg-background">
<div className="p-4 border-b">
<h2 className="font-semibold mb-4">Rechercher</h2>
<form onSubmit={handleSearch} className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Rechercher des mèmes..."
className="pl-8"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</form>
</div>
<ScrollArea className="flex-1 p-4">
<div className="space-y-6">
<div>
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
<Filter className="h-4 w-4" />
Filtres
</h3>
<div className="space-y-4">
<div>
<p className="text-xs text-muted-foreground mb-2">Trier par</p>
<div className="flex flex-wrap gap-2">
<Badge
variant={currentSort === "trend" ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("sort", "trend")}
>
Tendances
</Badge>
<Badge
variant={currentSort === "recent" ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("sort", "recent")}
>
Récent
</Badge>
</div>
</div>
<Separator />
<div>
<p className="text-xs text-muted-foreground mb-2">Catégories</p>
<div className="flex flex-wrap gap-2">
<Badge
variant={!currentCategory ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("category", null)}
>
Tout
</Badge>
{categories.map((cat) => (
<Badge
key={cat.id}
variant={currentCategory === cat.slug ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("category", cat.slug)}
>
{cat.name}
</Badge>
))}
</div>
</div>
</div>
</div>
<div>
<h3 className="text-sm font-medium mb-3">Tags populaires</h3>
<div className="flex flex-wrap gap-2">
{["funny", "coding", "cat", "dog", "work", "relatable", "gaming"].map(tag => (
<Badge
key={tag}
variant={searchParams.get("tag") === tag ? "default" : "outline"}
className="cursor-pointer hover:bg-secondary"
onClick={() => updateSearch("tag", searchParams.get("tag") === tag ? null : tag)}
>
#{tag}
</Badge>
))}
</div>
</div>
</div>
</ScrollArea>
</aside>
);
}