From a30113e8e26129e40196287939c998fe27088308 Mon Sep 17 00:00:00 2001 From: Mathis HERRIOT <197931332+0x485254@users.noreply.github.com> Date: Wed, 14 Jan 2026 21:42:46 +0100 Subject: [PATCH 01/22] 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. --- backend/src/database/schemas/users.ts | 2 + .../users/repositories/users.repository.ts | 5 + backend/src/users/users.controller.ts | 19 ++++ backend/src/users/users.module.ts | 10 +- backend/src/users/users.service.ts | 103 +++++++++++++++++- frontend/src/services/user.service.ts | 28 +++++ frontend/src/types/user.ts | 2 +- 7 files changed, 163 insertions(+), 6 deletions(-) diff --git a/backend/src/database/schemas/users.ts b/backend/src/database/schemas/users.ts index fb9c55f..86ce9dc 100644 --- a/backend/src/database/schemas/users.ts +++ b/backend/src/database/schemas/users.ts @@ -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"), diff --git a/backend/src/users/repositories/users.repository.ts b/backend/src/users/repositories/users.repository.ts index 7a3f538..167f12c 100644 --- a/backend/src/users/repositories/users.repository.ts +++ b/backend/src/users/repositories/users.repository.ts @@ -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) diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index c036337..e49dfcb 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -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) diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts index 21678c9..dd4daa1 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -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], diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 8c7a97e..9bea82f 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -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}`; + } } diff --git a/frontend/src/services/user.service.ts b/frontend/src/services/user.service.ts index 5eb4e7e..a957fef 100644 --- a/frontend/src/services/user.service.ts +++ b/frontend/src/services/user.service.ts @@ -16,4 +16,32 @@ export const UserService = { const { data } = await api.patch("/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 { + await api.delete(`/users/${uuid}`); + }, + + async updateAvatar(file: File): Promise { + const formData = new FormData(); + formData.append("file", file); + const { data } = await api.post("/users/me/avatar", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + return data; + }, }; diff --git a/frontend/src/types/user.ts b/frontend/src/types/user.ts index 1938f44..9966d78 100644 --- a/frontend/src/types/user.ts +++ b/frontend/src/types/user.ts @@ -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; } -- 2.49.1 From 026aebaee34b7a65fb09bdb47309e595e4d1ad37 Mon Sep 17 00:00:00 2001 From: Mathis HERRIOT <197931332+0x485254@users.noreply.github.com> Date: Wed, 14 Jan 2026 21:43:10 +0100 Subject: [PATCH 02/22] 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. --- .../0006_friendly_adam_warlock.sql | 2 + backend/.migrations/meta/0006_snapshot.json | 1652 +++++++++++++++++ backend/.migrations/meta/_journal.json | 7 + 3 files changed, 1661 insertions(+) create mode 100644 backend/.migrations/0006_friendly_adam_warlock.sql create mode 100644 backend/.migrations/meta/0006_snapshot.json diff --git a/backend/.migrations/0006_friendly_adam_warlock.sql b/backend/.migrations/0006_friendly_adam_warlock.sql new file mode 100644 index 0000000..7019dbc --- /dev/null +++ b/backend/.migrations/0006_friendly_adam_warlock.sql @@ -0,0 +1,2 @@ +ALTER TABLE "users" ADD COLUMN "avatar_url" varchar(512);--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "bio" varchar(255); \ No newline at end of file diff --git a/backend/.migrations/meta/0006_snapshot.json b/backend/.migrations/meta/0006_snapshot.json new file mode 100644 index 0000000..9088ab8 --- /dev/null +++ b/backend/.migrations/meta/0006_snapshot.json @@ -0,0 +1,1652 @@ +{ + "id": "538dd2c7-758f-41bd-8b34-f5a6b6b289dd", + "prevId": "6dbf8343-dced-4b01-a3fd-8c246b0b88e8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_keys_user_id_idx": { + "name": "api_keys_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_key_hash_idx": { + "name": "api_keys_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_user_id_users_uuid_fk": { + "name": "api_keys_user_id_users_uuid_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "key_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip_hash": { + "name": "ip_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_user_id_idx": { + "name": "audit_logs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_action_idx": { + "name": "audit_logs_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_entity_idx": { + "name": "audit_logs_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_created_at_idx": { + "name": "audit_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_user_id_users_uuid_fk": { + "name": "audit_logs_user_id_users_uuid_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "icon_url": { + "name": "icon_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "categories_slug_idx": { + "name": "categories_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_name_unique": { + "name": "categories_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "categories_slug_unique": { + "name": "categories_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contents": { + "name": "contents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "content_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "contents_user_id_idx": { + "name": "contents_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contents_storage_key_idx": { + "name": "contents_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contents_deleted_at_idx": { + "name": "contents_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contents_user_id_users_uuid_fk": { + "name": "contents_user_id_users_uuid_fk", + "tableFrom": "contents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "contents_category_id_categories_id_fk": { + "name": "contents_category_id_categories_id_fk", + "tableFrom": "contents", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "contents_slug_unique": { + "name": "contents_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "contents_storage_key_unique": { + "name": "contents_storage_key_unique", + "nullsNotDistinct": false, + "columns": [ + "storage_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contents_to_tags": { + "name": "contents_to_tags", + "schema": "", + "columns": { + "content_id": { + "name": "content_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "contents_to_tags_content_id_contents_id_fk": { + "name": "contents_to_tags_content_id_contents_id_fk", + "tableFrom": "contents_to_tags", + "tableTo": "contents", + "columnsFrom": [ + "content_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "contents_to_tags_tag_id_tags_id_fk": { + "name": "contents_to_tags_tag_id_tags_id_fk", + "tableFrom": "contents_to_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "contents_to_tags_content_id_tag_id_pk": { + "name": "contents_to_tags_content_id_tag_id_pk", + "columns": [ + "content_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.favorites": { + "name": "favorites", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content_id": { + "name": "content_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "favorites_user_id_users_uuid_fk": { + "name": "favorites_user_id_users_uuid_fk", + "tableFrom": "favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "favorites_content_id_contents_id_fk": { + "name": "favorites_content_id_contents_id_fk", + "tableFrom": "favorites", + "tableTo": "contents", + "columnsFrom": [ + "content_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "favorites_user_id_content_id_pk": { + "name": "favorites_user_id_content_id_pk", + "columns": [ + "user_id", + "content_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_slug_idx": { + "name": "permissions_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "permissions_name_unique": { + "name": "permissions_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "permissions_slug_unique": { + "name": "permissions_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "roles_slug_idx": { + "name": "roles_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "roles_slug_unique": { + "name": "roles_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles_to_permissions": { + "name": "roles_to_permissions", + "schema": "", + "columns": { + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "roles_to_permissions_role_id_roles_id_fk": { + "name": "roles_to_permissions_role_id_roles_id_fk", + "tableFrom": "roles_to_permissions", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "roles_to_permissions_permission_id_permissions_id_fk": { + "name": "roles_to_permissions_permission_id_permissions_id_fk", + "tableFrom": "roles_to_permissions", + "tableTo": "permissions", + "columnsFrom": [ + "permission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "roles_to_permissions_role_id_permission_id_pk": { + "name": "roles_to_permissions_role_id_permission_id_pk", + "columns": [ + "role_id", + "permission_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users_to_roles": { + "name": "users_to_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "users_to_roles_user_id_users_uuid_fk": { + "name": "users_to_roles_user_id_users_uuid_fk", + "tableFrom": "users_to_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "users_to_roles_role_id_roles_id_fk": { + "name": "users_to_roles_role_id_roles_id_fk", + "tableFrom": "users_to_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "users_to_roles_user_id_role_id_pk": { + "name": "users_to_roles_user_id_role_id_pk", + "columns": [ + "user_id", + "role_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "reporter_id": { + "name": "reporter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content_id": { + "name": "content_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "report_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "report_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reports_reporter_id_idx": { + "name": "reports_reporter_id_idx", + "columns": [ + { + "expression": "reporter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_content_id_idx": { + "name": "reports_content_id_idx", + "columns": [ + { + "expression": "content_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_tag_id_idx": { + "name": "reports_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_status_idx": { + "name": "reports_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_expires_at_idx": { + "name": "reports_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reports_reporter_id_users_uuid_fk": { + "name": "reports_reporter_id_users_uuid_fk", + "tableFrom": "reports", + "tableTo": "users", + "columnsFrom": [ + "reporter_id" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reports_content_id_contents_id_fk": { + "name": "reports_content_id_contents_id_fk", + "tableFrom": "reports", + "tableTo": "contents", + "columnsFrom": [ + "content_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reports_tag_id_tags_id_fk": { + "name": "reports_tag_id_tags_id_fk", + "tableFrom": "reports", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "ip_hash": { + "name": "ip_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "is_valid": { + "name": "is_valid", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_refresh_token_idx": { + "name": "sessions_refresh_token_idx", + "columns": [ + { + "expression": "refresh_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_uuid_fk": { + "name": "sessions_user_id_users_uuid_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_refresh_token_unique": { + "name": "sessions_refresh_token_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tags_slug_idx": { + "name": "tags_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tags_user_id_users_uuid_fk": { + "name": "tags_user_id_users_uuid_fk", + "tableFrom": "tags", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tags_name_unique": { + "name": "tags_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "tags_slug_unique": { + "name": "tags_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "uuid": { + "name": "uuid", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "user_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "email": { + "name": "email", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "email_hash": { + "name": "email_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "two_factor_secret": { + "name": "two_factor_secret", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "is_two_factor_enabled": { + "name": "is_two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "terms_version": { + "name": "terms_version", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "privacy_version": { + "name": "privacy_version", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "gdpr_accepted_at": { + "name": "gdpr_accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "users_uuid_idx": { + "name": "users_uuid_idx", + "columns": [ + { + "expression": "uuid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_hash_idx": { + "name": "users_email_hash_idx", + "columns": [ + { + "expression": "email_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_username_idx": { + "name": "users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_status_idx": { + "name": "users_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_hash_unique": { + "name": "users_email_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "email_hash" + ] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.content_type": { + "name": "content_type", + "schema": "public", + "values": [ + "meme", + "gif" + ] + }, + "public.report_reason": { + "name": "report_reason", + "schema": "public", + "values": [ + "inappropriate", + "spam", + "copyright", + "other" + ] + }, + "public.report_status": { + "name": "report_status", + "schema": "public", + "values": [ + "pending", + "reviewed", + "resolved", + "dismissed" + ] + }, + "public.user_status": { + "name": "user_status", + "schema": "public", + "values": [ + "active", + "verification", + "suspended", + "pending", + "deleted" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/.migrations/meta/_journal.json b/backend/.migrations/meta/_journal.json index d88fd1c..cd41171 100644 --- a/backend/.migrations/meta/_journal.json +++ b/backend/.migrations/meta/_journal.json @@ -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 } ] } \ No newline at end of file -- 2.49.1 From fb7ddde42e50b0eb8cfc7a30f7ce26565fc2f360 Mon Sep 17 00:00:00 2001 From: Mathis HERRIOT <197931332+0x485254@users.noreply.github.com> Date: Wed, 14 Jan 2026 21:43:27 +0100 Subject: [PATCH 03/22] 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`. --- .../app/(dashboard)/admin/categories/page.tsx | 72 +++++++ .../app/(dashboard)/admin/contents/page.tsx | 137 +++++++++++++ frontend/src/app/(dashboard)/admin/page.tsx | 85 ++++++++ .../src/app/(dashboard)/admin/users/page.tsx | 123 ++++++++++++ frontend/src/app/(dashboard)/help/page.tsx | 72 +++++++ frontend/src/app/(dashboard)/profile/page.tsx | 56 +++++- frontend/src/app/(dashboard)/recent/page.tsx | 21 +- .../src/app/(dashboard)/settings/page.tsx | 185 ++++++++++++++++++ frontend/src/app/(dashboard)/trends/page.tsx | 21 +- .../app/(dashboard)/user/[username]/page.tsx | 98 ++++++++++ 10 files changed, 842 insertions(+), 28 deletions(-) create mode 100644 frontend/src/app/(dashboard)/admin/categories/page.tsx create mode 100644 frontend/src/app/(dashboard)/admin/contents/page.tsx create mode 100644 frontend/src/app/(dashboard)/admin/page.tsx create mode 100644 frontend/src/app/(dashboard)/admin/users/page.tsx create mode 100644 frontend/src/app/(dashboard)/help/page.tsx create mode 100644 frontend/src/app/(dashboard)/settings/page.tsx create mode 100644 frontend/src/app/(dashboard)/user/[username]/page.tsx diff --git a/frontend/src/app/(dashboard)/admin/categories/page.tsx b/frontend/src/app/(dashboard)/admin/categories/page.tsx new file mode 100644 index 0000000..a1acb88 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/categories/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + CategoryService.getAll() + .then(setCategories) + .catch(err => console.error(err)) + .finally(() => setLoading(false)); + }, []); + + return ( +
+
+

Catégories ({categories.length})

+
+
+ + + + Nom + Slug + Description + + + + {loading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + + + + + )) + ) : categories.length === 0 ? ( + + + Aucune catégorie trouvée. + + + ) : ( + categories.map((category) => ( + + {category.name} + {category.slug} + + {category.description || "Aucune description"} + + + )) + )} + +
+
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/contents/page.tsx b/frontend/src/app/(dashboard)/admin/contents/page.tsx new file mode 100644 index 0000000..f07d495 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/contents/page.tsx @@ -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([]); + 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 ( +
+
+

Contenus ({totalCount})

+
+
+ + + + Contenu + Catégorie + Auteur + Stats + Date + + + + + {loading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + + + + + + + )) + ) : contents.length === 0 ? ( + + + Aucun contenu trouvé. + + + ) : ( + contents.map((content) => ( + + +
+
+ {content.type === "image" ? ( + + ) : ( +
+
+
{content.title}
+
{content.type} • {content.mimeType}
+
+
+
+ + {content.category.name} + + + @{content.author.username} + + +
+
+ {content.views} +
+
+ {content.usageCount} +
+
+
+ + {format(new Date(content.createdAt), "dd/MM/yyyy", { locale: fr })} + + + + +
+ )) + )} +
+
+
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/page.tsx b/frontend/src/app/(dashboard)/admin/page.tsx new file mode 100644 index 0000000..1043e24 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + adminService + .getStats() + .then(setStats) + .catch((err) => { + console.error(err); + setError("Impossible de charger les statistiques."); + }) + .finally(() => setLoading(false)); + }, []); + + if (error) { + return ( +
+ +

{error}

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

Dashboard Admin

+
+
+ {statCards.map((card) => ( + + + + {card.title} + + + + {loading ? ( + + ) : ( +
{card.value}
+ )} +
+
+ + ))} +
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/users/page.tsx b/frontend/src/app/(dashboard)/admin/users/page.tsx new file mode 100644 index 0000000..48df000 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/users/page.tsx @@ -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([]); + 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 ( +
+
+

Utilisateurs ({totalCount})

+
+
+ + + + Utilisateur + Email + Rôle + Status + Date d'inscription + + + + + {loading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + + + + + + + )) + ) : users.length === 0 ? ( + + + Aucun utilisateur trouvé. + + + ) : ( + users.map((user) => ( + + + {user.displayName || user.username} +
@{user.username}
+
+ {user.email} + + + {user.role} + + + + + {user.status} + + + + {format(new Date(user.createdAt), "PPP", { locale: fr })} + + + + +
+ )) + )} +
+
+
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/help/page.tsx b/frontend/src/app/(dashboard)/help/page.tsx new file mode 100644 index 0000000..53f66b3 --- /dev/null +++ b/frontend/src/app/(dashboard)/help/page.tsx @@ -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 ( +
+
+
+ +
+

Centre d'aide

+
+ +
+

Foire Aux Questions

+ + {faqs.map((faq, index) => ( + + + {faq.question} + + + {faq.answer} + + + ))} + +
+ +
+

Vous ne trouvez pas de réponse ?

+

+ N'hésitez pas à nous contacter sur nos réseaux sociaux ou par email. +

+

contact@memegoat.local

+
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/profile/page.tsx b/frontend/src/app/(dashboard)/profile/page.tsx index e2f6765..3eda480 100644 --- a/frontend/src/app/(dashboard)/profile/page.tsx +++ b/frontend/src/app/(dashboard)/profile/page.tsx @@ -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(null); + + const handleAvatarClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = async (event: React.ChangeEvent) => { + 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() {
- - - - {user.username.slice(0, 2).toUpperCase()} - - +
+ + + + {user.username.slice(0, 2).toUpperCase()} + + + + +

@@ -85,6 +122,9 @@ export default function ProfilePage() {

@{user.username}

+ {user.bio && ( +

{user.bio}

+ )}
diff --git a/frontend/src/app/(dashboard)/recent/page.tsx b/frontend/src/app/(dashboard)/recent/page.tsx index 328500b..fd5aedc 100644 --- a/frontend/src/app/(dashboard)/recent/page.tsx +++ b/frontend/src/app/(dashboard)/recent/page.tsx @@ -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 ( + Chargement des nouveautés...
}> + + ); - - return ; } diff --git a/frontend/src/app/(dashboard)/settings/page.tsx b/frontend/src/app/(dashboard)/settings/page.tsx new file mode 100644 index 0000000..6a3809d --- /dev/null +++ b/frontend/src/app/(dashboard)/settings/page.tsx @@ -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; + +export default function SettingsPage() { + const { user, isLoading, refreshUser } = useAuth(); + const [isSaving, setIsSaving] = React.useState(false); + + const form = useForm({ + 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 ( +
+ +
+ ); + } + + if (!user) { + return ( +
+ + + Accès refusé + + Vous devez être connecté pour accéder aux paramètres. + + + +
+ ); + } + + 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 ( +
+
+
+ +
+

Paramètres du profil

+
+ + + + Informations personnelles + + Mettez à jour vos informations publiques. Ces données seront visibles par les autres utilisateurs. + + + +
+ +
+ + Nom d'utilisateur + + + + + Le nom d'utilisateur ne peut pas être modifié. + + + + ( + + Nom d'affichage + + + + + Le nom qui sera affiché sur votre profil et vos mèmes. + + + + )} + /> + + ( + + Bio + +