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:
@@ -1,22 +1,66 @@
|
|||||||
FROM node:22-slim AS base
|
# syntax=docker.io/docker/dockerfile:1
|
||||||
ENV PNPM_HOME="/pnpm"
|
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
|
||||||
RUN corepack enable
|
|
||||||
|
|
||||||
FROM base AS build
|
FROM pnpm/pnpm:20-alpine AS base
|
||||||
WORKDIR /usr/src/app
|
|
||||||
COPY . .
|
|
||||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
|
||||||
RUN pnpm run --filter @memegoat/frontend build
|
|
||||||
|
|
||||||
FROM base AS runtime
|
# Install dependencies only when needed
|
||||||
|
FROM base AS deps
|
||||||
|
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /usr/src/app/frontend/public ./frontend/public
|
|
||||||
COPY --from=build /usr/src/app/frontend/.next/standalone ./
|
# Install dependencies based on the preferred package manager
|
||||||
COPY --from=build /usr/src/app/frontend/.next/static ./frontend/.next/static
|
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* source.config.ts* next.config.* ./
|
||||||
|
RUN \
|
||||||
|
if [ -f pnpm-lock.yaml ]; then pnpm i --frozen-lockfile; \
|
||||||
|
elif [ -f package-lock.json ]; then npm ci; \
|
||||||
|
elif [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||||
|
else echo "Lockfile not found." && exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Rebuild the source code only when needed
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Next.js collects completely anonymous telemetry data about general usage.
|
||||||
|
# Learn more here: https://nextjs.org/telemetry
|
||||||
|
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||||
|
# ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN \
|
||||||
|
if [ -f pnpm-lock.yaml ]; then pnpm run build; \
|
||||||
|
elif [ -f package-lock.json ]; then npm run build; \
|
||||||
|
elif [ -f yarn.lock ]; then yarn run build; \
|
||||||
|
else echo "Lockfile not found." && exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Production image, copy all the files and run next
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||||
|
# ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
# Automatically leverage output traces to reduce image size
|
||||||
|
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV PORT=3000
|
|
||||||
ENV HOSTNAME="0.0.0.0"
|
|
||||||
|
|
||||||
CMD ["node", "frontend/server.js"]
|
ENV PORT=3000
|
||||||
|
|
||||||
|
# server.js is created by next build from the standalone output
|
||||||
|
# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
"@radix-ui/react-toggle": "^1.1.10",
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"axios": "^1.13.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
|||||||
124
frontend/src/app/(auth)/login/page.tsx
Normal file
124
frontend/src/app/(auth)/login/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
frontend/src/app/(auth)/register/page.tsx
Normal file
153
frontend/src/app/(auth)/register/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
frontend/src/app/error.tsx
Normal file
46
frontend/src/app/error.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,35 +1,40 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Ubuntu_Mono, Ubuntu_Sans } from "next/font/google";
|
import { Ubuntu_Mono, Ubuntu_Sans } from "next/font/google";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { AuthProvider } from "@/providers/auth-provider";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const ubuntuSans = Ubuntu_Sans({
|
const ubuntuSans = Ubuntu_Sans({
|
||||||
variable: "--font-ubuntu-sans",
|
variable: "--font-ubuntu-sans",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const ubuntuMono = Ubuntu_Mono({
|
const ubuntuMono = Ubuntu_Mono({
|
||||||
variable: "--font-geist-mono",
|
variable: "--font-geist-mono",
|
||||||
weight: ["400", "700"],
|
weight: ["400", "700"],
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "MemeGoat",
|
title: "MemeGoat",
|
||||||
icons: "/memegoat-color.svg",
|
icons: "/memegoat-color.svg",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="fr" suppressHydrationWarning>
|
||||||
<body
|
<body
|
||||||
className={`${ubuntuSans.variable} ${ubuntuMono.variable} antialiased`}
|
className={`${ubuntuSans.variable} ${ubuntuMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
{children}
|
<AuthProvider>
|
||||||
</body>
|
{children}
|
||||||
</html>
|
<Toaster />
|
||||||
);
|
</AuthProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
34
frontend/src/app/not-found.tsx
Normal file
34
frontend/src/app/not-found.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
14
frontend/src/app/robots.ts
Normal file
14
frontend/src/app/robots.ts
Normal 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`,
|
||||||
|
};
|
||||||
|
}
|
||||||
45
frontend/src/app/sitemap.ts
Normal file
45
frontend/src/app/sitemap.ts
Normal 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;
|
||||||
|
}
|
||||||
243
frontend/src/components/app-sidebar.tsx
Normal file
243
frontend/src/components/app-sidebar.tsx
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import {
|
||||||
|
Home,
|
||||||
|
TrendingUp,
|
||||||
|
Clock,
|
||||||
|
LayoutGrid,
|
||||||
|
PlusCircle,
|
||||||
|
Settings,
|
||||||
|
HelpCircle,
|
||||||
|
ChevronRight,
|
||||||
|
LogOut,
|
||||||
|
User as UserIcon,
|
||||||
|
LogIn,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarMenuSubButton,
|
||||||
|
SidebarMenuSubItem,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { CategoryService } from "@/services/category.service";
|
||||||
|
import type { Category } from "@/types/content";
|
||||||
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
|
|
||||||
|
const mainNav = [
|
||||||
|
{
|
||||||
|
title: "Accueil",
|
||||||
|
url: "/",
|
||||||
|
icon: Home,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Tendances",
|
||||||
|
url: "/trends",
|
||||||
|
icon: TrendingUp,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Nouveautés",
|
||||||
|
url: "/recent",
|
||||||
|
icon: Clock,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AppSidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { user, logout, isAuthenticated, isLoading } = useAuth();
|
||||||
|
const [categories, setCategories] = React.useState<Category[]>([]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
CategoryService.getAll().then(setCategories).catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sidebar collapsible="icon">
|
||||||
|
<SidebarHeader className="flex items-center justify-center py-4">
|
||||||
|
<Link href="/" className="flex items-center gap-2 font-bold text-xl">
|
||||||
|
<div className="bg-primary text-primary-foreground p-1 rounded">
|
||||||
|
🐐
|
||||||
|
</div>
|
||||||
|
<span className="group-data-[collapsible=icon]:hidden">MemeGoat</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarHeader>
|
||||||
|
<SidebarContent>
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarMenu>
|
||||||
|
{mainNav.map((item) => (
|
||||||
|
<SidebarMenuItem key={item.title}>
|
||||||
|
<SidebarMenuButton
|
||||||
|
asChild
|
||||||
|
isActive={pathname === item.url}
|
||||||
|
tooltip={item.title}
|
||||||
|
>
|
||||||
|
<Link href={item.url}>
|
||||||
|
<item.icon />
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroup>
|
||||||
|
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>Explorer</SidebarGroupLabel>
|
||||||
|
<SidebarMenu>
|
||||||
|
<Collapsible asChild className="group/collapsible">
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<SidebarMenuButton tooltip="Catégories">
|
||||||
|
<LayoutGrid />
|
||||||
|
<span>Catégories</span>
|
||||||
|
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<SidebarMenuSub>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<SidebarMenuSubItem key={category.id}>
|
||||||
|
<SidebarMenuSubButton asChild isActive={pathname === `/category/${category.slug}`}>
|
||||||
|
<Link href={`/category/${category.slug}`}>
|
||||||
|
<span>{category.name}</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuSubButton>
|
||||||
|
</SidebarMenuSubItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenuSub>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</Collapsible>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroup>
|
||||||
|
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>Communauté</SidebarGroupLabel>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton asChild tooltip="Publier">
|
||||||
|
<Link href="/upload">
|
||||||
|
<PlusCircle />
|
||||||
|
<span>Publier</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroup>
|
||||||
|
</SidebarContent>
|
||||||
|
<SidebarFooter>
|
||||||
|
<SidebarMenu>
|
||||||
|
{isAuthenticated && user ? (
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<SidebarMenuButton
|
||||||
|
size="lg"
|
||||||
|
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
|
>
|
||||||
|
<Avatar className="h-8 w-8 rounded-lg">
|
||||||
|
<AvatarImage src={user.avatarUrl} alt={user.username} />
|
||||||
|
<AvatarFallback className="rounded-lg">
|
||||||
|
{user.username.slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
|
||||||
|
<span className="truncate font-semibold">
|
||||||
|
{user.displayName || user.username}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs">{user.email}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="ml-auto size-4 group-data-[collapsible=icon]:hidden" />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||||
|
side="right"
|
||||||
|
align="end"
|
||||||
|
sideOffset={4}
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel className="p-0 font-normal">
|
||||||
|
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||||
|
<Avatar className="h-8 w-8 rounded-lg">
|
||||||
|
<AvatarImage src={user.avatarUrl} alt={user.username} />
|
||||||
|
<AvatarFallback className="rounded-lg">
|
||||||
|
{user.username.slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span className="truncate font-semibold">
|
||||||
|
{user.displayName || user.username}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs">{user.email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/profile" className="flex items-center gap-2">
|
||||||
|
<UserIcon className="size-4" />
|
||||||
|
<span>Profil</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/settings" className="flex items-center gap-2">
|
||||||
|
<Settings className="size-4" />
|
||||||
|
<span>Paramètres</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => logout()}>
|
||||||
|
<LogOut className="size-4 mr-2" />
|
||||||
|
<span>Déconnexion</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
) : (
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton asChild tooltip="Se connecter">
|
||||||
|
<Link href="/login">
|
||||||
|
<LogIn className="size-4" />
|
||||||
|
<span>Se connecter</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
)}
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton asChild tooltip="Aide">
|
||||||
|
<Link href="/help">
|
||||||
|
<HelpCircle />
|
||||||
|
<span>Aide</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
frontend/src/components/category-content.tsx
Normal file
13
frontend/src/components/category-content.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { ContentList } from "@/components/content-list";
|
||||||
|
import { ContentService } from "@/services/content.service";
|
||||||
|
|
||||||
|
export function CategoryContent({ slug }: { slug: string }) {
|
||||||
|
const fetchFn = React.useCallback((p: { limit: number; offset: number }) =>
|
||||||
|
ContentService.getExplore({ ...p, category: slug }),
|
||||||
|
[slug]);
|
||||||
|
|
||||||
|
return <ContentList fetchFn={fetchFn} title={`Catégorie : ${slug}`} />;
|
||||||
|
}
|
||||||
131
frontend/src/components/content-card.tsx
Normal file
131
frontend/src/components/content-card.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Heart, MessageSquare, Share2, MoreHorizontal, Eye } from "lucide-react";
|
||||||
|
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import type { Content } from "@/types/content";
|
||||||
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
|
import { FavoriteService } from "@/services/favorite.service";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface ContentCardProps {
|
||||||
|
content: Content;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContentCard({ content }: ContentCardProps) {
|
||||||
|
const { isAuthenticated, user } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const [isLiked, setIsLiked] = React.useState(false);
|
||||||
|
const [likesCount, setLikesCount] = React.useState(content.favoritesCount);
|
||||||
|
|
||||||
|
const handleLike = async (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
toast.error("Vous devez être connecté pour liker un mème");
|
||||||
|
router.push("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isLiked) {
|
||||||
|
await FavoriteService.remove(content.id);
|
||||||
|
setIsLiked(false);
|
||||||
|
setLikesCount(prev => prev - 1);
|
||||||
|
} else {
|
||||||
|
await FavoriteService.add(content.id);
|
||||||
|
setIsLiked(true);
|
||||||
|
setLikesCount(prev => prev + 1);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Une erreur est survenue");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="overflow-hidden border-none shadow-sm hover:shadow-md transition-shadow">
|
||||||
|
<CardHeader className="p-4 flex flex-row items-center space-y-0 gap-3">
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarImage src={content.author.avatarUrl} />
|
||||||
|
<AvatarFallback>{content.author.username[0].toUpperCase()}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Link href={`/user/${content.author.username}`} className="text-sm font-semibold hover:underline">
|
||||||
|
{content.author.displayName || content.author.username}
|
||||||
|
</Link>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(content.createdAt).toLocaleDateString('fr-FR')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" className="ml-auto h-8 w-8">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0 relative bg-zinc-100 dark:bg-zinc-900 aspect-square flex items-center justify-center">
|
||||||
|
<Link href={`/meme/${content.slug}`} className="w-full h-full relative">
|
||||||
|
{content.type === "image" ? (
|
||||||
|
<Image
|
||||||
|
src={content.url}
|
||||||
|
alt={content.title}
|
||||||
|
fill
|
||||||
|
className="object-contain"
|
||||||
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<video
|
||||||
|
src={content.url}
|
||||||
|
controls={false}
|
||||||
|
autoPlay
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="p-4 flex flex-col gap-4">
|
||||||
|
<div className="w-full flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={`gap-1.5 h-8 ${isLiked ? 'text-red-500 hover:text-red-600' : ''}`}
|
||||||
|
onClick={handleLike}
|
||||||
|
>
|
||||||
|
<Heart className={`h-4 w-4 ${isLiked ? 'fill-current' : ''}`} />
|
||||||
|
<span className="text-xs">{likesCount}</span>
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="gap-1.5 h-8">
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
<span className="text-xs">{content.views}</span>
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||||
|
<Share2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="secondary" className="text-xs h-8">
|
||||||
|
Utiliser
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full space-y-2">
|
||||||
|
<h3 className="font-medium text-sm line-clamp-2">{content.title}</h3>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{content.tags.slice(0, 3).map((tag, i) => (
|
||||||
|
<Badge key={typeof tag === 'string' ? tag : tag.id} variant="secondary" className="text-[10px] py-0 px-1.5">
|
||||||
|
#{typeof tag === 'string' ? tag : tag.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
frontend/src/components/content-list.tsx
Normal file
89
frontend/src/components/content-list.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { ContentCard } from "@/components/content-card";
|
||||||
|
import { ContentService } from "@/services/content.service";
|
||||||
|
import type { Content, PaginatedResponse } from "@/types/content";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
|
||||||
|
import { useInfiniteScroll } from "@/app/(dashboard)/_hooks/use-infinite-scroll";
|
||||||
|
|
||||||
|
interface ContentListProps {
|
||||||
|
fetchFn: (params: { limit: number; offset: number }) => Promise<PaginatedResponse<Content>>;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContentList({ fetchFn, title }: ContentListProps) {
|
||||||
|
const [contents, setContents] = React.useState<Content[]>([]);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [offset, setOffset] = React.useState(0);
|
||||||
|
const [hasMore, setHasMore] = React.useState(true);
|
||||||
|
|
||||||
|
const loadMore = React.useCallback(async () => {
|
||||||
|
if (!hasMore || loading) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetchFn({
|
||||||
|
limit: 10,
|
||||||
|
offset: offset + 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
setContents(prev => [...prev, ...response.data]);
|
||||||
|
setOffset(prev => prev + 10);
|
||||||
|
setHasMore(response.data.length === 10);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load more contents:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [offset, hasMore, loading, fetchFn]);
|
||||||
|
|
||||||
|
const { loaderRef } = useInfiniteScroll({
|
||||||
|
hasMore,
|
||||||
|
loading,
|
||||||
|
onLoadMore: loadMore,
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const fetchInitial = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetchFn({
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
setContents(response.data);
|
||||||
|
setHasMore(response.data.length === 10);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch contents:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchInitial();
|
||||||
|
}, [fetchFn]);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto py-8 px-4 space-y-8">
|
||||||
|
{title && <h1 className="text-2xl font-bold">{title}</h1>}
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{contents.map((content) => (
|
||||||
|
<ContentCard key={content.id} content={content} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={loaderRef} className="py-8 flex justify-center">
|
||||||
|
{loading && <Spinner className="h-8 w-8 text-primary" />}
|
||||||
|
{!hasMore && contents.length > 0 && (
|
||||||
|
<p className="text-muted-foreground text-sm italic">Vous avez atteint la fin ! 🐐</p>
|
||||||
|
)}
|
||||||
|
{!loading && contents.length === 0 && (
|
||||||
|
<p className="text-muted-foreground text-sm italic">Aucun mème trouvé ici... pour l'instant !</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
frontend/src/components/content-skeleton.tsx
Normal file
35
frontend/src/components/content-skeleton.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
export function ContentSkeleton() {
|
||||||
|
return (
|
||||||
|
<Card className="overflow-hidden border-none shadow-sm">
|
||||||
|
<CardHeader className="p-4 flex flex-row items-center space-y-0 gap-3">
|
||||||
|
<Skeleton className="h-8 w-8 rounded-full" />
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
<Skeleton className="h-2 w-16" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0 aspect-square">
|
||||||
|
<Skeleton className="h-full w-full" />
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="p-4 flex flex-col gap-4">
|
||||||
|
<div className="w-full flex justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Skeleton className="h-8 w-12 rounded-md" />
|
||||||
|
<Skeleton className="h-8 w-12 rounded-md" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-8 w-20 rounded-md" />
|
||||||
|
</div>
|
||||||
|
<div className="w-full space-y-2">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Skeleton className="h-4 w-12" />
|
||||||
|
<Skeleton className="h-4 w-12" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
frontend/src/components/home-content.tsx
Normal file
35
frontend/src/components/home-content.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { ContentList } from "@/components/content-list";
|
||||||
|
import { ContentService } from "@/services/content.service";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
export function HomeContent() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const sort = (searchParams.get("sort") as "trend" | "recent") || "trend";
|
||||||
|
const category = searchParams.get("category") || undefined;
|
||||||
|
const tag = searchParams.get("tag") || undefined;
|
||||||
|
const query = searchParams.get("query") || undefined;
|
||||||
|
|
||||||
|
const fetchFn = React.useCallback((params: { limit: number; offset: number }) =>
|
||||||
|
ContentService.getExplore({
|
||||||
|
...params,
|
||||||
|
sort,
|
||||||
|
category,
|
||||||
|
tag,
|
||||||
|
query
|
||||||
|
}),
|
||||||
|
[sort, category, tag, query]);
|
||||||
|
|
||||||
|
const title = query
|
||||||
|
? `Résultats pour "${query}"`
|
||||||
|
: category
|
||||||
|
? `Catégorie : ${category}`
|
||||||
|
: sort === "trend"
|
||||||
|
? "Tendances du moment"
|
||||||
|
: "Nouveautés";
|
||||||
|
|
||||||
|
return <ContentList fetchFn={fetchFn} title={title} />;
|
||||||
|
}
|
||||||
146
frontend/src/components/mobile-filters.tsx
Normal file
146
frontend/src/components/mobile-filters.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Filter, Search } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||||
|
import { CategoryService } from "@/services/category.service";
|
||||||
|
import type { Category } from "@/types/content";
|
||||||
|
|
||||||
|
export function MobileFilters() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const [categories, setCategories] = React.useState<Category[]>([]);
|
||||||
|
const [query, setQuery] = React.useState(searchParams.get("query") || "");
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
CategoryService.getAll().then(setCategories).catch(console.error);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const updateSearch = React.useCallback((name: string, value: string | null) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (value) {
|
||||||
|
params.set(name, value);
|
||||||
|
} else {
|
||||||
|
params.delete(name);
|
||||||
|
}
|
||||||
|
router.push(`${pathname}?${params.toString()}`);
|
||||||
|
}, [router, pathname, searchParams]);
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
updateSearch("query", query);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentSort = searchParams.get("sort") || "trend";
|
||||||
|
const currentCategory = searchParams.get("category");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lg:hidden fixed top-4 right-4 z-50">
|
||||||
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button size="icon" className="rounded-full shadow-lg h-12 w-12">
|
||||||
|
<Filter className="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="right" className="w-[300px] sm:w-[400px]">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>Recherche & Filtres</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="mt-6 space-y-6">
|
||||||
|
<form onSubmit={handleSearch} className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Rechercher des mèmes..."
|
||||||
|
className="pl-8"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ScrollArea className="h-[calc(100vh-200px)]">
|
||||||
|
<div className="space-y-6 pr-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-3">Trier par</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
variant={currentSort === "trend" ? "default" : "outline"}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => updateSearch("sort", "trend")}
|
||||||
|
>
|
||||||
|
Tendances
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant={currentSort === "recent" ? "default" : "outline"}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => updateSearch("sort", "recent")}
|
||||||
|
>
|
||||||
|
Récent
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-3">Catégories</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
variant={!currentCategory ? "default" : "outline"}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => updateSearch("category", null)}
|
||||||
|
>
|
||||||
|
Tout
|
||||||
|
</Badge>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<Badge
|
||||||
|
key={cat.id}
|
||||||
|
variant={currentCategory === cat.slug ? "default" : "outline"}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => updateSearch("category", cat.slug)}
|
||||||
|
>
|
||||||
|
{cat.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-3">Tags populaires</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{["funny", "coding", "cat", "dog", "work", "relatable", "gaming"].map(tag => (
|
||||||
|
<Badge
|
||||||
|
key={tag}
|
||||||
|
variant={searchParams.get("tag") === tag ? "default" : "outline"}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => updateSearch("tag", searchParams.get("tag") === tag ? null : tag)}
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
frontend/src/components/search-sidebar.tsx
Normal file
132
frontend/src/components/search-sidebar.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Search, Filter } from "lucide-react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||||
|
import { CategoryService } from "@/services/category.service";
|
||||||
|
import type { Category } from "@/types/content";
|
||||||
|
|
||||||
|
export function SearchSidebar() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const [categories, setCategories] = React.useState<Category[]>([]);
|
||||||
|
const [query, setQuery] = React.useState(searchParams.get("query") || "");
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
CategoryService.getAll().then(setCategories).catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateSearch = React.useCallback((name: string, value: string | null) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (value) {
|
||||||
|
params.set(name, value);
|
||||||
|
} else {
|
||||||
|
params.delete(name);
|
||||||
|
}
|
||||||
|
// If we are not on explore/trends/recent, maybe we should redirect to home?
|
||||||
|
// For now, let's just update the URL.
|
||||||
|
router.push(`${pathname}?${params.toString()}`);
|
||||||
|
}, [router, pathname, searchParams]);
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
updateSearch("query", query);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentSort = searchParams.get("sort") || "trend";
|
||||||
|
const currentCategory = searchParams.get("category");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="hidden lg:flex flex-col w-80 border-l bg-background">
|
||||||
|
<div className="p-4 border-b">
|
||||||
|
<h2 className="font-semibold mb-4">Rechercher</h2>
|
||||||
|
<form onSubmit={handleSearch} className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Rechercher des mèmes..."
|
||||||
|
className="pl-8"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="flex-1 p-4">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
|
Filtres
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">Trier par</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
variant={currentSort === "trend" ? "default" : "outline"}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => updateSearch("sort", "trend")}
|
||||||
|
>
|
||||||
|
Tendances
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant={currentSort === "recent" ? "default" : "outline"}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => updateSearch("sort", "recent")}
|
||||||
|
>
|
||||||
|
Récent
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">Catégories</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
variant={!currentCategory ? "default" : "outline"}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => updateSearch("category", null)}
|
||||||
|
>
|
||||||
|
Tout
|
||||||
|
</Badge>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<Badge
|
||||||
|
key={cat.id}
|
||||||
|
variant={currentCategory === cat.slug ? "default" : "outline"}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => updateSearch("category", cat.slug)}
|
||||||
|
>
|
||||||
|
{cat.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-3">Tags populaires</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{["funny", "coding", "cat", "dog", "work", "relatable", "gaming"].map(tag => (
|
||||||
|
<Badge
|
||||||
|
key={tag}
|
||||||
|
variant={searchParams.get("tag") === tag ? "default" : "outline"}
|
||||||
|
className="cursor-pointer hover:bg-secondary"
|
||||||
|
onClick={() => updateSearch("tag", searchParams.get("tag") === tag ? null : tag)}
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
frontend/src/lib/api.ts
Normal file
11
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000",
|
||||||
|
withCredentials: true,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default api;
|
||||||
99
frontend/src/providers/auth-provider.tsx
Normal file
99
frontend/src/providers/auth-provider.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { UserService } from "@/services/user.service";
|
||||||
|
import { AuthService } from "@/services/auth.service";
|
||||||
|
import type { User } from "@/types/user";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
register: (payload: any) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
refreshUser: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = React.createContext<AuthContextType | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [user, setUser] = React.useState<User | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const refreshUser = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const userData = await UserService.getMe();
|
||||||
|
setUser(userData);
|
||||||
|
} catch (error) {
|
||||||
|
setUser(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
refreshUser();
|
||||||
|
}, [refreshUser]);
|
||||||
|
|
||||||
|
const login = async (email: string, password: string) => {
|
||||||
|
try {
|
||||||
|
await AuthService.login(email, password);
|
||||||
|
await refreshUser();
|
||||||
|
toast.success("Connexion réussie !");
|
||||||
|
router.push("/");
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.message || "Erreur de connexion");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = async (payload: any) => {
|
||||||
|
try {
|
||||||
|
await AuthService.register(payload);
|
||||||
|
toast.success("Inscription réussie ! Vous pouvez maintenant vous connecter.");
|
||||||
|
router.push("/login");
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.message || "Erreur d'inscription");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
await AuthService.logout();
|
||||||
|
setUser(null);
|
||||||
|
toast.success("Déconnexion réussie");
|
||||||
|
router.push("/");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Erreur lors de la déconnexion");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
user,
|
||||||
|
isLoading,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
refreshUser,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = React.useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useAuth must be used within an AuthProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
22
frontend/src/services/auth.service.ts
Normal file
22
frontend/src/services/auth.service.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import api from "@/lib/api";
|
||||||
|
import type { LoginResponse } from "@/types/auth";
|
||||||
|
|
||||||
|
export const AuthService = {
|
||||||
|
async login(email: string, password: string): Promise<LoginResponse> {
|
||||||
|
const { data } = await api.post<LoginResponse>("/auth/login", { email, password });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async register(payload: any): Promise<any> {
|
||||||
|
const { data } = await api.post("/auth/register", payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
await api.post("/auth/logout");
|
||||||
|
},
|
||||||
|
|
||||||
|
async refresh(): Promise<void> {
|
||||||
|
await api.post("/auth/refresh");
|
||||||
|
},
|
||||||
|
};
|
||||||
14
frontend/src/services/category.service.ts
Normal file
14
frontend/src/services/category.service.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import api from "@/lib/api";
|
||||||
|
import type { Category } from "@/types/content";
|
||||||
|
|
||||||
|
export const CategoryService = {
|
||||||
|
async getAll(): Promise<Category[]> {
|
||||||
|
const { data } = await api.get<Category[]>("/categories");
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getOne(id: string): Promise<Category> {
|
||||||
|
const { data } = await api.get<Category>(`/categories/${id}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
54
frontend/src/services/content.service.ts
Normal file
54
frontend/src/services/content.service.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import api from "@/lib/api";
|
||||||
|
import type { Content, PaginatedResponse } from "@/types/content";
|
||||||
|
|
||||||
|
export const ContentService = {
|
||||||
|
async getExplore(params: {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
sort?: "trend" | "recent";
|
||||||
|
tag?: string;
|
||||||
|
category?: string;
|
||||||
|
query?: string;
|
||||||
|
}): Promise<PaginatedResponse<Content>> {
|
||||||
|
const { data } = await api.get<PaginatedResponse<Content>>("/contents/explore", {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getTrends(limit = 10, offset = 0): Promise<PaginatedResponse<Content>> {
|
||||||
|
const { data } = await api.get<PaginatedResponse<Content>>("/contents/trends", {
|
||||||
|
params: { limit, offset },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRecent(limit = 10, offset = 0): Promise<PaginatedResponse<Content>> {
|
||||||
|
const { data } = await api.get<PaginatedResponse<Content>>("/contents/recent", {
|
||||||
|
params: { limit, offset },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getOne(idOrSlug: string): Promise<Content> {
|
||||||
|
const { data } = await api.get<Content>(`/contents/${idOrSlug}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async incrementViews(id: string): Promise<void> {
|
||||||
|
await api.post(`/contents/${id}/view`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async incrementUsage(id: string): Promise<void> {
|
||||||
|
await api.post(`/contents/${id}/use`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async upload(formData: FormData): Promise<Content> {
|
||||||
|
const { data } = await api.post<Content>("/contents/upload", formData, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
17
frontend/src/services/favorite.service.ts
Normal file
17
frontend/src/services/favorite.service.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import api from "@/lib/api";
|
||||||
|
import type { Content, PaginatedResponse } from "@/types/content";
|
||||||
|
|
||||||
|
export const FavoriteService = {
|
||||||
|
async add(contentId: string): Promise<void> {
|
||||||
|
await api.post(`/favorites/${contentId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(contentId: string): Promise<void> {
|
||||||
|
await api.delete(`/favorites/${contentId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async list(params: { limit: number; offset: number }): Promise<PaginatedResponse<Content>> {
|
||||||
|
const { data } = await api.get<PaginatedResponse<Content>>("/favorites", { params });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
19
frontend/src/services/user.service.ts
Normal file
19
frontend/src/services/user.service.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import api from "@/lib/api";
|
||||||
|
import type { User, UserProfile } from "@/types/user";
|
||||||
|
|
||||||
|
export const UserService = {
|
||||||
|
async getMe(): Promise<User> {
|
||||||
|
const { data } = await api.get<User>("/users/me");
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getProfile(username: string): Promise<User> {
|
||||||
|
const { data } = await api.get<User>(`/users/public/${username}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateMe(update: Partial<User>): Promise<User> {
|
||||||
|
const { data } = await api.patch<User>("/users/me", update);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
15
frontend/src/types/auth.ts
Normal file
15
frontend/src/types/auth.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export interface LoginResponse {
|
||||||
|
message: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthStatus {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
user: null | {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
};
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
46
frontend/src/types/content.ts
Normal file
46
frontend/src/types/content.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { User } from "./user";
|
||||||
|
|
||||||
|
export interface Content {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
description?: string;
|
||||||
|
url: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
type: "image" | "video";
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
duration?: number;
|
||||||
|
views: number;
|
||||||
|
usageCount: number;
|
||||||
|
favoritesCount: number;
|
||||||
|
tags: (string | Tag)[];
|
||||||
|
category?: Category;
|
||||||
|
authorId: string;
|
||||||
|
author: User;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tag {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
data: T[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
15
frontend/src/types/user.ts
Normal file
15
frontend/src/types/user.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
displayName?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
role: "user" | "admin";
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfile extends User {
|
||||||
|
bio?: string;
|
||||||
|
favoritesCount: number;
|
||||||
|
uploadsCount: number;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user