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:
45
frontend/src/app/(dashboard)/@modal/(.)meme/[slug]/page.tsx
Normal file
45
frontend/src/app/(dashboard)/@modal/(.)meme/[slug]/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"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 { ContentCard } from "@/components/content-card";
|
||||
import type { Content } from "@/types/content";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
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]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
3
frontend/src/app/(dashboard)/@modal/default.tsx
Normal file
3
frontend/src/app/(dashboard)/@modal/default.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
42
frontend/src/app/(dashboard)/_hooks/use-infinite-scroll.ts
Normal file
42
frontend/src/app/(dashboard)/_hooks/use-infinite-scroll.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from "react";
|
||||
|
||||
interface UseInfiniteScrollOptions {
|
||||
threshold?: number;
|
||||
hasMore: boolean;
|
||||
loading: boolean;
|
||||
onLoadMore: () => void;
|
||||
}
|
||||
|
||||
export function useInfiniteScroll({
|
||||
threshold = 1.0,
|
||||
hasMore,
|
||||
loading,
|
||||
onLoadMore,
|
||||
}: UseInfiniteScrollOptions) {
|
||||
const loaderRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !loading) {
|
||||
onLoadMore();
|
||||
}
|
||||
},
|
||||
{ threshold }
|
||||
);
|
||||
|
||||
const currentLoader = loaderRef.current;
|
||||
if (currentLoader) {
|
||||
observer.observe(currentLoader);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (currentLoader) {
|
||||
observer.unobserve(currentLoader);
|
||||
}
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [onLoadMore, hasMore, loading, threshold]);
|
||||
|
||||
return { loaderRef };
|
||||
}
|
||||
31
frontend/src/app/(dashboard)/category/[slug]/page.tsx
Normal file
31
frontend/src/app/(dashboard)/category/[slug]/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
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 }>
|
||||
}): 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` };
|
||||
}
|
||||
}
|
||||
|
||||
export default async function CategoryPage({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{ slug: string }>
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
return <CategoryContent slug={slug} />;
|
||||
}
|
||||
54
frontend/src/app/(dashboard)/category/page.tsx
Normal file
54
frontend/src/app/(dashboard)/category/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
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);
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
33
frontend/src/app/(dashboard)/layout.tsx
Normal file
33
frontend/src/app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
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";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
modal,
|
||||
}: {
|
||||
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>
|
||||
<MobileFilters />
|
||||
</div>
|
||||
<SearchSidebar />
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
13
frontend/src/app/(dashboard)/loading.tsx
Normal file
13
frontend/src/app/(dashboard)/loading.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
90
frontend/src/app/(dashboard)/meme/[slug]/page.tsx
Normal file
90
frontend/src/app/(dashboard)/meme/[slug]/page.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
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 Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
export const revalidate = 3600; // ISR: Revalider toutes les heures
|
||||
|
||||
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" };
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
21
frontend/src/app/(dashboard)/page.tsx
Normal file
21
frontend/src/app/(dashboard)/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
import type { Metadata } from "next";
|
||||
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.",
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
82
frontend/src/app/(dashboard)/profile/page.tsx
Normal file
82
frontend/src/app/(dashboard)/profile/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"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 Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function ProfilePage() {
|
||||
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 fetchMyFavorites = React.useCallback((params: { limit: number; offset: number }) =>
|
||||
FavoriteService.list(params),
|
||||
[]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
17
frontend/src/app/(dashboard)/recent/page.tsx
Normal file
17
frontend/src/app/(dashboard)/recent/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as React from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { ContentList } from "@/components/content-list";
|
||||
import { ContentService } from "@/services/content.service";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Nouveautés | MemeGoat",
|
||||
description: "Découvrez les derniers mèmes publiés sur MemeGoat.",
|
||||
};
|
||||
|
||||
export default function RecentPage() {
|
||||
const fetchFn = React.useCallback((params: { limit: number; offset: number }) =>
|
||||
ContentService.getRecent(params.limit, params.offset),
|
||||
[]);
|
||||
|
||||
return <ContentList fetchFn={fetchFn} title="Nouveaux Mèmes" />;
|
||||
}
|
||||
17
frontend/src/app/(dashboard)/trends/page.tsx
Normal file
17
frontend/src/app/(dashboard)/trends/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as React from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { ContentList } from "@/components/content-list";
|
||||
import { ContentService } from "@/services/content.service";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tendances | MemeGoat",
|
||||
description: "Découvrez les mèmes les plus populaires du moment sur MemeGoat.",
|
||||
};
|
||||
|
||||
export default function TrendsPage() {
|
||||
const fetchFn = React.useCallback((params: { limit: number; offset: number }) =>
|
||||
ContentService.getTrends(params.limit, params.offset),
|
||||
[]);
|
||||
|
||||
return <ContentList fetchFn={fetchFn} title="Top Tendances" />;
|
||||
}
|
||||
257
frontend/src/app/(dashboard)/upload/page.tsx
Normal file
257
frontend/src/app/(dashboard)/upload/page.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"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 { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
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(),
|
||||
});
|
||||
|
||||
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 form = useForm<UploadFormValues>({
|
||||
resolver: zodResolver(uploadSchema),
|
||||
defaultValues: {
|
||||
title: "",
|
||||
type: "meme",
|
||||
tags: "",
|
||||
},
|
||||
});
|
||||
|
||||
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 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));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user