refactor: apply consistent formatting to improve code quality
Some checks failed
Deploy to Production / deploy (push) Failing after 2m20s
Lint / lint (push) Successful in 9m37s

Ensure uniform code formatting across components by aligning with the established code style. Adjust imports, indentation, and spacing to enhance readability and maintainability.
This commit is contained in:
Mathis HERRIOT
2026-01-14 17:26:58 +01:00
parent 03e5915fcc
commit 35abd0496e
95 changed files with 6839 additions and 6659 deletions

View File

@@ -1,45 +1,58 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { ContentService } from "@/services/content.service";
import * as React from "react";
import { ContentCard } from "@/components/content-card";
import type { Content } from "@/types/content";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import { ContentService } from "@/services/content.service";
import type { Content } from "@/types/content";
export default function MemeModal({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = React.use(params);
const router = useRouter();
const [content, setContent] = React.useState<Content | null>(null);
const [loading, setLoading] = React.useState(true);
export default function MemeModal({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = React.use(params);
const router = useRouter();
const [content, setContent] = React.useState<Content | null>(null);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
ContentService.getOne(slug)
.then(setContent)
.catch(console.error)
.finally(() => setLoading(false));
}, [slug]);
React.useEffect(() => {
ContentService.getOne(slug)
.then(setContent)
.catch(console.error)
.finally(() => setLoading(false));
}, [slug]);
return (
<Dialog open onOpenChange={(open) => !open && router.back()}>
<DialogContent className="max-w-3xl p-0 overflow-hidden bg-transparent border-none">
<DialogTitle className="sr-only">{content?.title || "Détail du mème"}</DialogTitle>
<DialogDescription className="sr-only">Affiche le mème en grand avec ses détails</DialogDescription>
{loading ? (
<div className="h-[500px] flex items-center justify-center bg-zinc-950/50 rounded-lg">
<Spinner className="h-10 w-10 text-white" />
</div>
) : content ? (
<div className="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
<ContentCard content={content} />
</div>
) : (
<div className="p-8 bg-white dark:bg-zinc-900 rounded-lg text-center">
<p>Impossible de charger ce mème.</p>
</div>
)}
</DialogContent>
</Dialog>
);
return (
<Dialog open onOpenChange={(open) => !open && router.back()}>
<DialogContent className="max-w-3xl p-0 overflow-hidden bg-transparent border-none">
<DialogTitle className="sr-only">
{content?.title || "Détail du mème"}
</DialogTitle>
<DialogDescription className="sr-only">
Affiche le mème en grand avec ses détails
</DialogDescription>
{loading ? (
<div className="h-[500px] flex items-center justify-center bg-zinc-950/50 rounded-lg">
<Spinner className="h-10 w-10 text-white" />
</div>
) : content ? (
<div className="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
<ContentCard content={content} />
</div>
) : (
<div className="p-8 bg-white dark:bg-zinc-900 rounded-lg text-center">
<p>Impossible de charger ce mème.</p>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -1,3 +1,3 @@
export default function Default() {
return null;
return null;
}

View File

@@ -1,42 +1,42 @@
import * as React from "react";
interface UseInfiniteScrollOptions {
threshold?: number;
hasMore: boolean;
loading: boolean;
onLoadMore: () => void;
threshold?: number;
hasMore: boolean;
loading: boolean;
onLoadMore: () => void;
}
export function useInfiniteScroll({
threshold = 1.0,
hasMore,
loading,
onLoadMore,
threshold = 1.0,
hasMore,
loading,
onLoadMore,
}: UseInfiniteScrollOptions) {
const loaderRef = React.useRef<HTMLDivElement>(null);
const loaderRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading) {
onLoadMore();
}
},
{ threshold }
);
React.useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading) {
onLoadMore();
}
},
{ threshold },
);
const currentLoader = loaderRef.current;
if (currentLoader) {
observer.observe(currentLoader);
}
const currentLoader = loaderRef.current;
if (currentLoader) {
observer.observe(currentLoader);
}
return () => {
if (currentLoader) {
observer.unobserve(currentLoader);
}
observer.disconnect();
};
}, [onLoadMore, hasMore, loading, threshold]);
return () => {
if (currentLoader) {
observer.unobserve(currentLoader);
}
observer.disconnect();
};
}, [onLoadMore, hasMore, loading, threshold]);
return { loaderRef };
return { loaderRef };
}

View File

@@ -1,31 +1,30 @@
import * as React from "react";
import type { Metadata } from "next";
import { CategoryContent } from "@/components/category-content";
import { CategoryService } from "@/services/category.service";
export async function generateMetadata({
params
}: {
params: Promise<{ slug: string }>
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
try {
const categories = await CategoryService.getAll();
const category = categories.find(c => c.slug === slug);
return {
title: `${category?.name || slug} | MemeGoat`,
description: `Découvrez tous les mèmes de la catégorie ${category?.name || slug} sur MemeGoat.`,
};
} catch (error) {
return { title: `Catégorie : ${slug} | MemeGoat` };
}
const { slug } = await params;
try {
const categories = await CategoryService.getAll();
const category = categories.find((c) => c.slug === slug);
return {
title: `${category?.name || slug} | MemeGoat`,
description: `Découvrez tous les mèmes de la catégorie ${category?.name || slug} sur MemeGoat.`,
};
} catch (_error) {
return { title: `Catégorie : ${slug} | MemeGoat` };
}
}
export default async function CategoryPage({
params
}: {
params: Promise<{ slug: string }>
export default async function CategoryPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
return <CategoryContent slug={slug} />;
const { slug } = await params;
return <CategoryContent slug={slug} />;
}

View File

@@ -1,54 +1,54 @@
"use client";
import * as React from "react";
import { LayoutGrid } from "lucide-react";
import Link from "next/link";
import * as React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CategoryService } from "@/services/category.service";
import type { Category } from "@/types/content";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { LayoutGrid } from "lucide-react";
export default function CategoriesPage() {
const [categories, setCategories] = React.useState<Category[]>([]);
const [loading, setLoading] = React.useState(true);
const [categories, setCategories] = React.useState<Category[]>([]);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
CategoryService.getAll()
.then(setCategories)
.finally(() => setLoading(false));
}, []);
React.useEffect(() => {
CategoryService.getAll()
.then(setCategories)
.finally(() => setLoading(false));
}, []);
return (
<div className="max-w-4xl mx-auto py-8 px-4">
<div className="flex items-center gap-2 mb-8">
<LayoutGrid className="h-6 w-6" />
<h1 className="text-3xl font-bold">Catégories</h1>
</div>
return (
<div className="max-w-4xl mx-auto py-8 px-4">
<div className="flex items-center gap-2 mb-8">
<LayoutGrid className="h-6 w-6" />
<h1 className="text-3xl font-bold">Catégories</h1>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
{loading ? (
Array.from({ length: 6 }).map((_, i) => (
<Card key={i} className="animate-pulse">
<CardHeader className="h-24 bg-zinc-100 dark:bg-zinc-800 rounded-t-lg" />
<CardContent className="h-12" />
</Card>
))
) : (
categories.map((category) => (
<Link key={category.id} href={`/category/${category.slug}`}>
<Card className="hover:border-primary transition-colors cursor-pointer group h-full">
<CardHeader className="bg-zinc-50 dark:bg-zinc-900 group-hover:bg-primary/5 transition-colors">
<CardTitle className="text-lg">{category.name}</CardTitle>
</CardHeader>
<CardContent className="pt-4">
<p className="text-sm text-muted-foreground">
{category.description || `Découvrez tous les mèmes de la catégorie ${category.name}.`}
</p>
</CardContent>
</Card>
</Link>
))
)}
</div>
</div>
);
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
{loading
? Array.from({ length: 6 }).map((_, i) => (
/* biome-ignore lint/suspicious/noArrayIndexKey: skeleton items don't have unique IDs */
<Card key={`skeleton-${i}`} className="animate-pulse">
<CardHeader className="h-24 bg-zinc-100 dark:bg-zinc-800 rounded-t-lg" />
<CardContent className="h-12" />
</Card>
))
: categories.map((category) => (
<Link key={category.id} href={`/category/${category.slug}`}>
<Card className="hover:border-primary transition-colors cursor-pointer group h-full">
<CardHeader className="bg-zinc-50 dark:bg-zinc-900 group-hover:bg-primary/5 transition-colors">
<CardTitle className="text-lg">{category.name}</CardTitle>
</CardHeader>
<CardContent className="pt-4">
<p className="text-sm text-muted-foreground">
{category.description ||
`Découvrez tous les mèmes de la catégorie ${category.name}.`}
</p>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
);
}

View File

@@ -1,37 +1,41 @@
import * as React from "react";
import { SidebarProvider, SidebarTrigger, SidebarInset } from "@/components/ui/sidebar";
import { AppSidebar } from "@/components/app-sidebar";
import { SearchSidebar } from "@/components/search-sidebar";
import { MobileFilters } from "@/components/mobile-filters";
import { SearchSidebar } from "@/components/search-sidebar";
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
export default function DashboardLayout({
children,
modal,
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset className="flex flex-row overflow-hidden">
<div className="flex-1 flex flex-col min-w-0">
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4 lg:hidden">
<SidebarTrigger />
<div className="flex-1" />
</header>
<main className="flex-1 overflow-y-auto bg-zinc-50 dark:bg-zinc-950">
{children}
{modal}
</main>
<React.Suspense fallback={null}>
<MobileFilters />
</React.Suspense>
</div>
<React.Suspense fallback={null}>
<SearchSidebar />
</React.Suspense>
</SidebarInset>
</SidebarProvider>
);
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset className="flex flex-row overflow-hidden">
<div className="flex-1 flex flex-col min-w-0">
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4 lg:hidden">
<SidebarTrigger />
<div className="flex-1" />
</header>
<main className="flex-1 overflow-y-auto bg-zinc-50 dark:bg-zinc-950">
{children}
{modal}
</main>
<React.Suspense fallback={null}>
<MobileFilters />
</React.Suspense>
</div>
<React.Suspense fallback={null}>
<SearchSidebar />
</React.Suspense>
</SidebarInset>
</SidebarProvider>
);
}

View File

@@ -1,13 +1,14 @@
import { ContentSkeleton } from "@/components/content-skeleton";
export default function Loading() {
return (
<div className="max-w-2xl mx-auto py-8 px-4 space-y-8">
<div className="flex flex-col gap-6">
{[...Array(3)].map((_, i) => (
<ContentSkeleton key={i} />
))}
</div>
</div>
);
return (
<div className="max-w-2xl mx-auto py-8 px-4 space-y-8">
<div className="flex flex-col gap-6">
{[...Array(3)].map((_, i) => (
/* biome-ignore lint/suspicious/noArrayIndexKey: skeleton items don't have unique IDs */
<ContentSkeleton key={`loading-skeleton-${i}`} />
))}
</div>
</div>
);
}

View File

@@ -1,90 +1,98 @@
import * as React from "react";
import type { Metadata } from "next";
import { ContentService } from "@/services/content.service";
import { ContentCard } from "@/components/content-card";
import { Button } from "@/components/ui/button";
import { ChevronLeft } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ContentCard } from "@/components/content-card";
import { Button } from "@/components/ui/button";
import { ContentService } from "@/services/content.service";
export const revalidate = 3600; // ISR: Revalider toutes les heures
export async function generateMetadata({
params
}: {
params: Promise<{ slug: string }>
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
try {
const content = await ContentService.getOne(slug);
return {
title: `${content.title} | MemeGoat`,
description: content.description || `Regardez ce mème : ${content.title}`,
openGraph: {
images: [content.thumbnailUrl || content.url],
},
};
} catch (error) {
return { title: "Mème non trouvé | MemeGoat" };
}
const { slug } = await params;
try {
const content = await ContentService.getOne(slug);
return {
title: `${content.title} | MemeGoat`,
description: content.description || `Regardez ce mème : ${content.title}`,
openGraph: {
images: [content.thumbnailUrl || content.url],
},
};
} catch (_error) {
return { title: "Mème non trouvé | MemeGoat" };
}
}
export default async function MemePage({
params
}: {
params: Promise<{ slug: string }>
export default async function MemePage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
try {
const content = await ContentService.getOne(slug);
return (
<div className="max-w-4xl mx-auto py-8 px-4">
<Link href="/" className="inline-flex items-center text-sm mb-6 hover:text-primary transition-colors">
<ChevronLeft className="h-4 w-4 mr-1" />
Retour au flux
</Link>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2">
<ContentCard content={content} />
</div>
<div className="space-y-6">
<div className="bg-white dark:bg-zinc-900 p-6 rounded-xl shadow-sm border">
<h2 className="font-bold text-lg mb-4">À propos de ce mème</h2>
<div className="space-y-4 text-sm">
<div>
<p className="text-muted-foreground">Publié par</p>
<p className="font-medium">{content.author.displayName || content.author.username}</p>
</div>
<div>
<p className="text-muted-foreground">Date</p>
<p className="font-medium">{new Date(content.createdAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric'
})}</p>
</div>
{content.description && (
<div>
<p className="text-muted-foreground">Description</p>
<p>{content.description}</p>
</div>
)}
</div>
</div>
<div className="bg-white dark:bg-zinc-900 p-6 rounded-xl shadow-sm border text-center">
<p className="text-sm text-muted-foreground mb-4">Envie de créer votre propre mème ?</p>
<Button className="w-full">Utiliser ce template</Button>
</div>
</div>
</div>
</div>
);
} catch (error) {
notFound();
}
const { slug } = await params;
try {
const content = await ContentService.getOne(slug);
return (
<div className="max-w-4xl mx-auto py-8 px-4">
<Link
href="/"
className="inline-flex items-center text-sm mb-6 hover:text-primary transition-colors"
>
<ChevronLeft className="h-4 w-4 mr-1" />
Retour au flux
</Link>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2">
<ContentCard content={content} />
</div>
<div className="space-y-6">
<div className="bg-white dark:bg-zinc-900 p-6 rounded-xl shadow-sm border">
<h2 className="font-bold text-lg mb-4">À propos de ce mème</h2>
<div className="space-y-4 text-sm">
<div>
<p className="text-muted-foreground">Publié par</p>
<p className="font-medium">
{content.author.displayName || content.author.username}
</p>
</div>
<div>
<p className="text-muted-foreground">Date</p>
<p className="font-medium">
{new Date(content.createdAt).toLocaleDateString("fr-FR", {
day: "numeric",
month: "long",
year: "numeric",
})}
</p>
</div>
{content.description && (
<div>
<p className="text-muted-foreground">Description</p>
<p>{content.description}</p>
</div>
)}
</div>
</div>
<div className="bg-white dark:bg-zinc-900 p-6 rounded-xl shadow-sm border text-center">
<p className="text-sm text-muted-foreground mb-4">
Envie de créer votre propre mème ?
</p>
<Button className="w-full">Utiliser ce template</Button>
</div>
</div>
</div>
</div>
);
} catch (_error) {
notFound();
}
}

View File

@@ -1,21 +1,24 @@
import * as React from "react";
import type { Metadata } from "next";
import * as React from "react";
import { HomeContent } from "@/components/home-content";
import { Spinner } from "@/components/ui/spinner";
export const metadata: Metadata = {
title: "MemeGoat | La meilleure plateforme de mèmes pour les chèvres",
description: "Explorez, créez et partagez les meilleurs mèmes de la communauté. Rejoignez le troupeau sur MemeGoat.",
title: "MemeGoat | La meilleure plateforme de mèmes pour les chèvres",
description:
"Explorez, créez et partagez les meilleurs mèmes de la communauté. Rejoignez le troupeau sur MemeGoat.",
};
export default function HomePage() {
return (
<React.Suspense fallback={
<div className="flex items-center justify-center p-12">
<Spinner className="h-8 w-8 text-primary" />
</div>
}>
<HomeContent />
</React.Suspense>
);
return (
<React.Suspense
fallback={
<div className="flex items-center justify-center p-12">
<Spinner className="h-8 w-8 text-primary" />
</div>
}
>
<HomeContent />
</React.Suspense>
);
}

View File

@@ -1,82 +1,96 @@
"use client";
import * as React from "react";
import { useAuth } from "@/providers/auth-provider";
import { ContentList } from "@/components/content-list";
import { ContentService } from "@/services/content.service";
import { FavoriteService } from "@/services/favorite.service";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Settings, LogOut, Calendar } from "lucide-react";
import { Calendar, LogOut, Settings } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import * as React from "react";
import { ContentList } from "@/components/content-list";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useAuth } from "@/providers/auth-provider";
import { ContentService } from "@/services/content.service";
import { FavoriteService } from "@/services/favorite.service";
export default function ProfilePage() {
const { user, isAuthenticated, isLoading, logout } = useAuth();
const { user, isAuthenticated, isLoading, logout } = useAuth();
if (isLoading) return null;
if (!isAuthenticated || !user) {
redirect("/login");
}
const fetchMyMemes = React.useCallback(
(params: { limit: number; offset: number }) =>
ContentService.getExplore({ ...params, author: user?.username }),
[user?.username],
);
const fetchMyMemes = React.useCallback((params: { limit: number; offset: number }) =>
ContentService.getExplore({ ...params, author: user.username }),
[user.username]);
const fetchMyFavorites = React.useCallback(
(params: { limit: number; offset: number }) => FavoriteService.list(params),
[],
);
const fetchMyFavorites = React.useCallback((params: { limit: number; offset: number }) =>
FavoriteService.list(params),
[]);
if (isLoading) return null;
if (!isAuthenticated || !user) {
redirect("/login");
}
return (
<div className="max-w-4xl mx-auto py-8 px-4">
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-8 border shadow-sm mb-8">
<div className="flex flex-col md:flex-row items-center gap-8">
<Avatar className="h-32 w-32 border-4 border-primary/10">
<AvatarImage src={user.avatarUrl} alt={user.username} />
<AvatarFallback className="text-4xl">
{user.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 text-center md:text-left space-y-4">
<div>
<h1 className="text-3xl font-bold">{user.displayName || user.username}</h1>
<p className="text-muted-foreground">@{user.username}</p>
</div>
<div className="flex flex-wrap justify-center md:justify-start gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
Membre depuis {new Date(user.createdAt).toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' })}
</span>
</div>
<div className="flex flex-wrap justify-center md:justify-start gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/settings">
<Settings className="h-4 w-4 mr-2" />
Paramètres
</Link>
</Button>
<Button variant="ghost" size="sm" onClick={() => logout()} className="text-red-500 hover:text-red-600 hover:bg-red-50">
<LogOut className="h-4 w-4 mr-2" />
Déconnexion
</Button>
</div>
</div>
</div>
</div>
return (
<div className="max-w-4xl mx-auto py-8 px-4">
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-8 border shadow-sm mb-8">
<div className="flex flex-col md:flex-row items-center gap-8">
<Avatar className="h-32 w-32 border-4 border-primary/10">
<AvatarImage src={user.avatarUrl} alt={user.username} />
<AvatarFallback className="text-4xl">
{user.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 text-center md:text-left space-y-4">
<div>
<h1 className="text-3xl font-bold">
{user.displayName || user.username}
</h1>
<p className="text-muted-foreground">@{user.username}</p>
</div>
<div className="flex flex-wrap justify-center md:justify-start gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
Membre depuis{" "}
{new Date(user.createdAt).toLocaleDateString("fr-FR", {
month: "long",
year: "numeric",
})}
</span>
</div>
<div className="flex flex-wrap justify-center md:justify-start gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/settings">
<Settings className="h-4 w-4 mr-2" />
Paramètres
</Link>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => logout()}
className="text-red-500 hover:text-red-600 hover:bg-red-50"
>
<LogOut className="h-4 w-4 mr-2" />
Déconnexion
</Button>
</div>
</div>
</div>
</div>
<Tabs defaultValue="memes" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-8">
<TabsTrigger value="memes">Mes Mèmes</TabsTrigger>
<TabsTrigger value="favorites">Mes Favoris</TabsTrigger>
</TabsList>
<TabsContent value="memes">
<ContentList fetchFn={fetchMyMemes} />
</TabsContent>
<TabsContent value="favorites">
<ContentList fetchFn={fetchMyFavorites} />
</TabsContent>
</Tabs>
</div>
);
<Tabs defaultValue="memes" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-8">
<TabsTrigger value="memes">Mes Mèmes</TabsTrigger>
<TabsTrigger value="favorites">Mes Favoris</TabsTrigger>
</TabsList>
<TabsContent value="memes">
<ContentList fetchFn={fetchMyMemes} />
</TabsContent>
<TabsContent value="favorites">
<ContentList fetchFn={fetchMyFavorites} />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -5,9 +5,11 @@ import { ContentList } from "@/components/content-list";
import { ContentService } from "@/services/content.service";
export default function RecentPage() {
const fetchFn = React.useCallback((params: { limit: number; offset: number }) =>
ContentService.getRecent(params.limit, params.offset),
[]);
const fetchFn = React.useCallback(
(params: { limit: number; offset: number }) =>
ContentService.getRecent(params.limit, params.offset),
[],
);
return <ContentList fetchFn={fetchFn} title="Nouveaux Mèmes" />;
return <ContentList fetchFn={fetchFn} title="Nouveaux Mèmes" />;
}

View File

@@ -5,9 +5,11 @@ import { ContentList } from "@/components/content-list";
import { ContentService } from "@/services/content.service";
export default function TrendsPage() {
const fetchFn = React.useCallback((params: { limit: number; offset: number }) =>
ContentService.getTrends(params.limit, params.offset),
[]);
const fetchFn = React.useCallback(
(params: { limit: number; offset: number }) =>
ContentService.getTrends(params.limit, params.offset),
[],
);
return <ContentList fetchFn={fetchFn} title="Top Tendances" />;
return <ContentList fetchFn={fetchFn} title="Top Tendances" />;
}

View File

@@ -1,257 +1,283 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Upload, Image as ImageIcon, Film, X, Loader2 } from "lucide-react";
import { Image as ImageIcon, Loader2, Upload, X } from "lucide-react";
import NextImage from "next/image";
import { useRouter } from "next/navigation";
import * as React from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CategoryService } from "@/services/category.service";
import { ContentService } from "@/services/content.service";
import type { Category } from "@/types/content";
const uploadSchema = z.object({
title: z.string().min(3, "Le titre doit faire au moins 3 caractères"),
type: z.enum(["meme", "gif"]),
categoryId: z.string().optional(),
tags: z.string().optional(),
title: z.string().min(3, "Le titre doit faire au moins 3 caractères"),
type: z.enum(["meme", "gif"]),
categoryId: z.string().optional(),
tags: z.string().optional(),
});
type UploadFormValues = z.infer<typeof uploadSchema>;
export default function UploadPage() {
const router = useRouter();
const [categories, setCategories] = React.useState<Category[]>([]);
const [file, setFile] = React.useState<File | null>(null);
const [preview, setPreview] = React.useState<string | null>(null);
const [isUploading, setIsUploading] = React.useState(false);
const router = useRouter();
const [categories, setCategories] = React.useState<Category[]>([]);
const [file, setFile] = React.useState<File | null>(null);
const [preview, setPreview] = React.useState<string | null>(null);
const [isUploading, setIsUploading] = React.useState(false);
const form = useForm<UploadFormValues>({
resolver: zodResolver(uploadSchema),
defaultValues: {
title: "",
type: "meme",
tags: "",
},
});
const form = useForm<UploadFormValues>({
resolver: zodResolver(uploadSchema),
defaultValues: {
title: "",
type: "meme",
tags: "",
},
});
React.useEffect(() => {
CategoryService.getAll().then(setCategories).catch(console.error);
}, []);
React.useEffect(() => {
CategoryService.getAll().then(setCategories).catch(console.error);
}, []);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
if (selectedFile.size > 10 * 1024 * 1024) {
toast.error("Le fichier est trop volumineux (max 10Mo)");
return;
}
setFile(selectedFile);
const reader = new FileReader();
reader.onloadend = () => {
setPreview(reader.result as string);
};
reader.readAsDataURL(selectedFile);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
if (selectedFile.size > 10 * 1024 * 1024) {
toast.error("Le fichier est trop volumineux (max 10Mo)");
return;
}
setFile(selectedFile);
const reader = new FileReader();
reader.onloadend = () => {
setPreview(reader.result as string);
};
reader.readAsDataURL(selectedFile);
}
};
const onSubmit = async (values: UploadFormValues) => {
if (!file) {
toast.error("Veuillez sélectionner un fichier");
return;
}
const onSubmit = async (values: UploadFormValues) => {
if (!file) {
toast.error("Veuillez sélectionner un fichier");
return;
}
setIsUploading(true);
try {
const formData = new FormData();
formData.append("file", file);
formData.append("title", values.title);
formData.append("type", values.type);
if (values.categoryId) formData.append("categoryId", values.categoryId);
if (values.tags) {
const tagsArray = values.tags.split(",").map(t => t.trim()).filter(t => t !== "");
tagsArray.forEach(tag => formData.append("tags[]", tag));
}
setIsUploading(true);
try {
const formData = new FormData();
formData.append("file", file);
formData.append("title", values.title);
formData.append("type", values.type);
if (values.categoryId) formData.append("categoryId", values.categoryId);
if (values.tags) {
const tagsArray = values.tags
.split(",")
.map((t) => t.trim())
.filter((t) => t !== "");
for (const tag of tagsArray) {
formData.append("tags[]", tag);
}
}
await ContentService.upload(formData);
toast.success("Mème uploadé avec succès !");
router.push("/");
} catch (error: any) {
console.error("Upload failed:", error);
toast.error(error.response?.data?.message || "Échec de l'upload. Êtes-vous connecté ?");
} finally {
setIsUploading(false);
}
};
await ContentService.upload(formData);
toast.success("Mème uploadé avec succès !");
router.push("/");
} catch (error: unknown) {
console.error("Upload failed:", error);
let errorMessage = "Échec de l'upload. Êtes-vous connecté ?";
if (
error &&
typeof error === "object" &&
"response" in error &&
error.response &&
typeof error.response === "object" &&
"data" in error.response &&
error.response.data &&
typeof error.response.data === "object" &&
"message" in error.response.data &&
typeof error.response.data.message === "string"
) {
errorMessage = error.response.data.message;
}
toast.error(errorMessage);
} finally {
setIsUploading(false);
}
};
return (
<div className="max-w-2xl mx-auto py-8 px-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
Partager un mème
</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-4">
<FormLabel>Fichier (Image ou GIF)</FormLabel>
{!preview ? (
<div
className="border-2 border-dashed rounded-lg p-12 flex flex-col items-center justify-center bg-zinc-50 dark:bg-zinc-900 cursor-pointer hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
onClick={() => document.getElementById("file-upload")?.click()}
>
<div className="bg-primary/10 p-4 rounded-full mb-4">
<ImageIcon className="h-8 w-8 text-primary" />
</div>
<p className="font-medium">Cliquez pour choisir un fichier</p>
<p className="text-xs text-muted-foreground mt-1">PNG, JPG ou GIF jusqu'à 10Mo</p>
<input
id="file-upload"
type="file"
className="hidden"
accept="image/*,.gif"
onChange={handleFileChange}
/>
</div>
) : (
<div className="relative rounded-lg overflow-hidden border bg-zinc-100 dark:bg-zinc-800">
<img
src={preview}
alt="Preview"
className="max-h-[400px] mx-auto object-contain"
/>
<Button
type="button"
variant="destructive"
size="icon"
className="absolute top-2 right-2 rounded-full"
onClick={() => {
setFile(null);
setPreview(null);
}}
>
<X className="h-4 w-4" />
</Button>
</div>
)}
</div>
return (
<div className="max-w-2xl mx-auto py-8 px-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
Partager un mème
</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-4">
<FormLabel>Fichier (Image ou GIF)</FormLabel>
{!preview ? (
<button
type="button"
className="w-full border-2 border-dashed rounded-lg p-12 flex flex-col items-center justify-center bg-zinc-50 dark:bg-zinc-900 cursor-pointer hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
onClick={() => document.getElementById("file-upload")?.click()}
>
<div className="bg-primary/10 p-4 rounded-full mb-4">
<ImageIcon className="h-8 w-8 text-primary" />
</div>
<p className="font-medium">Cliquez pour choisir un fichier</p>
<p className="text-xs text-muted-foreground mt-1">
PNG, JPG ou GIF jusqu'à 10Mo
</p>
<input
id="file-upload"
type="file"
className="hidden"
accept="image/*,.gif"
onChange={handleFileChange}
/>
</button>
) : (
<div className="relative rounded-lg overflow-hidden border bg-zinc-100 dark:bg-zinc-800">
<div className="relative h-[400px] w-full">
<NextImage
src={preview}
alt="Preview"
fill
className="object-contain"
/>
</div>
<Button
type="button"
variant="destructive"
size="icon"
className="absolute top-2 right-2 rounded-full z-10"
onClick={() => {
setFile(null);
setPreview(null);
}}
>
<X className="h-4 w-4" />
</Button>
</div>
)}
</div>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Titre</FormLabel>
<FormControl>
<Input placeholder="Un titre génial pour votre mème..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Titre</FormLabel>
<FormControl>
<Input placeholder="Un titre génial pour votre mème..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Format</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Sélectionnez un format" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="meme">Image fixe</SelectItem>
<SelectItem value="gif">GIF Animé</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Format</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Sélectionnez un format" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="meme">Image fixe</SelectItem>
<SelectItem value="gif">GIF Animé</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="categoryId"
render={({ field }) => (
<FormItem>
<FormLabel>Catégorie</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Sélectionnez une catégorie" />
</SelectTrigger>
</FormControl>
<SelectContent>
{categories.map(cat => (
<SelectItem key={cat.id} value={cat.id}>{cat.name}</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="categoryId"
render={({ field }) => (
<FormItem>
<FormLabel>Catégorie</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Sélectionnez une catégorie" />
</SelectTrigger>
</FormControl>
<SelectContent>
{categories.map((cat) => (
<SelectItem key={cat.id} value={cat.id}>
{cat.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="tags"
render={({ field }) => (
<FormItem>
<FormLabel>Tags</FormLabel>
<FormControl>
<Input placeholder="funny, coding, goat..." {...field} />
</FormControl>
<FormDescription>
Séparez les tags par des virgules.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tags"
render={({ field }) => (
<FormItem>
<FormLabel>Tags</FormLabel>
<FormControl>
<Input placeholder="funny, coding, goat..." {...field} />
</FormControl>
<FormDescription>Séparez les tags par des virgules.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={isUploading}>
{isUploading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Upload en cours...
</>
) : (
"Publier le mème"
)}
</Button>
</form>
</Form>
</CardContent>
</Card>
</div>
);
<Button type="submit" className="w-full" disabled={isUploading}>
{isUploading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Upload en cours...
</>
) : (
"Publier le mème"
)}
</Button>
</form>
</Form>
</CardContent>
</Card>
</div>
);
}