Merge pull request 'UI & Feature update - Alpha' (#9) from dev into prod
Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
2
backend/.migrations/0006_friendly_adam_warlock.sql
Normal file
2
backend/.migrations/0006_friendly_adam_warlock.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "users" ADD COLUMN "avatar_url" varchar(512);--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "bio" varchar(255);
|
||||
1652
backend/.migrations/meta/0006_snapshot.json
Normal file
1652
backend/.migrations/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -107,7 +107,7 @@
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node",
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!(jose|@noble)/)"
|
||||
"node_modules/(?!(.pnpm/)?(jose|@noble|uuid)/)"
|
||||
],
|
||||
"transform": {
|
||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
||||
|
||||
17
backend/src/admin/admin.controller.ts
Normal file
17
backend/src/admin/admin.controller.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Controller, Get, UseGuards } from "@nestjs/common";
|
||||
import { Roles } from "../auth/decorators/roles.decorator";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { RolesGuard } from "../auth/guards/roles.guard";
|
||||
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();
|
||||
}
|
||||
}
|
||||
14
backend/src/admin/admin.module.ts
Normal file
14
backend/src/admin/admin.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { CategoriesModule } from "../categories/categories.module";
|
||||
import { ContentsModule } from "../contents/contents.module";
|
||||
import { UsersModule } from "../users/users.module";
|
||||
import { AdminController } from "./admin.controller";
|
||||
import { AdminService } from "./admin.service";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, UsersModule, ContentsModule, CategoriesModule],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
27
backend/src/admin/admin.service.ts
Normal file
27
backend/src/admin/admin.service.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { CategoriesRepository } from "../categories/repositories/categories.repository";
|
||||
import { ContentsRepository } from "../contents/repositories/contents.repository";
|
||||
import { UsersRepository } from "../users/repositories/users.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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { ThrottlerModule } from "@nestjs/throttler";
|
||||
import { redisStore } from "cache-manager-redis-yet";
|
||||
import { AdminModule } from "./admin/admin.module";
|
||||
import { ApiKeysModule } from "./api-keys/api-keys.module";
|
||||
import { AppController } from "./app.controller";
|
||||
import { AppService } from "./app.service";
|
||||
@@ -42,6 +43,7 @@ import { UsersModule } from "./users/users.module";
|
||||
SessionsModule,
|
||||
ReportsModule,
|
||||
ApiKeysModule,
|
||||
AdminModule,
|
||||
ScheduleModule.forRoot(),
|
||||
ThrottlerModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
jest.mock("uuid", () => ({
|
||||
v4: jest.fn(() => "mocked-uuid"),
|
||||
}));
|
||||
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
|
||||
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
|
||||
|
||||
39
backend/src/auth/guards/optional-auth.guard.ts
Normal file
39
backend/src/auth/guards/optional-auth.guard.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface IMediaService {
|
||||
processImage(
|
||||
buffer: Buffer,
|
||||
format?: "webp" | "avif",
|
||||
resize?: { width?: number; height?: number },
|
||||
): Promise<MediaProcessingResult>;
|
||||
processVideo(
|
||||
buffer: Buffer,
|
||||
|
||||
@@ -19,8 +19,11 @@ import {
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import type { Request, Response } from "express";
|
||||
import type { Response } from "express";
|
||||
import { Roles } from "../auth/decorators/roles.decorator";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { OptionalAuthGuard } from "../auth/guards/optional-auth.guard";
|
||||
import { RolesGuard } from "../auth/guards/roles.guard";
|
||||
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,57 @@ 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 +178,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -135,11 +135,20 @@ 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 +224,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 +236,31 @@ 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 +269,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 +395,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()
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("ReportsService", () => {
|
||||
describe("create", () => {
|
||||
it("should create a report", async () => {
|
||||
const reporterId = "u1";
|
||||
const data = { contentId: "c1", reason: "spam" };
|
||||
const data = { contentId: "c1", reason: "spam" } as const;
|
||||
mockReportsRepository.create.mockResolvedValue({
|
||||
id: "r1",
|
||||
...data,
|
||||
|
||||
@@ -5,4 +5,13 @@ export class UpdateUserDto {
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
displayName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
bio?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -66,7 +68,9 @@ export class UsersRepository {
|
||||
.select({
|
||||
uuid: users.uuid,
|
||||
username: users.username,
|
||||
email: users.email,
|
||||
displayName: users.displayName,
|
||||
avatarUrl: users.avatarUrl,
|
||||
status: users.status,
|
||||
createdAt: users.createdAt,
|
||||
})
|
||||
@@ -81,6 +85,8 @@ export class UsersRepository {
|
||||
uuid: users.uuid,
|
||||
username: users.username,
|
||||
displayName: users.displayName,
|
||||
avatarUrl: users.avatarUrl,
|
||||
bio: users.bio,
|
||||
createdAt: users.createdAt,
|
||||
})
|
||||
.from(users)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
jest.mock("uuid", () => ({
|
||||
v4: jest.fn(() => "mocked-uuid"),
|
||||
}));
|
||||
|
||||
jest.mock("@noble/post-quantum/ml-kem.js", () => ({
|
||||
ml_kem768: {
|
||||
keygen: jest.fn(),
|
||||
@@ -12,7 +16,11 @@ jest.mock("jose", () => ({
|
||||
}));
|
||||
|
||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { RbacService } from "../auth/rbac.service";
|
||||
import { MediaService } from "../media/media.service";
|
||||
import { S3Service } from "../s3/s3.service";
|
||||
import { UsersRepository } from "./repositories/users.repository";
|
||||
import { UsersService } from "./users.service";
|
||||
|
||||
@@ -39,6 +47,23 @@ describe("UsersService", () => {
|
||||
del: jest.fn(),
|
||||
};
|
||||
|
||||
const mockRbacService = {
|
||||
getUserRoles: jest.fn(),
|
||||
};
|
||||
|
||||
const mockMediaService = {
|
||||
scanFile: jest.fn(),
|
||||
processImage: jest.fn(),
|
||||
};
|
||||
|
||||
const mockS3Service = {
|
||||
uploadFile: jest.fn(),
|
||||
};
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
@@ -47,6 +72,10 @@ describe("UsersService", () => {
|
||||
UsersService,
|
||||
{ provide: UsersRepository, useValue: mockUsersRepository },
|
||||
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
|
||||
{ provide: RbacService, useValue: mockRbacService },
|
||||
{ provide: MediaService, useValue: mockMediaService },
|
||||
{ provide: S3Service, useValue: mockS3Service },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
||||
import {
|
||||
BadRequestException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
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 +24,11 @@ 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 readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
private async clearUserCache(username?: string) {
|
||||
@@ -33,7 +51,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 +72,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 +104,47 @@ 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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { ViewCounter } from "@/components/view-counter";
|
||||
import { ContentService } from "@/services/content.service";
|
||||
import type { Content } from "@/types/content";
|
||||
|
||||
@@ -45,6 +46,7 @@ export default function MemeModal({
|
||||
</div>
|
||||
) : content ? (
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
|
||||
<ViewCounter contentId={content.id} />
|
||||
<ContentCard content={content} />
|
||||
</div>
|
||||
) : (
|
||||
|
||||
83
frontend/src/app/(dashboard)/admin/categories/page.tsx
Normal file
83
frontend/src/app/(dashboard)/admin/categories/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { CategoryService } from "@/services/category.service";
|
||||
import type { Category } from "@/types/content";
|
||||
|
||||
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) => (
|
||||
/* biome-ignore lint/suspicious/noArrayIndexKey: skeleton items don't have unique IDs */
|
||||
<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>
|
||||
);
|
||||
}
|
||||
152
frontend/src/app/(dashboard)/admin/contents/page.tsx
Normal file
152
frontend/src/app/(dashboard)/admin/contents/page.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { Download, Eye, Image as ImageIcon, Trash2, Video } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ContentService } from "@/services/content.service";
|
||||
import type { Content } from "@/types/content";
|
||||
|
||||
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.totalCount);
|
||||
})
|
||||
.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) => (
|
||||
/* biome-ignore lint/suspicious/noArrayIndexKey: skeleton items don't have unique IDs */
|
||||
<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 || "Sans catégorie"}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
85
frontend/src/app/(dashboard)/admin/page.tsx
Normal file
85
frontend/src/app/(dashboard)/admin/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, FileText, LayoutGrid, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { type AdminStats, adminService } from "@/services/admin.service";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
141
frontend/src/app/(dashboard)/admin/users/page.tsx
Normal file
141
frontend/src/app/(dashboard)/admin/users/page.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { UserService } from "@/services/user.service";
|
||||
import type { User } from "@/types/user";
|
||||
|
||||
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) => (
|
||||
/* biome-ignore lint/suspicious/noArrayIndexKey: skeleton items don't have unique IDs */
|
||||
<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>
|
||||
);
|
||||
}
|
||||
70
frontend/src/app/(dashboard)/help/page.tsx
Normal file
70
frontend/src/app/(dashboard)/help/page.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
|
||||
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={faq.question} 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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ContentCard } from "@/components/content-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ViewCounter } from "@/components/view-counter";
|
||||
import { ContentService } from "@/services/content.service";
|
||||
|
||||
export const revalidate = 3600; // ISR: Revalider toutes les heures
|
||||
@@ -40,6 +41,7 @@ export default async function MemePage({
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto py-8 px-4">
|
||||
<ViewCounter contentId={content.id} />
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center text-sm mb-6 hover:text-primary transition-colors"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"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";
|
||||
import { toast } from "sonner";
|
||||
import { ContentList } from "@/components/content-list";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -19,11 +20,33 @@ 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";
|
||||
|
||||
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 +95,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 +124,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" />
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
"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" />;
|
||||
}
|
||||
|
||||
190
frontend/src/app/(dashboard)/settings/page.tsx
Normal file
190
frontend/src/app/(dashboard)/settings/page.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
"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 { Spinner } from "@/components/ui/spinner";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
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.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>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
"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" />;
|
||||
}
|
||||
|
||||
98
frontend/src/app/(dashboard)/user/[username]/page.tsx
Normal file
98
frontend/src/app/(dashboard)/user/[username]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,37 @@ 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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
CardHeader,
|
||||
} from "@/components/ui/card";
|
||||
import { useAuth } from "@/providers/auth-provider";
|
||||
import { ContentService } from "@/services/content.service";
|
||||
import { FavoriteService } from "@/services/favorite.service";
|
||||
import type { Content } from "@/types/content";
|
||||
|
||||
@@ -26,9 +27,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 +60,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 +135,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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { CategoryService } from "@/services/category.service";
|
||||
import type { Category } from "@/types/content";
|
||||
import { TagService } from "@/services/tag.service";
|
||||
import type { Category, Tag } from "@/types/content";
|
||||
|
||||
export function MobileFilters() {
|
||||
const router = useRouter();
|
||||
@@ -24,12 +25,16 @@ export function MobileFilters() {
|
||||
const pathname = usePathname();
|
||||
|
||||
const [categories, setCategories] = React.useState<Category[]>([]);
|
||||
const [popularTags, setPopularTags] = React.useState<Tag[]>([]);
|
||||
const [query, setQuery] = React.useState(searchParams.get("query") || "");
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
CategoryService.getAll().then(setCategories).catch(console.error);
|
||||
TagService.getAll({ limit: 10, sort: "popular" })
|
||||
.then(setPopularTags)
|
||||
.catch(console.error);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
@@ -127,19 +132,25 @@ export function MobileFilters() {
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3">Tags populaires</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{["funny", "coding", "cat", "dog", "work", "relatable", "gaming"].map(
|
||||
(tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant={searchParams.get("tag") === tag ? "default" : "outline"}
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
updateSearch("tag", searchParams.get("tag") === tag ? null : tag)
|
||||
}
|
||||
>
|
||||
#{tag}
|
||||
</Badge>
|
||||
),
|
||||
{popularTags.map((tag) => (
|
||||
<Badge
|
||||
key={tag.id}
|
||||
variant={
|
||||
searchParams.get("tag") === tag.name ? "default" : "outline"
|
||||
}
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
updateSearch(
|
||||
"tag",
|
||||
searchParams.get("tag") === tag.name ? null : tag.name,
|
||||
)
|
||||
}
|
||||
>
|
||||
#{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
{popularTags.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">Aucun tag trouvé.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,8 @@ import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { CategoryService } from "@/services/category.service";
|
||||
import type { Category } from "@/types/content";
|
||||
import { TagService } from "@/services/tag.service";
|
||||
import type { Category, Tag } from "@/types/content";
|
||||
|
||||
export function SearchSidebar() {
|
||||
const router = useRouter();
|
||||
@@ -16,10 +17,14 @@ export function SearchSidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
const [categories, setCategories] = React.useState<Category[]>([]);
|
||||
const [popularTags, setPopularTags] = React.useState<Tag[]>([]);
|
||||
const [query, setQuery] = React.useState(searchParams.get("query") || "");
|
||||
|
||||
React.useEffect(() => {
|
||||
CategoryService.getAll().then(setCategories).catch(console.error);
|
||||
TagService.getAll({ limit: 10, sort: "popular" })
|
||||
.then(setPopularTags)
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
const updateSearch = React.useCallback(
|
||||
@@ -116,19 +121,23 @@ export function SearchSidebar() {
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3">Tags populaires</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{["funny", "coding", "cat", "dog", "work", "relatable", "gaming"].map(
|
||||
(tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant={searchParams.get("tag") === tag ? "default" : "outline"}
|
||||
className="cursor-pointer hover:bg-secondary"
|
||||
onClick={() =>
|
||||
updateSearch("tag", searchParams.get("tag") === tag ? null : tag)
|
||||
}
|
||||
>
|
||||
#{tag}
|
||||
</Badge>
|
||||
),
|
||||
{popularTags.map((tag) => (
|
||||
<Badge
|
||||
key={tag.id}
|
||||
variant={searchParams.get("tag") === tag.name ? "default" : "outline"}
|
||||
className="cursor-pointer hover:bg-secondary"
|
||||
onClick={() =>
|
||||
updateSearch(
|
||||
"tag",
|
||||
searchParams.get("tag") === tag.name ? null : tag.name,
|
||||
)
|
||||
}
|
||||
>
|
||||
#{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
{popularTags.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">Aucun tag trouvé.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -53,8 +53,6 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
|
||||
@@ -26,6 +26,7 @@ function ButtonGroup({
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: standard pattern for button groups
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
|
||||
@@ -117,6 +117,7 @@ function Carousel({
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/useSemanticElements: standard pattern for carousels */}
|
||||
<div
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
@@ -156,6 +157,7 @@ function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: standard pattern for carousel items
|
||||
<div
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
|
||||
@@ -83,6 +83,7 @@ function Field({
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: standard pattern for field components
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
|
||||
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: standard pattern for input groups
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
@@ -62,6 +63,7 @@ function InputGroupAddon({
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: standard pattern for input groups
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
|
||||
@@ -68,7 +68,7 @@ function InputOTPSlot({
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||
<div data-slot="input-otp-separator" aria-hidden="true" {...props}>
|
||||
<MinusIcon />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: standard pattern for item groups
|
||||
<div
|
||||
role="list"
|
||||
data-slot="item-group"
|
||||
|
||||
@@ -82,6 +82,7 @@ function SidebarProvider({
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
// biome-ignore lint/suspicious/noDocumentCookie: persistence of sidebar state
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
[setOpenProp, open],
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { LogIn, User as UserIcon } from "lucide-react";
|
||||
import { LogIn } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/providers/auth-provider";
|
||||
|
||||
23
frontend/src/components/view-counter.tsx
Normal file
23
frontend/src/components/view-counter.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { ContentService } from "@/services/content.service";
|
||||
|
||||
interface ViewCounterProps {
|
||||
contentId: string;
|
||||
}
|
||||
|
||||
export function ViewCounter({ contentId }: ViewCounterProps) {
|
||||
const hasIncremented = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasIncremented.current) {
|
||||
ContentService.incrementViews(contentId).catch((err) => {
|
||||
console.error("Failed to increment views:", err);
|
||||
});
|
||||
hasIncremented.current = true;
|
||||
}
|
||||
}, [contentId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -8,6 +8,23 @@ const api = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
// Interceptor for Server-Side Rendering to pass cookies
|
||||
api.interceptors.request.use(async (config) => {
|
||||
if (typeof window === "undefined") {
|
||||
try {
|
||||
const { cookies } = await import("next/headers");
|
||||
const cookieStore = await cookies();
|
||||
const cookieHeader = cookieStore.toString();
|
||||
if (cookieHeader) {
|
||||
config.headers.Cookie = cookieHeader;
|
||||
}
|
||||
} catch (_error) {
|
||||
// Fail silently if cookies() is not available (e.g. during build)
|
||||
}
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Système anti-spam rudimentaire pour les erreurs répétitives
|
||||
const errorCache = new Map<string, number>();
|
||||
const SPAM_THRESHOLD_MS = 2000; // 2 secondes de silence après une erreur sur le même endpoint
|
||||
@@ -19,14 +36,35 @@ api.interceptors.response.use(
|
||||
errorCache.delete(url);
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Handle Token Refresh (401 Unauthorized)
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
!originalRequest._retry &&
|
||||
!originalRequest.url?.includes("/auth/refresh") &&
|
||||
!originalRequest.url?.includes("/auth/login")
|
||||
) {
|
||||
originalRequest._retry = true;
|
||||
try {
|
||||
await api.post("/auth/refresh");
|
||||
return api(originalRequest);
|
||||
} catch (refreshError) {
|
||||
// If refresh fails, we might want to redirect to login on the client
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
const url = error.config?.url || "unknown";
|
||||
const now = Date.now();
|
||||
const lastErrorTime = errorCache.get(url);
|
||||
|
||||
if (lastErrorTime && now - lastErrorTime < SPAM_THRESHOLD_MS) {
|
||||
// Ignorer l'erreur si elle se produit trop rapidement (déjà signalée)
|
||||
// On retourne une promesse qui ne se résout jamais ou on rejette avec une marque spéciale
|
||||
return new Promise(() => {});
|
||||
}
|
||||
|
||||
|
||||
14
frontend/src/services/admin.service.ts
Normal file
14
frontend/src/services/admin.service.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import api from "@/lib/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;
|
||||
},
|
||||
};
|
||||
@@ -61,4 +61,8 @@ export const ContentService = {
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async removeAdmin(id: string): Promise<void> {
|
||||
await api.delete(`/contents/${id}/admin`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -14,9 +14,15 @@ export const FavoriteService = {
|
||||
limit: number;
|
||||
offset: number;
|
||||
}): Promise<PaginatedResponse<Content>> {
|
||||
const { data } = await api.get<PaginatedResponse<Content>>("/favorites", {
|
||||
params,
|
||||
});
|
||||
const { data } = await api.get<PaginatedResponse<Content>>(
|
||||
"/contents/explore",
|
||||
{
|
||||
params: {
|
||||
...params,
|
||||
favoritesOnly: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
16
frontend/src/services/tag.service.ts
Normal file
16
frontend/src/services/tag.service.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import api from "@/lib/api";
|
||||
import type { Tag } from "@/types/content";
|
||||
|
||||
export const TagService = {
|
||||
async getAll(
|
||||
params: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
query?: string;
|
||||
sort?: "popular" | "recent";
|
||||
} = {},
|
||||
): Promise<Tag[]> {
|
||||
const { data } = await api.get<Tag[]>("/tags", { params });
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface RegisterPayload {
|
||||
export interface AuthStatus {
|
||||
isAuthenticated: boolean;
|
||||
user: null | {
|
||||
id: string;
|
||||
uuid: string;
|
||||
username: string;
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
id?: string;
|
||||
uuid: string;
|
||||
username: string;
|
||||
email: string;
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
role: "user" | "admin";
|
||||
bio?: string;
|
||||
role?: "user" | "admin";
|
||||
status?: "active" | "verification" | "suspended" | "pending" | "deleted";
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface UserProfile extends User {
|
||||
bio?: string;
|
||||
favoritesCount: number;
|
||||
uploadsCount: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user