Compare commits

...

8 Commits

Author SHA1 Message Date
Mathis HERRIOT
65f8860cc0 feat(auth): add optional authentication guard and extend AuthModule providers
Some checks failed
Backend Tests / test (push) Failing after 5m0s
Lint / lint (push) Failing after 5m2s
Introduce `OptionalAuthGuard` to allow conditional authentication for routes. Update `AuthModule` to include `AuthGuard`, `OptionalAuthGuard`, and `RolesGuard` in providers and exports for broader reuse.

feat(app): integrate `AdminModule` into app module

Add `AdminModule` to the app's main module to enable administration functionalities.

feat(users): enhance user profiles with bio and avatar fields

Extend `UpdateUserDto` to include optional `bio` and `avatarUrl` fields for better user customization.

feat(categories): add functionality to count all categories

Implement `countAll` method in `CategoriesRepository` to fetch the total number of categories using raw SQL counting.
2026-01-14 21:45:32 +01:00
Mathis HERRIOT
0e9edd4bfc feat(app): enhance metadata and add admin sidebar section
Update app metadata with multilingual support, SEO improvements, and structured OpenGraph and Twitter metadata. Add an "Administration" section in the sidebar for authenticated admin users. Improve role display and enable dynamic sorting in `HomeContent`. Extend UI badges with a success variant.
2026-01-14 21:44:25 +01:00
Mathis HERRIOT
6ce58d1639 feat(admin): implement admin statistics API and service
Add admin statistics endpoint to provide user, content, and category stats. Introduce `AdminModule` with controller, service, and repository integration for data aggregation. Include frontend service to consume the stats API.
2026-01-14 21:44:14 +01:00
Mathis HERRIOT
47d6fcb6a0 feat(media): add image resizing support for processImage
Extend the `processImage` method to support optional resizing with `width` and `height` parameters. Update processing pipeline to handle resizing while maintaining existing format processing for `webp` and `avif`.
2026-01-14 21:44:00 +01:00
Mathis HERRIOT
d7c2a965a0 feat(contents): enhance user-specific data handling and admin content management
Integrate user-specific fields (`isLiked`, `favoritesCount`) in content APIs and improve `ContentCard` through reactive updates. Add admin-only content deletion support. Refactor services and repository to enrich responses with additional data (author details, tags).
2026-01-14 21:43:44 +01:00
Mathis HERRIOT
fb7ddde42e feat(app): add dashboard pages for settings, admin, and public user profiles
Introduce new pages for profile settings, admin dashboard (users, contents, categories), and public user profiles. Enhance profile functionality with avatar uploads and bio updates. Include help and improved content trends/recent pages. Streamline content display using `HomeContent`.
2026-01-14 21:43:27 +01:00
Mathis HERRIOT
026aebaee3 feat(database): add migration snapshot 0006_snapshot.json for schema updates
Capture extensive database schema changes, including new tables and updated relationships for better data management and integrity.
2026-01-14 21:43:10 +01:00
Mathis HERRIOT
a30113e8e2 feat(users): add avatar and bio support, improve user profile handling
Introduce `avatarUrl` and `bio` fields in the user schema. Update repository, service, and controller to handle avatar uploads, processing, and bio updates. Add S3 integration for avatar storage and enhance user data handling for private and public profiles.
2026-01-14 21:42:46 +01:00
42 changed files with 3049 additions and 58 deletions

View File

@@ -0,0 +1,2 @@
ALTER TABLE "users" ADD COLUMN "avatar_url" varchar(512);--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "bio" varchar(255);

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,13 @@
"when": 1768420201679,
"tag": "0005_perpetual_silverclaw",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1768423315172,
"tag": "0006_friendly_adam_warlock",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,17 @@
import { Controller, Get, UseGuards } from "@nestjs/common";
import { AuthGuard } from "../auth/guards/auth.guard";
import { RolesGuard } from "../auth/guards/roles.guard";
import { Roles } from "../auth/decorators/roles.decorator";
import { AdminService } from "./admin.service";
@Controller("admin")
@UseGuards(AuthGuard, RolesGuard)
@Roles("admin")
export class AdminController {
constructor(private readonly adminService: AdminService) {}
@Get("stats")
getStats() {
return this.adminService.getStats();
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { UsersModule } from "../users/users.module";
import { ContentsModule } from "../contents/contents.module";
import { CategoriesModule } from "../categories/categories.module";
import { AdminController } from "./admin.controller";
import { AdminService } from "./admin.service";
@Module({
imports: [AuthModule, UsersModule, ContentsModule, CategoriesModule],
controllers: [AdminController],
providers: [AdminService],
})
export class AdminModule {}

View File

@@ -0,0 +1,27 @@
import { Injectable } from "@nestjs/common";
import { UsersRepository } from "../users/repositories/users.repository";
import { ContentsRepository } from "../contents/repositories/contents.repository";
import { CategoriesRepository } from "../categories/repositories/categories.repository";
@Injectable()
export class AdminService {
constructor(
private readonly usersRepository: UsersRepository,
private readonly contentsRepository: ContentsRepository,
private readonly categoriesRepository: CategoriesRepository,
) {}
async getStats() {
const [userCount, contentCount, categoryCount] = await Promise.all([
this.usersRepository.countAll(),
this.contentsRepository.count({}),
this.categoriesRepository.countAll(),
]);
return {
users: userCount,
contents: contentCount,
categories: categoryCount,
};
}
}

View File

@@ -5,6 +5,7 @@ import { ScheduleModule } from "@nestjs/schedule";
import { ThrottlerModule } from "@nestjs/throttler";
import { redisStore } from "cache-manager-redis-yet";
import { ApiKeysModule } from "./api-keys/api-keys.module";
import { AdminModule } from "./admin/admin.module";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { AuthModule } from "./auth/auth.module";
@@ -42,6 +43,7 @@ import { UsersModule } from "./users/users.module";
SessionsModule,
ReportsModule,
ApiKeysModule,
AdminModule,
ScheduleModule.forRoot(),
ThrottlerModule.forRootAsync({
imports: [ConfigModule],

View File

@@ -5,6 +5,9 @@ import { SessionsModule } from "../sessions/sessions.module";
import { UsersModule } from "../users/users.module";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { AuthGuard } from "./guards/auth.guard";
import { OptionalAuthGuard } from "./guards/optional-auth.guard";
import { RolesGuard } from "./guards/roles.guard";
import { RbacService } from "./rbac.service";
import { RbacRepository } from "./repositories/rbac.repository";
@@ -16,7 +19,21 @@ import { RbacRepository } from "./repositories/rbac.repository";
DatabaseModule,
],
controllers: [AuthController],
providers: [AuthService, RbacService, RbacRepository],
exports: [AuthService, RbacService, RbacRepository],
providers: [
AuthService,
RbacService,
RbacRepository,
AuthGuard,
OptionalAuthGuard,
RolesGuard,
],
exports: [
AuthService,
RbacService,
RbacRepository,
AuthGuard,
OptionalAuthGuard,
RolesGuard,
],
})
export class AuthModule {}

View File

@@ -0,0 +1,43 @@
import {
CanActivate,
ExecutionContext,
Injectable,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { getIronSession } from "iron-session";
import { JwtService } from "../../crypto/services/jwt.service";
import { getSessionOptions, SessionData } from "../session.config";
@Injectable()
export class OptionalAuthGuard implements CanActivate {
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse();
const session = await getIronSession<SessionData>(
request,
response,
getSessionOptions(this.configService.get("SESSION_PASSWORD") as string),
);
const token = session.accessToken;
if (!token) {
return true;
}
try {
const payload = await this.jwtService.verifyJwt(token);
request.user = payload;
} catch {
// Ignore invalid tokens for optional auth
}
return true;
}
}

View File

@@ -1,5 +1,5 @@
import { Injectable } from "@nestjs/common";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import { DatabaseService } from "../../database/database.service";
import { categories } from "../../database/schemas";
import type { CreateCategoryDto } from "../dto/create-category.dto";
@@ -16,6 +16,13 @@ export class CategoriesRepository {
.orderBy(categories.name);
}
async countAll() {
const result = await this.databaseService.db
.select({ count: sql<number>`count(*)` })
.from(categories);
return Number(result[0].count);
}
async findOne(id: string) {
const result = await this.databaseService.db
.select()

View File

@@ -17,6 +17,7 @@ export interface IMediaService {
processImage(
buffer: Buffer,
format?: "webp" | "avif",
resize?: { width?: number; height?: number },
): Promise<MediaProcessingResult>;
processVideo(
buffer: Buffer,

View File

@@ -21,6 +21,9 @@ import {
import { FileInterceptor } from "@nestjs/platform-express";
import type { Request, Response } from "express";
import { AuthGuard } from "../auth/guards/auth.guard";
import { OptionalAuthGuard } from "../auth/guards/optional-auth.guard";
import { RolesGuard } from "../auth/guards/roles.guard";
import { Roles } from "../auth/decorators/roles.decorator";
import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { ContentsService } from "./contents.service";
import { CreateContentDto } from "./dto/create-content.dto";
@@ -65,10 +68,12 @@ export class ContentsController {
}
@Get("explore")
@UseGuards(OptionalAuthGuard)
@UseInterceptors(CacheInterceptor)
@CacheTTL(60)
@Header("Cache-Control", "public, max-age=60")
explore(
@Req() req: AuthenticatedRequest,
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
@Query("sort") sort?: "trend" | "recent",
@@ -78,7 +83,7 @@ export class ContentsController {
@Query("query") query?: string,
@Query("favoritesOnly", new DefaultValuePipe(false), ParseBoolPipe)
favoritesOnly?: boolean,
@Query("userId") userId?: string,
@Query("userId") userIdQuery?: string,
) {
return this.contentsService.findAll({
limit,
@@ -89,42 +94,60 @@ export class ContentsController {
author,
query,
favoritesOnly,
userId,
userId: userIdQuery || req.user?.sub,
});
}
@Get("trends")
@UseGuards(OptionalAuthGuard)
@UseInterceptors(CacheInterceptor)
@CacheTTL(300)
@Header("Cache-Control", "public, max-age=300")
trends(
@Req() req: AuthenticatedRequest,
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
) {
return this.contentsService.findAll({ limit, offset, sortBy: "trend" });
return this.contentsService.findAll({
limit,
offset,
sortBy: "trend",
userId: req.user?.sub,
});
}
@Get("recent")
@UseGuards(OptionalAuthGuard)
@UseInterceptors(CacheInterceptor)
@CacheTTL(60)
@Header("Cache-Control", "public, max-age=60")
recent(
@Req() req: AuthenticatedRequest,
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
) {
return this.contentsService.findAll({ limit, offset, sortBy: "recent" });
return this.contentsService.findAll({
limit,
offset,
sortBy: "recent",
userId: req.user?.sub,
});
}
@Get(":idOrSlug")
@UseGuards(OptionalAuthGuard)
@UseInterceptors(CacheInterceptor)
@CacheTTL(3600)
@Header("Cache-Control", "public, max-age=3600")
async findOne(
@Param("idOrSlug") idOrSlug: string,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
@Res() res: Response,
) {
const content = await this.contentsService.findOne(idOrSlug);
const content = await this.contentsService.findOne(
idOrSlug,
req.user?.sub,
);
if (!content) {
throw new NotFoundException("Contenu non trouvé");
}
@@ -158,4 +181,11 @@ export class ContentsController {
remove(@Param("id") id: string, @Req() req: AuthenticatedRequest) {
return this.contentsService.remove(id, req.user.sub);
}
@Delete(":id/admin")
@UseGuards(AuthGuard, RolesGuard)
@Roles("admin")
removeAdmin(@Param("id") id: string) {
return this.contentsService.removeAdmin(id);
}
}

View File

@@ -126,7 +126,18 @@ export class ContentsService {
this.contentsRepository.count(options),
]);
return { data, totalCount };
const processedData = data.map((content) => ({
...content,
url: this.getFileUrl(content.storageKey),
author: {
...content.author,
avatarUrl: content.author?.avatarUrl
? this.getFileUrl(content.author.avatarUrl)
: null,
},
}));
return { data: processedData, totalCount };
}
async create(userId: string, data: CreateContentDto) {
@@ -162,8 +173,30 @@ export class ContentsService {
return deleted;
}
async findOne(idOrSlug: string) {
return this.contentsRepository.findOne(idOrSlug);
async removeAdmin(id: string) {
this.logger.log(`Removing content ${id} by admin`);
const deleted = await this.contentsRepository.softDeleteAdmin(id);
if (deleted) {
await this.clearContentsCache();
}
return deleted;
}
async findOne(idOrSlug: string, userId?: string) {
const content = await this.contentsRepository.findOne(idOrSlug, userId);
if (!content) return null;
return {
...content,
url: this.getFileUrl(content.storageKey),
author: {
...content.author,
avatarUrl: content.author?.avatarUrl
? this.getFileUrl(content.author.avatarUrl)
: null,
},
};
}
generateBotHtml(content: { title: string; storageKey: string }): string {

View File

@@ -135,11 +135,19 @@ export class ContentsRepository {
fileSize: contents.fileSize,
views: contents.views,
usageCount: contents.usageCount,
favoritesCount: sql<number>`(SELECT count(*) FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id})`.mapWith(
Number,
),
isLiked: userId
? sql<boolean>`EXISTS(SELECT 1 FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id} AND ${favorites.userId} = ${userId})`
: sql<boolean>`false`,
createdAt: contents.createdAt,
updatedAt: contents.updatedAt,
author: {
id: users.uuid,
username: users.username,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
},
category: {
id: categories.id,
@@ -215,7 +223,7 @@ export class ContentsRepository {
});
}
async findOne(idOrSlug: string) {
async findOne(idOrSlug: string, userId?: string) {
const [result] = await this.databaseService.db
.select({
id: contents.id,
@@ -227,11 +235,30 @@ export class ContentsRepository {
fileSize: contents.fileSize,
views: contents.views,
usageCount: contents.usageCount,
favoritesCount: sql<number>`(SELECT count(*) FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id})`.mapWith(
Number,
),
isLiked: userId
? sql<boolean>`EXISTS(SELECT 1 FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id} AND ${favorites.userId} = ${userId})`
: sql<boolean>`false`,
createdAt: contents.createdAt,
updatedAt: contents.updatedAt,
userId: contents.userId,
author: {
id: users.uuid,
username: users.username,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
},
category: {
id: categories.id,
name: categories.name,
slug: categories.slug,
},
})
.from(contents)
.leftJoin(users, eq(contents.userId, users.uuid))
.leftJoin(categories, eq(contents.categoryId, categories.id))
.where(
and(
isNull(contents.deletedAt),
@@ -240,7 +267,20 @@ export class ContentsRepository {
)
.limit(1);
return result;
if (!result) return null;
const tagsForContent = await this.databaseService.db
.select({
name: tags.name,
})
.from(contentsToTags)
.innerJoin(tags, eq(contentsToTags.tagId, tags.id))
.where(eq(contentsToTags.contentId, result.id));
return {
...result,
tags: tagsForContent.map((t) => t.name),
};
}
async count(options: {
@@ -353,6 +393,15 @@ export class ContentsRepository {
return deleted;
}
async softDeleteAdmin(id: string) {
const [deleted] = await this.databaseService.db
.update(contents)
.set({ deletedAt: new Date() })
.where(eq(contents.id, id))
.returning();
return deleted;
}
async findBySlug(slug: string) {
const [result] = await this.databaseService.db
.select()

View File

@@ -30,6 +30,8 @@ export const users = pgTable(
username: varchar("username", { length: 32 }).notNull().unique(),
passwordHash: varchar("password_hash", { length: 100 }).notNull(),
avatarUrl: varchar("avatar_url", { length: 512 }),
bio: varchar("bio", { length: 255 }),
// Sécurité
twoFactorSecret: pgpEncrypted("two_factor_secret"),

View File

@@ -83,8 +83,9 @@ export class MediaService implements IMediaService {
async processImage(
buffer: Buffer,
format: "webp" | "avif" = "webp",
resize?: { width?: number; height?: number },
): Promise<MediaProcessingResult> {
return this.imageProcessor.process(buffer, { format });
return this.imageProcessor.process(buffer, { format, resize });
}
async processVideo(

View File

@@ -13,11 +13,22 @@ export class ImageProcessorStrategy implements IMediaProcessorStrategy {
async process(
buffer: Buffer,
options: { format: "webp" | "avif" } = { format: "webp" },
options: {
format: "webp" | "avif";
resize?: { width?: number; height?: number };
} = { format: "webp" },
): Promise<MediaProcessingResult> {
try {
const { format } = options;
const { format, resize } = options;
let pipeline = sharp(buffer);
if (resize) {
pipeline = pipeline.resize(resize.width, resize.height, {
fit: "cover",
position: "center",
});
}
const metadata = await pipeline.metadata();
if (format === "webp") {

View File

@@ -5,4 +5,13 @@ export class UpdateUserDto {
@IsString()
@MaxLength(32)
displayName?: string;
@IsOptional()
@IsString()
@MaxLength(255)
bio?: string;
@IsOptional()
@IsString()
avatarUrl?: string;
}

View File

@@ -43,6 +43,8 @@ export class UsersRepository {
username: users.username,
email: users.email,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
bio: users.bio,
status: users.status,
isTwoFactorEnabled: users.isTwoFactorEnabled,
createdAt: users.createdAt,
@@ -67,6 +69,7 @@ export class UsersRepository {
uuid: users.uuid,
username: users.username,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
status: users.status,
createdAt: users.createdAt,
})
@@ -81,6 +84,8 @@ export class UsersRepository {
uuid: users.uuid,
username: users.username,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
bio: users.bio,
createdAt: users.createdAt,
})
.from(users)

View File

@@ -13,9 +13,11 @@ import {
Post,
Query,
Req,
UploadedFile,
UseGuards,
UseInterceptors,
} from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import { AuthService } from "../auth/auth.service";
import { Roles } from "../auth/decorators/roles.decorator";
import { AuthGuard } from "../auth/guards/auth.guard";
@@ -74,6 +76,16 @@ export class UsersController {
return this.usersService.update(req.user.sub, updateUserDto);
}
@Post("me/avatar")
@UseGuards(AuthGuard)
@UseInterceptors(FileInterceptor("file"))
updateAvatar(
@Req() req: AuthenticatedRequest,
@UploadedFile() file: Express.Multer.File,
) {
return this.usersService.updateAvatar(req.user.sub, file);
}
@Patch("me/consent")
@UseGuards(AuthGuard)
updateConsent(
@@ -93,6 +105,13 @@ export class UsersController {
return this.usersService.remove(req.user.sub);
}
@Delete(":uuid")
@UseGuards(AuthGuard, RolesGuard)
@Roles("admin")
removeAdmin(@Param("uuid") uuid: string) {
return this.usersService.remove(uuid);
}
// Double Authentification (2FA)
@Post("me/2fa/setup")
@UseGuards(AuthGuard)

View File

@@ -2,12 +2,20 @@ import { forwardRef, Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { CryptoModule } from "../crypto/crypto.module";
import { DatabaseModule } from "../database/database.module";
import { MediaModule } from "../media/media.module";
import { S3Module } from "../s3/s3.module";
import { UsersRepository } from "./repositories/users.repository";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";
@Module({
imports: [DatabaseModule, CryptoModule, forwardRef(() => AuthModule)],
imports: [
DatabaseModule,
CryptoModule,
forwardRef(() => AuthModule),
MediaModule,
S3Module,
],
controllers: [UsersController],
providers: [UsersService, UsersRepository],
exports: [UsersService, UsersRepository],

View File

@@ -1,6 +1,18 @@
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Inject, Injectable, Logger } from "@nestjs/common";
import {
BadRequestException,
forwardRef,
Inject,
Injectable,
Logger,
} from "@nestjs/common";
import type { Cache } from "cache-manager";
import { v4 as uuidv4 } from "uuid";
import { RbacService } from "../auth/rbac.service";
import type { IMediaService } from "../common/interfaces/media.interface";
import type { IStorageService } from "../common/interfaces/storage.interface";
import { MediaService } from "../media/media.service";
import { S3Service } from "../s3/s3.service";
import { UpdateUserDto } from "./dto/update-user.dto";
import { UsersRepository } from "./repositories/users.repository";
@@ -11,6 +23,10 @@ export class UsersService {
constructor(
private readonly usersRepository: UsersRepository,
@Inject(CACHE_MANAGER) private cacheManager: Cache,
@Inject(forwardRef(() => RbacService))
private readonly rbacService: RbacService,
@Inject(MediaService) private readonly mediaService: IMediaService,
@Inject(S3Service) private readonly s3Service: IStorageService,
) {}
private async clearUserCache(username?: string) {
@@ -33,7 +49,19 @@ export class UsersService {
}
async findOneWithPrivateData(uuid: string) {
return await this.usersRepository.findOneWithPrivateData(uuid);
const [user, roles] = await Promise.all([
this.usersRepository.findOneWithPrivateData(uuid),
this.rbacService.getUserRoles(uuid),
]);
if (!user) return null;
return {
...user,
avatarUrl: user.avatarUrl ? this.getFileUrl(user.avatarUrl) : null,
role: roles.includes("admin") ? "admin" : "user",
roles,
};
}
async findAll(limit: number, offset: number) {
@@ -42,11 +70,22 @@ export class UsersService {
this.usersRepository.countAll(),
]);
return { data, totalCount };
const processedData = data.map((user) => ({
...user,
avatarUrl: user.avatarUrl ? this.getFileUrl(user.avatarUrl) : null,
}));
return { data: processedData, totalCount };
}
async findPublicProfile(username: string) {
return await this.usersRepository.findByUsername(username);
const user = await this.usersRepository.findByUsername(username);
if (!user) return null;
return {
...user,
avatarUrl: user.avatarUrl ? this.getFileUrl(user.avatarUrl) : null,
};
}
async findOne(uuid: string) {
@@ -63,6 +102,49 @@ export class UsersService {
return result;
}
async updateAvatar(uuid: string, file: Express.Multer.File) {
this.logger.log(`Updating avatar for user ${uuid}`);
// Validation du format et de la taille
const allowedMimeTypes = ["image/png", "image/jpeg", "image/webp"];
if (!allowedMimeTypes.includes(file.mimetype)) {
throw new BadRequestException(
"Format d'image non supporté. Formats acceptés: png, jpeg, webp.",
);
}
if (file.size > 2 * 1024 * 1024) {
throw new BadRequestException(
"Image trop volumineuse. Limite: 2 Mo.",
);
}
// 1. Scan Antivirus
const scanResult = await this.mediaService.scanFile(
file.buffer,
file.originalname,
);
if (scanResult.isInfected) {
throw new BadRequestException(
`Le fichier est infecté par ${scanResult.virusName}`,
);
}
// 2. Traitement (WebP + Redimensionnement 512x512)
const processed = await this.mediaService.processImage(file.buffer, "webp", {
width: 512,
height: 512,
});
// 3. Upload vers S3
const key = `avatars/${uuid}/${Date.now()}-${uuidv4()}.${processed.extension}`;
await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType);
// 4. Mise à jour de la base de données
const user = await this.update(uuid, { avatarUrl: key });
return user[0];
}
async updateConsent(
uuid: string,
termsVersion: string,
@@ -111,4 +193,17 @@ export class UsersService {
async remove(uuid: string) {
return await this.usersRepository.softDeleteUserAndContents(uuid);
}
private getFileUrl(storageKey: string): string {
const endpoint = this.configService.get("S3_ENDPOINT");
const port = this.configService.get("S3_PORT");
const protocol =
this.configService.get("S3_USE_SSL") === true ? "https" : "http";
const bucket = this.configService.get("S3_BUCKET_NAME");
if (endpoint === "localhost" || endpoint === "127.0.0.1") {
return `${protocol}://${endpoint}:${port}/${bucket}/${storageKey}`;
}
return `${protocol}://${endpoint}/${bucket}/${storageKey}`;
}
}

View File

@@ -0,0 +1,72 @@
"use client";
import { useEffect, useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { CategoryService } from "@/services/category.service";
import type { Category } from "@/types/content";
import { Skeleton } from "@/components/ui/skeleton";
export default function AdminCategoriesPage() {
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
CategoryService.getAll()
.then(setCategories)
.catch(err => console.error(err))
.finally(() => setLoading(false));
}, []);
return (
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
<div className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight">Catégories ({categories.length})</h2>
</div>
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nom</TableHead>
<TableHead>Slug</TableHead>
<TableHead>Description</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
<TableCell><Skeleton className="h-4 w-[150px]" /></TableCell>
<TableCell><Skeleton className="h-4 w-[150px]" /></TableCell>
<TableCell><Skeleton className="h-4 w-[250px]" /></TableCell>
</TableRow>
))
) : categories.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-center h-24">
Aucune catégorie trouvée.
</TableCell>
</TableRow>
) : (
categories.map((category) => (
<TableRow key={category.id}>
<TableCell className="font-medium whitespace-nowrap">{category.name}</TableCell>
<TableCell className="whitespace-nowrap">{category.slug}</TableCell>
<TableCell className="text-muted-foreground">
{category.description || "Aucune description"}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1,137 @@
"use client";
import { useEffect, useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ContentService } from "@/services/content.service";
import type { Content } from "@/types/content";
import { Badge } from "@/components/ui/badge";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { Skeleton } from "@/components/ui/skeleton";
import { Eye, Download, Image as ImageIcon, Video, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function AdminContentsPage() {
const [contents, setContents] = useState<Content[]>([]);
const [loading, setLoading] = useState(true);
const [totalCount, setTotalCount] = useState(0);
useEffect(() => {
ContentService.getExplore({ limit: 20 })
.then((res) => {
setContents(res.data);
setTotalCount(res.total);
})
.catch(err => console.error(err))
.finally(() => setLoading(false));
}, []);
const handleDelete = async (id: string) => {
if (!confirm("Êtes-vous sûr de vouloir supprimer ce contenu ?")) return;
try {
await ContentService.removeAdmin(id);
setContents(contents.filter(c => c.id !== id));
setTotalCount(prev => prev - 1);
} catch (error) {
console.error(error);
}
};
return (
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
<div className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight">Contenus ({totalCount})</h2>
</div>
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Contenu</TableHead>
<TableHead>Catégorie</TableHead>
<TableHead>Auteur</TableHead>
<TableHead>Stats</TableHead>
<TableHead>Date</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
<TableCell><Skeleton className="h-10 w-[200px]" /></TableCell>
<TableCell><Skeleton className="h-4 w-[100px]" /></TableCell>
<TableCell><Skeleton className="h-4 w-[100px]" /></TableCell>
<TableCell><Skeleton className="h-4 w-[80px]" /></TableCell>
<TableCell><Skeleton className="h-4 w-[100px]" /></TableCell>
</TableRow>
))
) : contents.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center h-24">
Aucun contenu trouvé.
</TableCell>
</TableRow>
) : (
contents.map((content) => (
<TableRow key={content.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded bg-muted">
{content.type === "image" ? (
<ImageIcon className="h-5 w-5 text-muted-foreground" />
) : (
<Video className="h-5 w-5 text-muted-foreground" />
)}
</div>
<div>
<div className="font-semibold">{content.title}</div>
<div className="text-xs text-muted-foreground">{content.type} {content.mimeType}</div>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{content.category.name}</Badge>
</TableCell>
<TableCell>
@{content.author.username}
</TableCell>
<TableCell>
<div className="flex flex-col gap-1 text-xs">
<div className="flex items-center gap-1">
<Eye className="h-3 w-3" /> {content.views}
</div>
<div className="flex items-center gap-1">
<Download className="h-3 w-3" /> {content.usageCount}
</div>
</div>
</TableCell>
<TableCell className="whitespace-nowrap">
{format(new Date(content.createdAt), "dd/MM/yyyy", { locale: fr })}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(content.id)}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1,85 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { adminService, type AdminStats } from "@/services/admin.service";
import { Users, FileText, LayoutGrid, AlertCircle } from "lucide-react";
import Link from "next/link";
import { Skeleton } from "@/components/ui/skeleton";
export default function AdminDashboardPage() {
const [stats, setStats] = useState<AdminStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
adminService
.getStats()
.then(setStats)
.catch((err) => {
console.error(err);
setError("Impossible de charger les statistiques.");
})
.finally(() => setLoading(false));
}, []);
if (error) {
return (
<div className="flex h-[50vh] flex-col items-center justify-center gap-4 text-center">
<AlertCircle className="h-12 w-12 text-destructive" />
<p className="text-xl font-semibold">{error}</p>
</div>
);
}
const statCards = [
{
title: "Utilisateurs",
value: stats?.users,
icon: Users,
href: "/admin/users",
color: "text-blue-500",
},
{
title: "Contenus",
value: stats?.contents,
icon: FileText,
href: "/admin/contents",
color: "text-green-500",
},
{
title: "Catégories",
value: stats?.categories,
icon: LayoutGrid,
href: "/admin/categories",
color: "text-purple-500",
},
];
return (
<div className="flex-1 space-y-8 p-4 pt-6 md:p-8">
<div className="flex items-center justify-between space-y-2">
<h2 className="text-3xl font-bold tracking-tight">Dashboard Admin</h2>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{statCards.map((card) => (
<Link key={card.title} href={card.href}>
<Card className="hover:bg-accent transition-colors cursor-pointer">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{card.title}</CardTitle>
<card.icon className={`h-4 w-4 ${card.color}`} />
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-8 w-20" />
) : (
<div className="text-2xl font-bold">{card.value}</div>
)}
</CardContent>
</Card>
</Link>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,123 @@
"use client";
import { useEffect, useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { UserService } from "@/services/user.service";
import type { User } from "@/types/user";
import { Badge } from "@/components/ui/badge";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
export default function AdminUsersPage() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [totalCount, setTotalCount] = useState(0);
useEffect(() => {
UserService.getUsersAdmin()
.then((res) => {
setUsers(res.data);
setTotalCount(res.totalCount);
})
.catch(err => {
console.error(err);
})
.finally(() => setLoading(false));
}, []);
const handleDelete = async (uuid: string) => {
if (!confirm("Êtes-vous sûr de vouloir supprimer cet utilisateur ? Cette action est irréversible.")) return;
try {
await UserService.removeUserAdmin(uuid);
setUsers(users.filter(u => u.uuid !== uuid));
setTotalCount(prev => prev - 1);
} catch (error) {
console.error(error);
}
};
return (
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
<div className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight">Utilisateurs ({totalCount})</h2>
</div>
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Utilisateur</TableHead>
<TableHead>Email</TableHead>
<TableHead>Rôle</TableHead>
<TableHead>Status</TableHead>
<TableHead>Date d'inscription</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
<TableCell><Skeleton className="h-4 w-[150px]" /></TableCell>
<TableCell><Skeleton className="h-4 w-[200px]" /></TableCell>
<TableCell><Skeleton className="h-4 w-[50px]" /></TableCell>
<TableCell><Skeleton className="h-4 w-[80px]" /></TableCell>
<TableCell><Skeleton className="h-4 w-[100px]" /></TableCell>
</TableRow>
))
) : users.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center h-24">
Aucun utilisateur trouvé.
</TableCell>
</TableRow>
) : (
users.map((user) => (
<TableRow key={user.uuid}>
<TableCell className="font-medium whitespace-nowrap">
{user.displayName || user.username}
<div className="text-xs text-muted-foreground">@{user.username}</div>
</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<Badge variant={user.role === "admin" ? "default" : "secondary"}>
{user.role}
</Badge>
</TableCell>
<TableCell>
<Badge variant={user.status === "active" ? "success" : "destructive"}>
{user.status}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap">
{format(new Date(user.createdAt), "PPP", { locale: fr })}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(user.uuid)}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1,72 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { HelpCircle } from "lucide-react";
export default function HelpPage() {
const faqs = [
{
question: "Comment puis-je publier un mème ?",
answer:
"Pour publier un mème, vous devez être connecté à votre compte. Cliquez sur le bouton 'Publier' dans la barre latérale, choisissez votre fichier (image ou GIF), donnez-lui un titre et une catégorie, puis validez.",
},
{
question: "Quels formats de fichiers sont acceptés ?",
answer:
"Nous acceptons les images au format PNG, JPEG, WebP et les GIF animés. La taille maximale recommandée est de 2 Mo.",
},
{
question: "Comment fonctionnent les favoris ?",
answer:
"En cliquant sur l'icône de cœur sur un mème, vous l'ajoutez à vos favoris. Vous pouvez retrouver tous vos mèmes favoris dans l'onglet 'Mes Favoris' de votre profil.",
},
{
question: "Puis-je supprimer un mème que j'ai publié ?",
answer:
"Oui, vous pouvez supprimer vos propres mèmes en vous rendant sur votre profil, en sélectionnant le mème et en cliquant sur l'option de suppression.",
},
{
question: "Comment fonctionne le système de recherche ?",
answer:
"Vous pouvez rechercher des mèmes par titre en utilisant la barre de recherche dans la colonne de droite. Vous pouvez également filtrer par catégories ou par tags populaires.",
},
];
return (
<div className="max-w-3xl mx-auto py-12 px-4">
<div className="flex items-center gap-3 mb-8">
<div className="bg-primary/10 p-3 rounded-xl">
<HelpCircle className="h-6 w-6 text-primary" />
</div>
<h1 className="text-3xl font-bold">Centre d'aide</h1>
</div>
<div className="bg-white dark:bg-zinc-900 border rounded-2xl p-6 shadow-sm mb-12">
<h2 className="text-xl font-semibold mb-6">Foire Aux Questions</h2>
<Accordion type="single" collapsible className="w-full">
{faqs.map((faq, index) => (
<AccordionItem key={index} value={`item-${index}`}>
<AccordionTrigger className="text-left">
{faq.question}
</AccordionTrigger>
<AccordionContent className="text-muted-foreground leading-relaxed">
{faq.answer}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
<div className="text-center space-y-4">
<h2 className="text-lg font-medium">Vous ne trouvez pas de réponse ?</h2>
<p className="text-muted-foreground">
N'hésitez pas à nous contacter sur nos réseaux sociaux ou par email.
</p>
<p className="font-semibold text-primary">contact@memegoat.local</p>
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { Calendar, LogIn, LogOut, Settings } from "lucide-react";
import { Calendar, Camera, LogIn, LogOut, Settings } from "lucide-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import * as React from "react";
@@ -19,11 +19,32 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useAuth } from "@/providers/auth-provider";
import { ContentService } from "@/services/content.service";
import { FavoriteService } from "@/services/favorite.service";
import { UserService } from "@/services/user.service";
import { toast } from "sonner";
export default function ProfilePage() {
const { user, isAuthenticated, isLoading, logout } = useAuth();
const { user, isAuthenticated, isLoading, logout, refreshUser } = useAuth();
const searchParams = useSearchParams();
const tab = searchParams.get("tab") || "memes";
const fileInputRef = React.useRef<HTMLInputElement>(null);
const handleAvatarClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
await UserService.updateAvatar(file);
toast.success("Avatar mis à jour avec succès !");
await refreshUser?.();
} catch (error) {
console.error(error);
toast.error("Erreur lors de la mise à jour de l'avatar.");
}
};
const fetchMyMemes = React.useCallback(
(params: { limit: number; offset: number }) =>
@@ -72,12 +93,28 @@ export default function ProfilePage() {
<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="relative group">
<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>
<button
type="button"
onClick={handleAvatarClick}
className="absolute inset-0 flex items-center justify-center bg-black/40 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
>
<Camera className="h-8 w-8" />
</button>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept="image/*"
className="hidden"
/>
</div>
<div className="flex-1 text-center md:text-left space-y-4">
<div>
<h1 className="text-3xl font-bold">
@@ -85,6 +122,9 @@ export default function ProfilePage() {
</h1>
<p className="text-muted-foreground">@{user.username}</p>
</div>
{user.bio && (
<p className="max-w-md text-sm leading-relaxed">{user.bio}</p>
)}
<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" />

View File

@@ -1,15 +1,16 @@
"use client";
import type { Metadata } from "next";
import * as React from "react";
import { ContentList } from "@/components/content-list";
import { ContentService } from "@/services/content.service";
import { HomeContent } from "@/components/home-content";
export const metadata: Metadata = {
title: "Nouveautés",
description: "Les tout derniers mèmes fraîchement débarqués sur MemeGoat.",
};
export default function RecentPage() {
const fetchFn = React.useCallback(
(params: { limit: number; offset: number }) =>
ContentService.getRecent(params.limit, params.offset),
[],
return (
<React.Suspense fallback={<div className="p-8 text-center">Chargement des nouveautés...</div>}>
<HomeContent defaultSort="recent" />
</React.Suspense>
);
return <ContentList fetchFn={fetchFn} title="Nouveaux Mèmes" />;
}

View File

@@ -0,0 +1,185 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2, Save, User as UserIcon } from "lucide-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { useAuth } from "@/providers/auth-provider";
import { UserService } from "@/services/user.service";
const settingsSchema = z.object({
displayName: z.string().max(32, "Le nom d'affichage est trop long").optional(),
bio: z.string().max(255, "La bio est trop longue").optional(),
});
type SettingsFormValues = z.infer<typeof settingsSchema>;
export default function SettingsPage() {
const { user, isLoading, refreshUser } = useAuth();
const [isSaving, setIsSaving] = React.useState(false);
const form = useForm<SettingsFormValues>({
resolver: zodResolver(settingsSchema),
defaultValues: {
displayName: "",
bio: "",
},
});
React.useEffect(() => {
if (user) {
form.reset({
displayName: user.displayName || "",
bio: (user as any).bio || "",
});
}
}, [user, form]);
if (isLoading) {
return (
<div className="flex h-[400px] items-center justify-center">
<Spinner className="h-8 w-8 text-primary" />
</div>
);
}
if (!user) {
return (
<div className="max-w-2xl mx-auto py-8 px-4 text-center">
<Card>
<CardHeader>
<CardTitle>Accès refusé</CardTitle>
<CardDescription>
Vous devez être connecté pour accéder aux paramètres.
</CardDescription>
</CardHeader>
</Card>
</div>
);
}
const onSubmit = async (values: SettingsFormValues) => {
setIsSaving(true);
try {
await UserService.updateMe(values);
toast.success("Paramètres mis à jour !");
await refreshUser();
} catch (error) {
console.error(error);
toast.error("Erreur lors de la mise à jour des paramètres.");
} finally {
setIsSaving(false);
}
};
return (
<div className="max-w-2xl mx-auto py-12 px-4">
<div className="flex items-center gap-3 mb-8">
<div className="bg-primary/10 p-3 rounded-xl">
<UserIcon className="h-6 w-6 text-primary" />
</div>
<h1 className="text-3xl font-bold">Paramètres du profil</h1>
</div>
<Card>
<CardHeader>
<CardTitle>Informations personnelles</CardTitle>
<CardDescription>
Mettez à jour vos informations publiques. Ces données seront visibles par les autres utilisateurs.
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid gap-4">
<FormItem>
<FormLabel>Nom d'utilisateur</FormLabel>
<FormControl>
<Input value={user.username} disabled className="bg-zinc-50 dark:bg-zinc-900" />
</FormControl>
<FormDescription>
Le nom d'utilisateur ne peut pas être modifié.
</FormDescription>
</FormItem>
<FormField
control={form.control}
name="displayName"
render={({ field }) => (
<FormItem>
<FormLabel>Nom d'affichage</FormLabel>
<FormControl>
<Input placeholder="Votre nom" {...field} />
</FormControl>
<FormDescription>
Le nom qui sera affiché sur votre profil et vos mèmes.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bio"
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<Textarea
placeholder="Racontez-nous quelque chose sur vous..."
className="resize-none"
{...field}
/>
</FormControl>
<FormDescription>
Une courte description de vous (max 255 caractères).
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button type="submit" disabled={isSaving} className="w-full sm:w-auto">
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Enregistrement...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Enregistrer les modifications
</>
)}
</Button>
</form>
</Form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,15 +1,16 @@
"use client";
import type { Metadata } from "next";
import * as React from "react";
import { ContentList } from "@/components/content-list";
import { ContentService } from "@/services/content.service";
import { HomeContent } from "@/components/home-content";
export const metadata: Metadata = {
title: "Tendances",
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 (
<React.Suspense fallback={<div className="p-8 text-center">Chargement des tendances...</div>}>
<HomeContent defaultSort="trend" />
</React.Suspense>
);
return <ContentList fetchFn={fetchFn} title="Top Tendances" />;
}

View File

@@ -0,0 +1,98 @@
"use client";
import { Calendar, User as UserIcon } from "lucide-react";
import * as React from "react";
import { ContentList } from "@/components/content-list";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Spinner } from "@/components/ui/spinner";
import { ContentService } from "@/services/content.service";
import { UserService } from "@/services/user.service";
import type { User } from "@/types/user";
export default function PublicProfilePage({
params,
}: {
params: Promise<{ username: string }>;
}) {
const { username } = React.use(params);
const [user, setUser] = React.useState<User | null>(null);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
UserService.getProfile(username)
.then(setUser)
.catch(console.error)
.finally(() => setLoading(false));
}, [username]);
const fetchUserMemes = React.useCallback(
(params: { limit: number; offset: number }) =>
ContentService.getExplore({ ...params, author: username }),
[username],
);
if (loading) {
return (
<div className="flex h-[400px] items-center justify-center">
<Spinner className="h-8 w-8 text-primary" />
</div>
);
}
if (!user) {
return (
<div className="max-w-2xl mx-auto py-12 px-4 text-center">
<div className="bg-primary/10 p-4 rounded-full w-fit mx-auto mb-4">
<UserIcon className="h-8 w-8 text-primary" />
</div>
<h1 className="text-2xl font-bold">Utilisateur non trouvé</h1>
<p className="text-muted-foreground mt-2">
Le membre @{username} n'existe pas ou a quitté le troupeau.
</p>
</div>
);
}
return (
<div className="max-w-4xl mx-auto py-8 px-4">
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-8 border shadow-sm mb-8">
<div className="flex flex-col md:flex-row items-center gap-8">
<Avatar className="h-32 w-32 border-4 border-primary/10">
<AvatarImage src={user.avatarUrl} alt={user.username} />
<AvatarFallback className="text-4xl">
{user.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 text-center md:text-left space-y-4">
<div>
<h1 className="text-3xl font-bold">
{user.displayName || user.username}
</h1>
<p className="text-muted-foreground">@{user.username}</p>
</div>
{user.bio && (
<p className="max-w-md text-sm leading-relaxed mx-auto md:mx-0">
{user.bio}
</p>
)}
<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>
</div>
</div>
<div className="space-y-8">
<h2 className="text-xl font-bold border-b pb-4">Ses mèmes</h2>
<ContentList fetchFn={fetchUserMemes} />
</div>
</div>
);
}

View File

@@ -16,7 +16,38 @@ const ubuntuMono = Ubuntu_Mono({
});
export const metadata: Metadata = {
title: "MemeGoat",
title: {
default: "MemeGoat | Partagez vos meilleurs mèmes",
template: "%s | MemeGoat",
},
description:
"MemeGoat est la plateforme ultime pour découvrir, créer et partager les mèmes les plus drôles de la communauté des chèvres.",
keywords: ["meme", "drôle", "goat", "chèvre", "humour", "partage", "gif"],
authors: [{ name: "MemeGoat Team" }],
creator: "MemeGoat Team",
openGraph: {
type: "website",
locale: "fr_FR",
url: "https://memegoat.local",
siteName: "MemeGoat",
title: "MemeGoat | Partagez vos meilleurs mèmes",
description:
"La plateforme ultime pour les mèmes. Rejoignez le troupeau !",
images: [
{
url: "/memegoat-og.png",
width: 1200,
height: 630,
alt: "MemeGoat",
},
],
},
twitter: {
card: "summary_large_image",
title: "MemeGoat | Partagez vos meilleurs mèmes",
description: "La plateforme ultime pour les mèmes. Rejoignez le troupeau !",
images: ["/memegoat-og.png"],
},
icons: "/memegoat-color.svg",
};

View File

@@ -12,6 +12,7 @@ import {
LogOut,
PlusCircle,
Settings,
ShieldCheck,
TrendingUp,
User as UserIcon,
} from "lucide-react";
@@ -186,6 +187,26 @@ export function AppSidebar() {
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
{isAuthenticated && user?.role === "admin" && (
<SidebarGroup>
<SidebarGroupLabel>Administration</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={pathname.startsWith("/admin")}
tooltip="Dashboard Admin"
>
<Link href="/admin">
<ShieldCheck />
<span>Admin</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
)}
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
@@ -230,7 +251,7 @@ export function AppSidebar() {
<span className="truncate font-semibold">
{user.displayName || user.username}
</span>
<span className="truncate text-xs">{user.email}</span>
<span className="truncate text-xs">{user.role}</span>
</div>
</div>
</DropdownMenuLabel>

View File

@@ -26,9 +26,14 @@ interface ContentCardProps {
export function ContentCard({ content }: ContentCardProps) {
const { isAuthenticated } = useAuth();
const router = useRouter();
const [isLiked, setIsLiked] = React.useState(false);
const [isLiked, setIsLiked] = React.useState(content.isLiked || false);
const [likesCount, setLikesCount] = React.useState(content.favoritesCount);
React.useEffect(() => {
setIsLiked(content.isLiked || false);
setLikesCount(content.favoritesCount);
}, [content.isLiked, content.favoritesCount]);
const handleLike = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
@@ -54,6 +59,17 @@ export function ContentCard({ content }: ContentCardProps) {
}
};
const handleUse = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
try {
await ContentService.incrementUsage(content.id);
toast.success("Mème prêt à être utilisé !");
} 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">
@@ -118,7 +134,12 @@ export function ContentCard({ content }: ContentCardProps) {
<Share2 className="h-4 w-4" />
</Button>
</div>
<Button size="sm" variant="secondary" className="text-xs h-8">
<Button
size="sm"
variant="secondary"
className="text-xs h-8"
onClick={handleUse}
>
Utiliser
</Button>
</div>

View File

@@ -5,10 +5,14 @@ import * as React from "react";
import { ContentList } from "@/components/content-list";
import { ContentService } from "@/services/content.service";
export function HomeContent() {
export function HomeContent({
defaultSort = "trend",
}: {
defaultSort?: "trend" | "recent";
}) {
const searchParams = useSearchParams();
const sort = (searchParams.get("sort") as "trend" | "recent") || "trend";
const sort = (searchParams.get("sort") as "trend" | "recent") || defaultSort;
const category = searchParams.get("category") || undefined;
const tag = searchParams.get("tag") || undefined;
const query = searchParams.get("query") || undefined;

View File

@@ -15,6 +15,8 @@ const badgeVariants = cva(
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
success:
"border-transparent bg-emerald-500 text-white [a&]:hover:bg-emerald-500/90",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},

View File

@@ -0,0 +1,14 @@
import { api } from "./api";
export interface AdminStats {
users: number;
contents: number;
categories: number;
}
export const adminService = {
getStats: async (): Promise<AdminStats> => {
const response = await api.get("/admin/stats");
return response.data;
},
};

View File

@@ -61,4 +61,8 @@ export const ContentService = {
});
return data;
},
async removeAdmin(id: string): Promise<void> {
await api.delete(`/contents/${id}/admin`);
},
};

View File

@@ -16,4 +16,32 @@ export const UserService = {
const { data } = await api.patch<User>("/users/me", update);
return data;
},
async getUsersAdmin(
limit = 10,
offset = 0,
): Promise<{ data: User[]; totalCount: number }> {
const { data } = await api.get<{ data: User[]; totalCount: number }>(
"/users/admin",
{
params: { limit, offset },
},
);
return data;
},
async removeUserAdmin(uuid: string): Promise<void> {
await api.delete(`/users/${uuid}`);
},
async updateAvatar(file: File): Promise<User> {
const formData = new FormData();
formData.append("file", file);
const { data } = await api.post<User>("/users/me/avatar", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
return data;
},
};

View File

@@ -16,6 +16,7 @@ export interface Content {
views: number;
usageCount: number;
favoritesCount: number;
isLiked?: boolean;
tags: (string | Tag)[];
category?: Category;
authorId: string;
@@ -39,7 +40,7 @@ export interface Category {
export interface PaginatedResponse<T> {
data: T[];
total: number;
totalCount: number;
limit: number;
offset: number;
}

View File

@@ -4,12 +4,12 @@ export interface User {
email: string;
displayName?: string;
avatarUrl?: string;
bio?: string;
role: "user" | "admin";
createdAt: string;
}
export interface UserProfile extends User {
bio?: string;
favoritesCount: number;
uploadsCount: number;
}