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,124 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { useAuth } from "@/providers/auth-provider";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { ArrowLeft } from "lucide-react";
const loginSchema = z.object({
email: z.string().email({ message: "Email invalide" }),
password: z.string().min(6, { message: "Le mot de passe doit faire au moins 6 caractères" }),
});
type LoginFormValues = z.infer<typeof loginSchema>;
export default function LoginPage() {
const { login } = useAuth();
const [loading, setLoading] = React.useState(false);
const form = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: "",
password: "",
},
});
async function onSubmit(values: LoginFormValues) {
setLoading(true);
try {
await login(values.email, values.password);
} catch (error) {
// Error is handled in useAuth via toast
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-950 p-4">
<div className="w-full max-w-md space-y-4">
<Link
href="/"
className="inline-flex items-center text-sm text-muted-foreground hover:text-primary transition-colors"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Retour à l'accueil
</Link>
<Card>
<CardHeader>
<CardTitle className="text-2xl">Connexion</CardTitle>
<CardDescription>
Entrez vos identifiants pour accéder à votre compte MemeGoat.
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="goat@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Mot de passe</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Connexion en cours..." : "Se connecter"}
</Button>
</form>
</Form>
</CardContent>
<CardFooter className="flex flex-col space-y-2">
<p className="text-sm text-center text-muted-foreground">
Vous n'avez pas de compte ?{" "}
<Link href="/register" className="text-primary hover:underline font-medium">
S'inscrire
</Link>
</p>
</CardFooter>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,153 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { useAuth } from "@/providers/auth-provider";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { ArrowLeft } from "lucide-react";
const registerSchema = z.object({
username: z.string().min(3, { message: "Le pseudo doit faire au moins 3 caractères" }),
email: z.string().email({ message: "Email invalide" }),
password: z.string().min(6, { message: "Le mot de passe doit faire au moins 6 caractères" }),
displayName: z.string().optional(),
});
type RegisterFormValues = z.infer<typeof registerSchema>;
export default function RegisterPage() {
const { register } = useAuth();
const [loading, setLoading] = React.useState(false);
const form = useForm<RegisterFormValues>({
resolver: zodResolver(registerSchema),
defaultValues: {
username: "",
email: "",
password: "",
displayName: "",
},
});
async function onSubmit(values: RegisterFormValues) {
setLoading(true);
try {
await register(values);
} catch (error) {
// Error handled in useAuth
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-950 p-4">
<div className="w-full max-w-md space-y-4">
<Link
href="/"
className="inline-flex items-center text-sm text-muted-foreground hover:text-primary transition-colors"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Retour à l'accueil
</Link>
<Card>
<CardHeader>
<CardTitle className="text-2xl">Inscription</CardTitle>
<CardDescription>
Rejoignez la communauté MemeGoat dès aujourd'hui.
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Pseudo</FormLabel>
<FormControl>
<Input placeholder="supergoat" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="goat@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="displayName"
render={({ field }) => (
<FormItem>
<FormLabel>Nom d'affichage (Optionnel)</FormLabel>
<FormControl>
<Input placeholder="Le Roi des Chèvres" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Mot de passe</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Création du compte..." : "S'inscrire"}
</Button>
</form>
</Form>
</CardContent>
<CardFooter className="flex flex-col space-y-2">
<p className="text-sm text-center text-muted-foreground">
Vous avez déjà un compte ?{" "}
<Link href="/login" className="text-primary hover:underline font-medium">
Se connecter
</Link>
</p>
</CardFooter>
</Card>
</div>
</div>
);
}

View 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>
);
}

View File

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

View 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 };
}

View 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} />;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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();
}
}

View 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>
);
}

View 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>
);
}

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

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

View 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>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
import { AlertTriangle, RefreshCw, Home } from "lucide-react";
import Link from "next/link";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-zinc-50 dark:bg-zinc-950 px-4">
<div className="text-center space-y-6 max-w-md">
<div className="flex justify-center">
<div className="bg-orange-100 dark:bg-orange-900/30 p-4 rounded-full">
<AlertTriangle className="h-12 w-12 text-orange-600 dark:text-orange-400" />
</div>
</div>
<h1 className="text-4xl font-bold tracking-tight">Oups ! Une erreur est survenue</h1>
<p className="text-muted-foreground text-lg">
La chèvre a glissé sur une peau de banane. Nous essayons de la remettre sur pied.
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Button onClick={reset} size="lg" className="gap-2">
<RefreshCw className="h-4 w-4" />
Réessayer
</Button>
<Button asChild variant="outline" size="lg" className="gap-2">
<Link href="/">
<Home className="h-4 w-4" />
Retourner à l'accueil
</Link>
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,35 +1,40 @@
import type { Metadata } from "next";
import { Ubuntu_Mono, Ubuntu_Sans } from "next/font/google";
import { Toaster } from "@/components/ui/sonner";
import { AuthProvider } from "@/providers/auth-provider";
import "./globals.css";
const ubuntuSans = Ubuntu_Sans({
variable: "--font-ubuntu-sans",
subsets: ["latin"],
variable: "--font-ubuntu-sans",
subsets: ["latin"],
});
const ubuntuMono = Ubuntu_Mono({
variable: "--font-geist-mono",
weight: ["400", "700"],
subsets: ["latin"],
variable: "--font-geist-mono",
weight: ["400", "700"],
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "MemeGoat",
icons: "/memegoat-color.svg",
title: "MemeGoat",
icons: "/memegoat-color.svg",
};
export default function RootLayout({
children,
children,
}: Readonly<{
children: React.ReactNode;
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${ubuntuSans.variable} ${ubuntuMono.variable} antialiased`}
>
{children}
</body>
</html>
);
return (
<html lang="fr" suppressHydrationWarning>
<body
className={`${ubuntuSans.variable} ${ubuntuMono.variable} antialiased`}
>
<AuthProvider>
{children}
<Toaster />
</AuthProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Home, AlertCircle } from "lucide-react";
export default function NotFound() {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-zinc-50 dark:bg-zinc-950 px-4">
<div className="text-center space-y-6 max-w-md">
<div className="flex justify-center">
<div className="bg-red-100 dark:bg-red-900/30 p-4 rounded-full">
<AlertCircle className="h-12 w-12 text-red-600 dark:text-red-400" />
</div>
</div>
<h1 className="text-4xl font-bold tracking-tight">404 - Perdu dans le troupeau ?</h1>
<p className="text-muted-foreground text-lg">
On dirait que ce mème s'est enfui. La chèvre ne l'a pas trouvé.
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Button asChild size="lg" className="gap-2">
<Link href="/">
<Home className="h-4 w-4" />
Retourner à l'accueil
</Link>
</Button>
</div>
</div>
<div className="mt-12 text-8xl grayscale opacity-20 select-none">
🐐
</div>
</div>
);
}

View File

@@ -1,9 +0,0 @@
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<p>Hello world !</p>
</main>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://memegoat.local";
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/settings/", "/upload/", "/api/"],
},
sitemap: `${baseUrl}/sitemap.xml`,
};
}

View File

@@ -0,0 +1,45 @@
import type { MetadataRoute } from "next";
import { ContentService } from "@/services/content.service";
import { CategoryService } from "@/services/category.service";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://memegoat.local";
// Pages statiques
const routes = ["", "/trends", "/recent"].map((route) => ({
url: `${baseUrl}${route}`,
lastModified: new Date(),
changeFrequency: "daily" as const,
priority: route === "" ? 1 : 0.8,
}));
// Catégories
try {
const categories = await CategoryService.getAll();
const categoryRoutes = categories.map((category) => ({
url: `${baseUrl}/category/${category.slug}`,
lastModified: new Date(),
changeFrequency: "weekly" as const,
priority: 0.6,
}));
routes.push(...categoryRoutes);
} catch (error) {
console.error("Sitemap: Failed to fetch categories");
}
// Mèmes (limité aux 100 derniers pour éviter un sitemap trop gros d'un coup)
try {
const contents = await ContentService.getRecent(100, 0);
const memeRoutes = contents.data.map((meme) => ({
url: `${baseUrl}/meme/${meme.slug}`,
lastModified: new Date(meme.updatedAt),
changeFrequency: "monthly" as const,
priority: 0.5,
}));
routes.push(...memeRoutes);
} catch (error) {
console.error("Sitemap: Failed to fetch memes");
}
return routes;
}