diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 8bfbb83..ef10474 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,22 +1,66 @@ -FROM node:22-slim AS base -ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" -RUN corepack enable +# syntax=docker.io/docker/dockerfile:1 -FROM base AS build -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 pnpm/pnpm:20-alpine AS base -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 -COPY --from=build /usr/src/app/frontend/public ./frontend/public -COPY --from=build /usr/src/app/frontend/.next/standalone ./ -COPY --from=build /usr/src/app/frontend/.next/static ./frontend/.next/static + +# Install dependencies based on the preferred package manager +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 -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"] diff --git a/frontend/package.json b/frontend/package.json index de3eae7..a503b1c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -37,6 +37,7 @@ "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", + "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..6f0ecfd --- /dev/null +++ b/frontend/src/app/(auth)/login/page.tsx @@ -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; + +export default function LoginPage() { + const { login } = useAuth(); + const [loading, setLoading] = React.useState(false); + + const form = useForm({ + 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 ( +
+
+ + + Retour à l'accueil + + + + Connexion + + Entrez vos identifiants pour accéder à votre compte MemeGoat. + + + +
+ + ( + + Email + + + + + + )} + /> + ( + + Mot de passe + + + + + + )} + /> + + + +
+ +

+ Vous n'avez pas de compte ?{" "} + + S'inscrire + +

+
+
+
+
+ ); +} diff --git a/frontend/src/app/(auth)/register/page.tsx b/frontend/src/app/(auth)/register/page.tsx new file mode 100644 index 0000000..4dc4370 --- /dev/null +++ b/frontend/src/app/(auth)/register/page.tsx @@ -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; + +export default function RegisterPage() { + const { register } = useAuth(); + const [loading, setLoading] = React.useState(false); + + const form = useForm({ + 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 ( +
+
+ + + Retour à l'accueil + + + + Inscription + + Rejoignez la communauté MemeGoat dès aujourd'hui. + + + +
+ + ( + + Pseudo + + + + + + )} + /> + ( + + Email + + + + + + )} + /> + ( + + Nom d'affichage (Optionnel) + + + + + + )} + /> + ( + + Mot de passe + + + + + + )} + /> + + + +
+ +

+ Vous avez déjà un compte ?{" "} + + Se connecter + +

+
+
+
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/@modal/(.)meme/[slug]/page.tsx b/frontend/src/app/(dashboard)/@modal/(.)meme/[slug]/page.tsx new file mode 100644 index 0000000..9b6d733 --- /dev/null +++ b/frontend/src/app/(dashboard)/@modal/(.)meme/[slug]/page.tsx @@ -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(null); + const [loading, setLoading] = React.useState(true); + + React.useEffect(() => { + ContentService.getOne(slug) + .then(setContent) + .catch(console.error) + .finally(() => setLoading(false)); + }, [slug]); + + return ( + !open && router.back()}> + + {content?.title || "Détail du mème"} + Affiche le mème en grand avec ses détails + {loading ? ( +
+ +
+ ) : content ? ( +
+ +
+ ) : ( +
+

Impossible de charger ce mème.

+
+ )} +
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/@modal/default.tsx b/frontend/src/app/(dashboard)/@modal/default.tsx new file mode 100644 index 0000000..6ddf1b7 --- /dev/null +++ b/frontend/src/app/(dashboard)/@modal/default.tsx @@ -0,0 +1,3 @@ +export default function Default() { + return null; +} diff --git a/frontend/src/app/(dashboard)/_hooks/use-infinite-scroll.ts b/frontend/src/app/(dashboard)/_hooks/use-infinite-scroll.ts new file mode 100644 index 0000000..eef3110 --- /dev/null +++ b/frontend/src/app/(dashboard)/_hooks/use-infinite-scroll.ts @@ -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(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 }; +} diff --git a/frontend/src/app/(dashboard)/category/[slug]/page.tsx b/frontend/src/app/(dashboard)/category/[slug]/page.tsx new file mode 100644 index 0000000..e7b44bd --- /dev/null +++ b/frontend/src/app/(dashboard)/category/[slug]/page.tsx @@ -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 { + 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 ; +} diff --git a/frontend/src/app/(dashboard)/category/page.tsx b/frontend/src/app/(dashboard)/category/page.tsx new file mode 100644 index 0000000..94a5378 --- /dev/null +++ b/frontend/src/app/(dashboard)/category/page.tsx @@ -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([]); + const [loading, setLoading] = React.useState(true); + + React.useEffect(() => { + CategoryService.getAll() + .then(setCategories) + .finally(() => setLoading(false)); + }, []); + + return ( +
+
+ +

Catégories

+
+ +
+ {loading ? ( + Array.from({ length: 6 }).map((_, i) => ( + + + + + )) + ) : ( + categories.map((category) => ( + + + + {category.name} + + +

+ {category.description || `Découvrez tous les mèmes de la catégorie ${category.name}.`} +

+
+
+ + )) + )} +
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/layout.tsx b/frontend/src/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..671e9cf --- /dev/null +++ b/frontend/src/app/(dashboard)/layout.tsx @@ -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 ( + + + +
+
+ +
+
+
+ {children} + {modal} +
+ +
+ +
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/loading.tsx b/frontend/src/app/(dashboard)/loading.tsx new file mode 100644 index 0000000..8d18edb --- /dev/null +++ b/frontend/src/app/(dashboard)/loading.tsx @@ -0,0 +1,13 @@ +import { ContentSkeleton } from "@/components/content-skeleton"; + +export default function Loading() { + return ( +
+
+ {[...Array(3)].map((_, i) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/meme/[slug]/page.tsx b/frontend/src/app/(dashboard)/meme/[slug]/page.tsx new file mode 100644 index 0000000..8cba663 --- /dev/null +++ b/frontend/src/app/(dashboard)/meme/[slug]/page.tsx @@ -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 { + 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 ( +
+ + + Retour au flux + + +
+
+ +
+ +
+
+

À propos de ce mème

+
+
+

Publié par

+

{content.author.displayName || content.author.username}

+
+
+

Date

+

{new Date(content.createdAt).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'long', + year: 'numeric' + })}

+
+ {content.description && ( +
+

Description

+

{content.description}

+
+ )} +
+
+ +
+

Envie de créer votre propre mème ?

+ +
+
+
+
+ ); + } catch (error) { + notFound(); + } +} diff --git a/frontend/src/app/(dashboard)/page.tsx b/frontend/src/app/(dashboard)/page.tsx new file mode 100644 index 0000000..cf4b60c --- /dev/null +++ b/frontend/src/app/(dashboard)/page.tsx @@ -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 ( + + + + }> + + + ); +} diff --git a/frontend/src/app/(dashboard)/profile/page.tsx b/frontend/src/app/(dashboard)/profile/page.tsx new file mode 100644 index 0000000..14d09fa --- /dev/null +++ b/frontend/src/app/(dashboard)/profile/page.tsx @@ -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 ( +
+
+
+ + + + {user.username.slice(0, 2).toUpperCase()} + + +
+
+

{user.displayName || user.username}

+

@{user.username}

+
+
+ + + Membre depuis {new Date(user.createdAt).toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' })} + +
+
+ + +
+
+
+
+ + + + Mes Mèmes + Mes Favoris + + + + + + + + +
+ ); +} diff --git a/frontend/src/app/(dashboard)/recent/page.tsx b/frontend/src/app/(dashboard)/recent/page.tsx new file mode 100644 index 0000000..c892c3d --- /dev/null +++ b/frontend/src/app/(dashboard)/recent/page.tsx @@ -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 ; +} diff --git a/frontend/src/app/(dashboard)/trends/page.tsx b/frontend/src/app/(dashboard)/trends/page.tsx new file mode 100644 index 0000000..1038a9a --- /dev/null +++ b/frontend/src/app/(dashboard)/trends/page.tsx @@ -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 ; +} diff --git a/frontend/src/app/(dashboard)/upload/page.tsx b/frontend/src/app/(dashboard)/upload/page.tsx new file mode 100644 index 0000000..27a18b5 --- /dev/null +++ b/frontend/src/app/(dashboard)/upload/page.tsx @@ -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; + +export default function UploadPage() { + const router = useRouter(); + const [categories, setCategories] = React.useState([]); + const [file, setFile] = React.useState(null); + const [preview, setPreview] = React.useState(null); + const [isUploading, setIsUploading] = React.useState(false); + + const form = useForm({ + resolver: zodResolver(uploadSchema), + defaultValues: { + title: "", + type: "meme", + tags: "", + }, + }); + + React.useEffect(() => { + CategoryService.getAll().then(setCategories).catch(console.error); + }, []); + + const handleFileChange = (e: React.ChangeEvent) => { + 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 ( +
+ + + + + Partager un mème + + + +
+ +
+ Fichier (Image ou GIF) + {!preview ? ( +
document.getElementById("file-upload")?.click()} + > +
+ +
+

Cliquez pour choisir un fichier

+

PNG, JPG ou GIF jusqu'à 10Mo

+ +
+ ) : ( +
+ Preview + +
+ )} +
+ + ( + + Titre + + + + + + )} + /> + +
+ ( + + Format + + + + )} + /> + + ( + + Catégorie + + + + )} + /> +
+ + ( + + Tags + + + + + Séparez les tags par des virgules. + + + + )} + /> + + + + +
+
+
+ ); +} diff --git a/frontend/src/app/error.tsx b/frontend/src/app/error.tsx new file mode 100644 index 0000000..6b6e2ba --- /dev/null +++ b/frontend/src/app/error.tsx @@ -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 ( +
+
+
+
+ +
+
+

Oups ! Une erreur est survenue

+

+ La chèvre a glissé sur une peau de banane. Nous essayons de la remettre sur pied. +

+
+ + +
+
+
+ ); +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 76f6fd8..0b3f5db 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,35 +1,40 @@ import type { Metadata } from "next"; import { Ubuntu_Mono, Ubuntu_Sans } from "next/font/google"; +import { Toaster } from "@/components/ui/sonner"; +import { AuthProvider } from "@/providers/auth-provider"; import "./globals.css"; const ubuntuSans = Ubuntu_Sans({ - variable: "--font-ubuntu-sans", - subsets: ["latin"], + variable: "--font-ubuntu-sans", + subsets: ["latin"], }); const ubuntuMono = Ubuntu_Mono({ - variable: "--font-geist-mono", - weight: ["400", "700"], - subsets: ["latin"], + variable: "--font-geist-mono", + weight: ["400", "700"], + subsets: ["latin"], }); export const metadata: Metadata = { - title: "MemeGoat", - icons: "/memegoat-color.svg", + title: "MemeGoat", + icons: "/memegoat-color.svg", }; export default function RootLayout({ - children, + children, }: Readonly<{ - children: React.ReactNode; + children: React.ReactNode; }>) { - return ( - - - {children} - - - ); + return ( + + + + {children} + + + + + ); } diff --git a/frontend/src/app/not-found.tsx b/frontend/src/app/not-found.tsx new file mode 100644 index 0000000..55caf8f --- /dev/null +++ b/frontend/src/app/not-found.tsx @@ -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 ( +
+
+
+
+ +
+
+

404 - Perdu dans le troupeau ?

+

+ On dirait que ce mème s'est enfui. La chèvre ne l'a pas trouvé. +

+
+ +
+
+
+ 🐐 +
+
+ ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx deleted file mode 100644 index ceaaf9f..0000000 --- a/frontend/src/app/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -export default function Home() { - return ( -
-
-

Hello world !

-
-
- ); -} diff --git a/frontend/src/app/robots.ts b/frontend/src/app/robots.ts new file mode 100644 index 0000000..59735bd --- /dev/null +++ b/frontend/src/app/robots.ts @@ -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`, + }; +} diff --git a/frontend/src/app/sitemap.ts b/frontend/src/app/sitemap.ts new file mode 100644 index 0000000..81dfc3f --- /dev/null +++ b/frontend/src/app/sitemap.ts @@ -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 { + 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; +} diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx new file mode 100644 index 0000000..34dec5d --- /dev/null +++ b/frontend/src/components/app-sidebar.tsx @@ -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([]); + + React.useEffect(() => { + CategoryService.getAll().then(setCategories).catch(console.error); + }, []); + + return ( + + + +
+ 🐐 +
+ MemeGoat + +
+ + + + {mainNav.map((item) => ( + + + + + {item.title} + + + + ))} + + + + + Explorer + + + + + + + Catégories + + + + + + {categories.map((category) => ( + + + + {category.name} + + + + ))} + + + + + + + + + Communauté + + + + + + Publier + + + + + + + + + {isAuthenticated && user ? ( + + + + + + + + {user.username.slice(0, 2).toUpperCase()} + + +
+ + {user.displayName || user.username} + + {user.email} +
+ +
+
+ + +
+ + + + {user.username.slice(0, 2).toUpperCase()} + + +
+ + {user.displayName || user.username} + + {user.email} +
+
+
+ + + + + Profil + + + + + + Paramètres + + + + logout()}> + + Déconnexion + +
+
+
+ ) : ( + + + + + Se connecter + + + + )} + + + + + Aide + + + +
+
+
+ ); +} diff --git a/frontend/src/components/category-content.tsx b/frontend/src/components/category-content.tsx new file mode 100644 index 0000000..9c05566 --- /dev/null +++ b/frontend/src/components/category-content.tsx @@ -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 ; +} diff --git a/frontend/src/components/content-card.tsx b/frontend/src/components/content-card.tsx new file mode 100644 index 0000000..14a74eb --- /dev/null +++ b/frontend/src/components/content-card.tsx @@ -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 ( + + + + + {content.author.username[0].toUpperCase()} + +
+ + {content.author.displayName || content.author.username} + + + {new Date(content.createdAt).toLocaleDateString('fr-FR')} + +
+ +
+ + + {content.type === "image" ? ( + {content.title} + ) : ( + + +
+
+ + + +
+ +
+ +
+

{content.title}

+
+ {content.tags.slice(0, 3).map((tag, i) => ( + + #{typeof tag === 'string' ? tag : tag.name} + + ))} +
+
+
+
+ ); +} diff --git a/frontend/src/components/content-list.tsx b/frontend/src/components/content-list.tsx new file mode 100644 index 0000000..7021215 --- /dev/null +++ b/frontend/src/components/content-list.tsx @@ -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>; + title?: string; +} + +export function ContentList({ fetchFn, title }: ContentListProps) { + const [contents, setContents] = React.useState([]); + 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 ( +
+ {title &&

{title}

} +
+ {contents.map((content) => ( + + ))} +
+ +
+ {loading && } + {!hasMore && contents.length > 0 && ( +

Vous avez atteint la fin ! 🐐

+ )} + {!loading && contents.length === 0 && ( +

Aucun mème trouvé ici... pour l'instant !

+ )} +
+
+ ); +} diff --git a/frontend/src/components/content-skeleton.tsx b/frontend/src/components/content-skeleton.tsx new file mode 100644 index 0000000..f0605d3 --- /dev/null +++ b/frontend/src/components/content-skeleton.tsx @@ -0,0 +1,35 @@ +import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +export function ContentSkeleton() { + return ( + + + +
+ + +
+
+ + + + +
+
+ + +
+ +
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/home-content.tsx b/frontend/src/components/home-content.tsx new file mode 100644 index 0000000..a785021 --- /dev/null +++ b/frontend/src/components/home-content.tsx @@ -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 ; +} diff --git a/frontend/src/components/mobile-filters.tsx b/frontend/src/components/mobile-filters.tsx new file mode 100644 index 0000000..cc60f5f --- /dev/null +++ b/frontend/src/components/mobile-filters.tsx @@ -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([]); + 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 ( +
+ + + + + + + Recherche & Filtres + +
+
+ + setQuery(e.target.value)} + /> + + + +
+
+

Trier par

+
+ updateSearch("sort", "trend")} + > + Tendances + + updateSearch("sort", "recent")} + > + Récent + +
+
+ +
+

Catégories

+
+ updateSearch("category", null)} + > + Tout + + {categories.map((cat) => ( + updateSearch("category", cat.slug)} + > + {cat.name} + + ))} +
+
+ +
+

Tags populaires

+
+ {["funny", "coding", "cat", "dog", "work", "relatable", "gaming"].map(tag => ( + updateSearch("tag", searchParams.get("tag") === tag ? null : tag)} + > + #{tag} + + ))} +
+
+
+
+
+
+
+
+ ); +} diff --git a/frontend/src/components/search-sidebar.tsx b/frontend/src/components/search-sidebar.tsx new file mode 100644 index 0000000..1794281 --- /dev/null +++ b/frontend/src/components/search-sidebar.tsx @@ -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([]); + 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 ( + + ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..48caa04 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -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; diff --git a/frontend/src/providers/auth-provider.tsx b/frontend/src/providers/auth-provider.tsx new file mode 100644 index 0000000..4c7ee29 --- /dev/null +++ b/frontend/src/providers/auth-provider.tsx @@ -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; + register: (payload: any) => Promise; + logout: () => Promise; + refreshUser: () => Promise; +} + +const AuthContext = React.createContext(null); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = React.useState(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 ( + + {children} + + ); +} + +export const useAuth = () => { + const context = React.useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +}; diff --git a/frontend/src/services/auth.service.ts b/frontend/src/services/auth.service.ts new file mode 100644 index 0000000..5a81a6c --- /dev/null +++ b/frontend/src/services/auth.service.ts @@ -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 { + const { data } = await api.post("/auth/login", { email, password }); + return data; + }, + + async register(payload: any): Promise { + const { data } = await api.post("/auth/register", payload); + return data; + }, + + async logout(): Promise { + await api.post("/auth/logout"); + }, + + async refresh(): Promise { + await api.post("/auth/refresh"); + }, +}; diff --git a/frontend/src/services/category.service.ts b/frontend/src/services/category.service.ts new file mode 100644 index 0000000..ca36a71 --- /dev/null +++ b/frontend/src/services/category.service.ts @@ -0,0 +1,14 @@ +import api from "@/lib/api"; +import type { Category } from "@/types/content"; + +export const CategoryService = { + async getAll(): Promise { + const { data } = await api.get("/categories"); + return data; + }, + + async getOne(id: string): Promise { + const { data } = await api.get(`/categories/${id}`); + return data; + }, +}; diff --git a/frontend/src/services/content.service.ts b/frontend/src/services/content.service.ts new file mode 100644 index 0000000..f5f6b0a --- /dev/null +++ b/frontend/src/services/content.service.ts @@ -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> { + const { data } = await api.get>("/contents/explore", { + params, + }); + return data; + }, + + async getTrends(limit = 10, offset = 0): Promise> { + const { data } = await api.get>("/contents/trends", { + params: { limit, offset }, + }); + return data; + }, + + async getRecent(limit = 10, offset = 0): Promise> { + const { data } = await api.get>("/contents/recent", { + params: { limit, offset }, + }); + return data; + }, + + async getOne(idOrSlug: string): Promise { + const { data } = await api.get(`/contents/${idOrSlug}`); + return data; + }, + + async incrementViews(id: string): Promise { + await api.post(`/contents/${id}/view`); + }, + + async incrementUsage(id: string): Promise { + await api.post(`/contents/${id}/use`); + }, + + async upload(formData: FormData): Promise { + const { data } = await api.post("/contents/upload", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + return data; + }, +}; diff --git a/frontend/src/services/favorite.service.ts b/frontend/src/services/favorite.service.ts new file mode 100644 index 0000000..5769588 --- /dev/null +++ b/frontend/src/services/favorite.service.ts @@ -0,0 +1,17 @@ +import api from "@/lib/api"; +import type { Content, PaginatedResponse } from "@/types/content"; + +export const FavoriteService = { + async add(contentId: string): Promise { + await api.post(`/favorites/${contentId}`); + }, + + async remove(contentId: string): Promise { + await api.delete(`/favorites/${contentId}`); + }, + + async list(params: { limit: number; offset: number }): Promise> { + const { data } = await api.get>("/favorites", { params }); + return data; + }, +}; diff --git a/frontend/src/services/user.service.ts b/frontend/src/services/user.service.ts new file mode 100644 index 0000000..cbd9525 --- /dev/null +++ b/frontend/src/services/user.service.ts @@ -0,0 +1,19 @@ +import api from "@/lib/api"; +import type { User, UserProfile } from "@/types/user"; + +export const UserService = { + async getMe(): Promise { + const { data } = await api.get("/users/me"); + return data; + }, + + async getProfile(username: string): Promise { + const { data } = await api.get(`/users/public/${username}`); + return data; + }, + + async updateMe(update: Partial): Promise { + const { data } = await api.patch("/users/me", update); + return data; + }, +}; diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts new file mode 100644 index 0000000..51ff195 --- /dev/null +++ b/frontend/src/types/auth.ts @@ -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; +} diff --git a/frontend/src/types/content.ts b/frontend/src/types/content.ts new file mode 100644 index 0000000..e30eda3 --- /dev/null +++ b/frontend/src/types/content.ts @@ -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 { + data: T[]; + total: number; + limit: number; + offset: number; +} diff --git a/frontend/src/types/user.ts b/frontend/src/types/user.ts new file mode 100644 index 0000000..a3ff144 --- /dev/null +++ b/frontend/src/types/user.ts @@ -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; +}